spring
spring-boot
spring-mvc

SpringでバージョニングされたAPIのエンドポイントを定義する(1)

More than 1 year has passed since last update.


バージョニングされたAPI

REST-APIでバージョニングっていうのはよくある要件です。

今回はURLにバージョン番号があるREST-APIの実現方法について考えてみます。


やりたいこと


@1.0

GET /1.0/items/1



@2.0

GET /2.0/items/1


この2つのAPI、戻りの型が違ってる。だけど、


@1.1

GET /1.1/items/1


これだと1.0の挙動をそのまま引き継ぎたい。

みたいな感じにしたい時に、全バージョン毎にコントローラメソッドを定義するのはしんどい。

だからAPIのバージョンを動的に判定して、マッピングの条件に入れてやる。


RequestCondition

Springにはマッピング条件をカスタムで追加できる機能がある。

org.springframework.web.servlet.mvc.condition.RequestConditionを実装してAPIのバージョニングをしてみる。


ApiVersionRequestCondition.java

@RequiredArgsConstructor

public class ApiVersionRequestCondition implements RequestCondition<ApiVersionRequestCondition> {

private static final AntPathMatcher PATH_MATCHER = new AntPathMatcher();
// マイナーバージョンまでしか対応しない
private static final String VERSIONED_PATH_PATTERN = "/{version:\\d+\\.\\d+}/**";

// サポートするAPIのバージョンを範囲のセットで定義する
@NonNull
private final Set<Range<Version>> supported;

}


こんな形で定義する。

で、RequestConditionを実装する。

条件繋げる時にどう繋げるかっていうのを定義する。


#combine(ApiVersionRequestCondition)

@Override

public ApiVersionRequestCondition combine(ApiVersionRequestCondition other) {
// 結合する場合はどっちのバージョンもサポートする
return new ApiVersionRequestCondition(ImmutableSet.<Range<Version>> builder()
.addAll(this.supported)
.addAll(other.supported)
.build());
}

次にリクエストされたURLがマッチするか判定するロジックを実装する。


#getMatchingCondition(HttpServletRequest)

@Override

public ApiVersionRequestCondition getMatchingCondition(HttpServletRequest request) {
// APIのパスからバージョンを特定する
Version apiVersion = getRequestApiVersion(request);
if (apiVersion == null) {
// バージョンが特定できなければnullを返して、この条件にはマッチしなかったことを伝える
return null;
}

// バージョンがサポートされていればこのインスタンスを返し、
// そうでなければnullを返して、この条件にはマッチしなかったことを伝える
return this.supported.stream().anyMatch(range -> range.contains(apiVersion))
? this
: null;
}

private static Version getRequestApiVersion(HttpServletRequest request) {
return Optional.ofNullable(PATH_MATCHER.extractUriTemplateVariables(pathPattern, request.getRequestURI()))
.map(map -> map.get("version"))
.map(version -> {
try {
return Version.parse(version); // 自前のVersionクラス
} catch (Exception e) {
return null;
}
})
.orElse(null);
}


次に複数マッチした場合、優先する方を選ぶためにcompareToを実装する。

複数マッチしたらエラーにしたいのだが。。とりあえず。


#compareTo(ApiVersionRequestCondition,HttpServletRequest)

public int compareTo(ApiVersionRequestCondition other, HttpServletRequest request) {

// サポートされているバージョンの条件が多い方を優先する
return other.supported.size() - this.supported.size();
}

ここまでが実際にリクエストされた際に処理されるロジック。

ここからは起動時にマッピング定義を追加する実装になります。


@ApiVersionアノテーション

コントローラにマッピングルールを追加するためのアノテーション。


ApiVersion.java

@Target(ElementType.METHOD)

@Retention(RetentionPolicy.RUNTIME)
public @interface ApiVersion {
// ピンポイントでサポートするバージョン
@AliasFor("supported")
String[] value() default {};
// #value()のエイリアス
@AliasFor("value")
String[] supported() default {};
// 最低バージョン。これ以上のバージョンをサポート。
String atLeast() default "";
// 最高バージョン。これ以下のバージョンをサポート。
String atMost() default "";
// これ未満のバージョンをサポート
String lessThan() default "";
// これより高いバージョンをサポート
String greaterThan() default "";
}


RequestMappingHandlerMappingの実装

