バージョニングされたAPI
REST-APIでバージョニングっていうのはよくある要件です。
今回はURLにバージョン番号があるREST-APIの実現方法について考えてみます。
やりたいこと
GET /1.0/items/1
GET /2.0/items/1
この2つのAPI、戻りの型が違ってる。だけど、
GET /1.1/items/1
これだと1.0
の挙動をそのまま引き継ぎたい。
みたいな感じにしたい時に、全バージョン毎にコントローラメソッドを定義するのはしんどい。
だからAPIのバージョンを動的に判定して、マッピングの条件に入れてやる。
RequestCondition
Springにはマッピング条件をカスタムで追加できる機能がある。
org.springframework.web.servlet.mvc.condition.RequestCondition
を実装してAPIのバージョニングをしてみる。
@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
を実装する。
条件繋げる時にどう繋げるかっていうのを定義する。
@Override
public ApiVersionRequestCondition combine(ApiVersionRequestCondition other) {
// 結合する場合はどっちのバージョンもサポートする
return new ApiVersionRequestCondition(ImmutableSet.<Range<Version>> builder()
.addAll(this.supported)
.addAll(other.supported)
.build());
}
次にリクエストされたURLがマッチするか判定するロジックを実装する。
@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.getServletPath()))
.map(map -> map.get("version"))
.map(version -> {
try {
return Version.parse(version); // 自前のVersionクラス
} catch (Exception e) {
return null;
}
})
.orElse(null);
}
次に複数マッチした場合、優先する方を選ぶためにcompareTo
を実装する。
複数マッチしたらエラーにしたいのだが。。とりあえず。
public int compareTo(ApiVersionRequestCondition other, HttpServletRequest request) {
// サポートされているバージョンの条件が多い方を優先する
return other.supported.size() - this.supported.size();
}
ここまでが実際にリクエストされた際に処理されるロジック。
ここからは起動時にマッピング定義を追加する実装になります。
@ApiVersion
アノテーション
コントローラにマッピングルールを追加するためのアノテーション。
@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
を継承して実装する。
@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(ApiVersion apiVersion) {
return new ApiVersionRequestCondition(this.createVersionRangeSet(apiVersion));
}
private Set<Range<Version>> createVersionRangeSet(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
を使うようにしてあげる。
@Configuration
public class WebMvcConfig extends DelegatingWebMvcConfiguration {
@Override
public VersionedRequestMappingHandlerMapping createRequestMappingHandlerMapping() {
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();
}
}
@Builder
@Value
static class Item1 {
Integer id;
String name;
}
@Builder
@Value
static class Item2 {
Long id;
String name;
Double price;
}
@Builder
@Value
static class User {
Long id;
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
に対応できるようにした方法をまとめました。