カスタムで用意した条件をマッピングルールに追加するためのクラス。

RequestMappingHandlerMappingを継承して実装する。


#getCustomMethodCondition(Method)&#getCustomTypeCondition(Class)

@Override

protected RequestCondition<?> getCustomMethodCondition(Method method) {
// アノテーションが付与されている場合のみ条件を追加する
if (method.isAnnotationPresent(ApiVersion.class)) {
return this.createApiVersionCondition(AnnotationUtils.findAnnotation(method, ApiVersion.class));
}

return null;
}


次に@ApiVersionから先述で実装したApiVersionRequestConditionのインスタンスを作成する。

private ApiVersionRequestCondition createApiVersionCondition(@Nonnull ApiVersion apiVersion) {

return new ApiVersionRequestCondition(this.createVersionRangeSet(apiVersion));
}

private Set<Range<Version>> createVersionRangeSet(@Nonnull ApiVersion apiVersion) {
// ピンポイントでサポートするバージョン
final String[] supported = apiVersion.value();

if (ArrayUtils.isNotEmpty(supported)) {
// これが指定されていたらこのバージョンしかサポートしない
return Arrays.stream(supported)
.map(Version::parse)
.map(Range::singleton)
.collect(Collectors.toSet());
}

// --- 以下ズラズラ長いけど、 com.google.common.collect.Rangeを作ってるだけ --- //

final String atLeast = apiVersion.atLeast();
final String atMost = apiVersion.atMost();
final String lessThan = apiVersion.lessThan();
final String greaterThan = apiVersion.greaterThan();

final BoundType lowerBoundType = StringUtils.isNotBlank(atMost)
? BoundType.CLOSED
: StringUtils.isNotBlank(greaterThan)
? BoundType.OPEN
: null;
final BoundType upperBoundType = StringUtils.isNotBlank(atLeast)
? BoundType.CLOSED
: StringUtils.isNotBlank(lessThan)
? BoundType.OPEN
: null;
final String lowerVersion = StringUtils.isNotBlank(atMost)
? atMost
: StringUtils.isNotBlank(greaterThan)
? greaterThan
: null;
final String upperVersion = StringUtils.isNotBlank(atLeast)
? atLeast
: StringUtils.isNotBlank(lessThan)
? lessThan
: null;

final Range<Version> versionRange = (StringUtils.isNotBlank(lowerVersion) && StringUtils.isNotBlank(upperVersion))
? Range.range(Version.parse(lowerVersion), lowerBoundType, Version.parse(upperVersion), upperBoundType)
: StringUtils.isNotBlank(lowerVersion)
? Range.downTo(Version.parse(lowerVersion), lowerBoundType)
: StringUtils.isNotBlank(upperVersion)
? Range.upTo(Version.parse(upperVersion), upperBoundType)
: null;

if (versionRange == null) {
return Collections.emptySet();
}

return Collections.singleton(versionRange);
}

次に、定義されたマッピング条件のマッピング情報を生成するためのロジック。

ちょっとしんどくなってきた。。

@Override

protected RequestMappingInfo getMappingForMethod(Method method, Class<?> handlerType) {
// 本来作られるマッピング情報をスーパークラスがから生成
RequestMappingInfo info = super.getMappingForMethod(method, handlerType);

// メソッドにアノテーションが付与されていたらカスタムのマッピング情報に繋げる
if (AnnotationUtils.findAnnotation(method, ApiVersion.class) != null) {
RequestCondition<?> methodCondition = this.getCustomMethodCondition(method);
return this.createApiVersionInfo(methodCondition).combine(info);
}
return info;
}

// カスタムのコンディションからマッピング情報を生成する
private RequestMappingInfo createApiVersionInfo(RequestCondition<?> customCondition) {
// ここは起動時しか実行されないから定数にしない
// サポートするバージョンはマイナーバージョンまで
String pathPattern = "{version:\\d+\\.\\d+}";

// パスマッチングな条件を生成する
PatternsRequestCondition patternCondtion = new PatternsRequestCondition(new String[] { pathPattern },
this.getUrlPathHelper(), this.getPathMatcher(), this.useSuffixPatternMatch(), this.useTrailingSlashMatch(),
this.getFileExtensions());

// 新たなマッピング情報に喰わせてインスタンス生成完了
// その他余計な情報は指定しない。追加条件はパスマッチングとマッチした後のバージョン判定のみ。
return new RequestMappingInfo(patternCondtion, null, null, null, null, null, customCondition);
}

このクラスがあれば、コントローラに付与された@ApiVersionの設定によってマッピング条件を変えてくれる。

あとは、このクラスを登録するだけ。

通常使われるRequestMappingHandlerMappingではなく、実装したVersionedRequestMappingHandlerMappingを使うようにしてあげる。


WebMvcConfig.java

@Configuration

public class WebMvcConfig extends DelegatingWebMvcConfiguration {

@Override
public VersionedRequestMappingHandlerMapping requestMappingHandlerMapping() {
return new VersionedRequestMappingHandlerMapping();
}

}


これで準備完了。


検証用コントローラ

@RestController

public class DemoController {

@GetMapping("/items/{id}")
@ApiVersion("1.0")
public Item1 getItem(@PathVariable Integer id) {
return Item1.builder()
.id(id)
.name("バッグ")
.build();
}

@GetMapping("/items/{id}")
@ApiVersion(greaterThan = "1.0")
public Item2 getItem(@PathVariable Long id) {
return Item2.builder()
.id(id)
.name("バッグ")
.price(10.9)
.build();
}

@GetMapping("/users/{id}")
@ApiVersion(supported = { "1.0", "3.1" })
public User getUser(@PathVariable Long id) {
return User.builder()
.id(id)
.name("Kenny")
.build();
}

}


DTO

@Builder

@Value
static class Item1 {
private Integer id;
private String name;
}

@Builder
@Value
static class Item2 {
private Long id;
private String name;
private Double price;
}

@Builder
@Value
static class User {
private Long id;
private String name;
}



検証


v1.0の/items

これは古いItemの型で返るはず。

$ curl http://localhost:8080/1.0/items/1 --silent | jq "."

{

"id": 1,
"name": "バッグ"
}


v1.1の/items

ここから新しい型。

$ curl http://localhost:8080/1.1/items/1 --silent | jq "."

{

"id": 1,
"name": "バッグ",
"price": 10.9
}


v2.0の/items

これも新しい型。

$ curl http://localhost:8080/2.0/items/1 --silent | jq "."

{

"id": 1,
"name": "バッグ",
"price": 10.9
}


v0.9の/items

サポートされていないバージョン。

$ curl http://localhost:8080/0.9/items/1 --silent | jq "."

エラー。マッピング条件にヒットしなかったため。

{

"timestamp": 1488022835360,
"status": 404,
"error": "Not Found",
"message": "No message available",
"path": "/0.9/items/1"
}


v1.1.1の/items

これはサポートされていない階層のバージョンを指定している。

$ curl http://localhost:8080/1.1.1/items/1 --silent | jq "."

範囲外で、パスマッチングでfalseになったから404。

{

"timestamp": 1488022842665,
"status": 404,
"error": "Not Found",
"message": "No message available",
"path": "/1.1.1/items/1"
}


v1.0の/users

ピンポイントで定義しているので、Userが返るはず。

$ curl http://localhost:8080/1.0/users/1 --silent | jq "."

{

"id": 1,
"name": "Kenny"
}


v3.0の/users

3.0は定義していないので404になる。

$ curl http://localhost:8080/3.0/users/1 --silent | jq "."

{

"timestamp": 1488022853760,
"status": 404,
"error": "Not Found",
"message": "No message available",
"path": "/3.0/users/1"
}


v3.1の/users

でも、何故か3.1はサポートしているのでUserが返ります。

$ curl http://localhost:8080/3.1/users/1 --silent | jq "."

{

"id": 1,
"name": "Kenny"
}


まとめ


  • 戻りの型を変えるくらいならJacksonのMix-Inで何とかできそう

  • バージョンによってのIf-Elseが無くなるのは良いと思うし、どのバージョンでどの動きになるかが見やすくなる


  • RequestMappingHandlerMappingの辺りがややこしい、、100%は理解できてないかも。

いつもながらGitHubに上げてます。


次回

SpringでバージョニングされたAPIのエンドポイントを定義する(2)RequestMappingHandlerMapping周りをもう少しスッキリさせて、コントローラのクラス側に付与した@ApiVersionに対応できるようにした方法をまとめました。