OpenAPI Generatorを使ったコードの自動生成とインタフェースの守り方
この記事は、自分の書いた下記記事を抜粋編集したものです。
OpenAPI コードから作るか コードを作るか
はじめに
この記事では、「OpenAPI Specification」と呼ばれる、REST APIの仕様を記述する文書から、クライアントやサーバのコードを自動生成するOpenAPI Generatorを中心に、下記に関して取り扱います。
- delegateを利用したGenerationGapデザインパターンにより、Controllerを自動生成コードでまかなう方法
- Consumer–Driven-ContractテストでIFをテストする方法
これらは、早いサイクルで機能追加、変更を繰り返しながらも、提供APIのI/Fを他チームに正確に伝えつつ、サービスのコンテキスト境界をより効率よく守るための工夫の話です。
個人的な試行錯誤の結果でもあるので、よりよい方法などあったらご紹介ください。
記事では、まずOpenAPIそのものに関して紹介します。
次に、API開発において継続的にインタフェースを守る難しさに関して説明します。
本題として、openapi-generator-maven-pluginを使用したAPI仕様書からのコード自動生成及び、自動生成コードの取り扱い方に関して説明します。動作するJavaのサンプルプロジェクトもあります。
(OpenAPIそのものは、Javaだけでなく本当に多くの言語、FW、ライブラリに対応しています)
おまけとして、APIの境界をテストするためにConsumer–Driven-Contractテストに関して説明します。
OpenAPIとはなにか
OpenAPIは、REST APIの設計/仕様書を記述するために生み出された一連のオープンソースツールを指します。現在の仕様はバージョン3系であり、OAS3.0とも呼ばれます。
また、バージョン2系の頃の名称であったSwaggerの名で呼ばれることもあります(厳密にはバージョン2と3ではSwaggerという単語の意味するものが変わっています)
OpenAPIはOpenAPI Specificationと呼ばれる、REST APIの仕様を記述する取り決めに従って仕様が策定されており、ドキュメントの自動作成はもちろん、Mockupの生成やコードの生成などもサポートしています。
継続的な開発と仕様書の整備
OpenAPI Specificationは、API仕様書として多くの企業で採用されており、私の職場でもマイクロサービス化されたそれぞれのコンポーネントがOpenAPIで記載されたドキュメントを公開しています。
APIを新たに呼ぶとき、機能が追加されるとき、当然そのAPI仕様書をベースに話をすすめるのですが、よくAPI仕様書と実装が乖離している場面に出くわします。
これは様々な理由が考えられますが、基本的にはヒューマンエラーです。いかに、Stoplightなどを使い、優れたGUIでAPI仕様書を記載しても、レビューするときにはGithubには無数のyamlの変更点が載るわけです。
少しの変更であればレビューで気づけますが、差し迫った状況下や炎上下では直接動作に関わらないドキュメントは後手に回ってしまいます。
そこで、発想を転換します。
API仕様書を書いたほうが早く、ラクに開発できるとすればどうでしょうか。
仕様書からコードを自動生成する
サンプルプロジェクト
こちらにあります。
- producerはAPI提供者です。自動生成の対象です。
- consumerはAPI利用者です。これも自動生成できますが、癖が強く自分自身まだ使いこなせていないため対象外にしておきます。(モデル生成だけなら簡単ですが、APIクライアント生成となるとゴチャゴチャしちゃう)
自動生成に利用するyamlはこちらです。とても単純な例になっています。
このyamlの作成自体にはstoplightを利用しました。
Generation Gapパターン
御存知の通り、となると思いますが、自動生成されたコードとうまく付き合う方法にGeneration Gapパターンがあります。
OpenAPI Generatorを使う方法でも、このGeneration Gapパターンがベースとなります。
方法としては、原題のように継承を使う方法もありますし、インタフェースだけ生成させて型だけ守る方法もあります。今回紹介するのは、Controllerも自動生成してしまい、その具体処理をサービスとしてDIする、つまり委譲してしまう方法を紹介します。
具体的な話
openapi-generator-maven-plugin
openapi-generator-maven-pluginは、openapi-generatorのラッパーの一つで、mavenで実行されることを前提にしたプラグインです。
大本はCLIが提供されていますが、多少機能が拡張されており、使用可能なオプションが異なります。
今回はopenapi-generatorがSpring向けに提供するオプションを利用しています。
特徴的なものを2つ紹介します。
- interfaceOnly. これを有効にすると、サーバ実装(Controllerなど)がスタブ実装の入ったインタフェースになります。delegatePatternと併用できません。
- delegatePattern. これを有効にすると、サーバ実装が委譲パターンを利用したものになります。Controllerがクラスファイルとして生成されます。
interfaceOnly=trueのとき
生成されるのは、下記4ファイルです。
このオプションの対象はサーバ関連のファイルに限定されるので、modelは影響しません。そのままクラスとして自動生成されます。
- api
- ApiUtil: APIのレスポンスを設定するだけの処理が書かれたクラス
- ReservationApi: APIのインタフェース。スタブ実装がdefaultで記載されている。
- model
- Pizza
- PizzaError
ReservationApiは下記のような実装になっています。
ポイントは2つです。
- このインタフェースをControllerに継承するだけで、強固にIFを守ることができます。
- swagger.annotationが最初からついているので、コードベースのドキュメント公開も楽にできるはずです。ただし、ドキュメント公開用の設定は自動生成されませんので自分でなんとかします。
/**
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech) (4.3.1).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
package chalkboard.me.cdc.producer.api;
import chalkboard.me.cdc.producer.model.Pizza;
import chalkboard.me.cdc.producer.model.PizzaError;
import io.swagger.annotations.*;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.CookieValue;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.context.request.NativeWebRequest;
import org.springframework.web.multipart.MultipartFile;
import javax.validation.Valid;
import javax.validation.constraints.*;
import java.util.List;
import java.util.Map;
import java.util.Optional;
@javax.annotation.Generated(value = "org.openapitools.codegen.languages.SpringCodegen", date = "2020-12-16T23:15:15.495231+09:00[Asia/Tokyo]")
@Validated
@Api(value = "reservation", description = "the reservation API")
public interface ReservationApi {
default Optional<NativeWebRequest> getRequest() {
return Optional.empty();
}
/**
* GET /reservation/{rid} : 予約情報の取得
* 予約の情報を返します
*
* @param rid 予約ID (required)
* @return 成功時 (status code 200)
* or 予約が見つからなかった (status code 404)
*/
@ApiOperation(value = "予約情報の取得", nickname = "getReservationRid", notes = "予約の情報を返します", response = Pizza.class, tags={ "reservation", })
@ApiResponses(value = {
@ApiResponse(code = 200, message = "成功時", response = Pizza.class),
@ApiResponse(code = 404, message = "予約が見つからなかった", response = PizzaError.class) })
@RequestMapping(value = "/reservation/{rid}",
produces = { "application/json", "application/xml" },
method = RequestMethod.GET)
default ResponseEntity<Pizza> getReservationRid(@ApiParam(value = "予約ID",required=true) @PathVariable("rid") Integer rid) {
getRequest().ifPresent(request -> {
for (MediaType mediaType: MediaType.parseMediaTypes(request.getHeader("Accept"))) {
if (mediaType.isCompatibleWith(MediaType.valueOf("application/json"))) {
String exampleString = "{ \"pizza\" : \"pizza\", \"id\" : 0, \"topping\" : [ \"topping\", \"topping\" ] }";
ApiUtil.setExampleResponse(request, "application/json", exampleString);
break;
}
if (mediaType.isCompatibleWith(MediaType.valueOf("application/xml"))) {
String exampleString = "<null> <id>123</id> <topping>aeiou</topping> <pizza>aeiou</pizza> </null>";
ApiUtil.setExampleResponse(request, "application/xml", exampleString);
break;
}
}
});
return new ResponseEntity<>(HttpStatus.NOT_IMPLEMENTED);
}
/**
* POST /reservation
* 予約登録
*
* @param pizza 注文するpizzaの情報 (optional)
* @return OK (status code 201)
* or Internal Server Error (status code 500)
*/
@ApiOperation(value = "", nickname = "postReservation", notes = "予約登録", tags={ "reservation", })
@ApiResponses(value = {
@ApiResponse(code = 201, message = "OK"),
@ApiResponse(code = 500, message = "Internal Server Error") })
@RequestMapping(value = "/reservation",
consumes = { "application/json" },
method = RequestMethod.POST)
default ResponseEntity<Void> postReservation(@ApiParam(value = "注文するpizzaの情報" ) @Valid @RequestBody(required = false) Pizza pizza) {
return new ResponseEntity<>(HttpStatus.NOT_IMPLEMENTED);
}
}
delegatePattern=trueのとき
色々と生成されます。
- api
- ApiUtil
- ReservationApi. ほぼ同上ですが、具体実装をdelegateに丸投げするようになっています。
- ReservationApiController. ReservationApiを実装するControllerです。delegateをDIします。
- ReservationApiDelegate. delegateのインタフェースです。スタブ実装がdefaultで書かれています。
- appconfig
- HomeController. これはOpenApiを公開するためのコントローラです。springfoxアノテーションが自動生成されるため、それ向けの設定です。
- OpenAPIDocumentationConfig
- model
- Pizza
- PizzaError
- OpenAPI2SpringBoot
このパターンでは、ReservationApiControllerは下記のようになります。
package chalkboard.me.cdc.producer.api;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import java.util.Optional;
@javax.annotation.Generated(value = "org.openapitools.codegen.languages.SpringCodegen", date = "2020-12-16T23:26:47.792734+09:00[Asia/Tokyo]")
@Controller
@RequestMapping("${openapi.reservations.base-path:}")
public class ReservationApiController implements ReservationApi {
private final ReservationApiDelegate delegate;
public ReservationApiController(@org.springframework.beans.factory.annotation.Autowired(required = false) ReservationApiDelegate delegate) {
this.delegate = Optional.ofNullable(delegate).orElse(new ReservationApiDelegate() {});
}
@Override
public ReservationApiDelegate getDelegate() {
return delegate;
}
}
そして、実際に実装したdelegateはこうなります。
package chalkboard.me.cdc.producer.presentation.controller.pizza;
import chalkboard.me.cdc.producer.api.ReservationApiDelegate;
import chalkboard.me.cdc.producer.domain.entity.pizza.PizzaRepository;
import chalkboard.me.cdc.producer.model.Pizza;
import chalkboard.me.cdc.producer.presentation.controller.exception.SystemException;
import chalkboard.me.cdc.producer.presentation.controller.pizza.exception.PizzaNotFoundException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import java.util.Objects;
@Service
@RequiredArgsConstructor
@Slf4j
public class ReservationDelegateImpl implements ReservationApiDelegate {
private final PizzaRepository pizzaRepository;
@Override
public ResponseEntity<Pizza> getReservationRid(Integer rid) {
try {
Pizza pizza = pizzaRepository.findPizza(rid);
if(Objects.isNull(pizza)) {
throw new PizzaNotFoundException("ピザがありませんでした");
}
return new ResponseEntity<>(pizza, HttpStatus.OK);
} catch(PizzaNotFoundException ne){
throw ne;
} catch (Exception e) {
throw new SystemException("ピザ取得時にサーバーエラー");
}
}
@Override
public ResponseEntity<Void> postReservation(Pizza pizza) {
try {
Integer rid = pizzaRepository.addPizza(pizza);
log.info("ピザID:" + rid);
return new ResponseEntity<>(HttpStatus.CREATED);
} catch (Exception e) {
throw new SystemException("ピザ作成時にサーバーエラー");
}
}
}
Controllerからdelegateが呼び出されるので、その具体実装だけをdelegateに実装してあげれば良くなります。Controllerは完全にクラスファイルとして扱われ、Githubから姿を消すことになります。
どちらがより良いのか
Delegateパターンの記述量の少なさ、ドキュメント公開設定の自動生成は魅力ですが、Controllerをクラスファイルだけにしてしまうため、弊害も生みます。たとえば、ExceptionHandlerをController内に実装することができません。
これによって、try-catchである程度ハンドリングするか、GlobalなExceptionHandlerを利用する必要があります。
上記が受け入れられない場合はインタフェースのみの生成のほうが良いでしょう。
メリットについて
従来開発の問題は、人間の意志でAPI仕様書をコードに反映するところにあります。まして、日々発生する小さな変更の全てをAPI仕様書に追従させるのは面倒くさがりにとって困難を極めるでしょう。
API仕様書からのコード生成は、API仕様書が開発のベースとなることを強制し、それなくしては開発することができなくなるところに強みがあります。(かつ、それを決めたら自動生成してくれるのです)
CDCのメリットと向き合い方
DSLのサンプル
こちらにあります。
CDCの必要性
さて、Generation Gapパターンで強固にIFを守れると私は言いました。それはある一面だけ見れば正解です。しかしながら、それは本当にIFだけなのです。
OpenAPIで表現できる以上のデータのバリデーションはできませんし、例外を定める事もできません。OpenAPI Specificationで確実に守れるのはIFそのものだけに過ぎないのです。
Consumer–Driven-Contractは、その点を契約という形で補うことができ、また、その契約内容の自動テストをProducerに提供します。Consumerにはその契約内容で作られたスタブサーバーを提供します。
DSLで表現できること
正常系
例えば、正常系のDSLとして下記のようなサンプルを用意しています。
package contracts.pizz
// https://cloud.spring.io/spring-cloud-contract/2.0.x/multi/multi__contract_dsl.html
org.springframework.cloud.contract.spec.Contract.make {
request {
method 'GET'
urlPath '/reservation/2' // ここを固定しないと、Producer側単体テストの準備が難しい
}
response {
status 200
headers {
header('Content-Type': 'application/json')
}
body("""
{
"id": 1234567890,
"pizza": "ナポリ",
"topping": [
"サラミ",
"ウインナー",
"フランクフルト",
"えび"
]
}
""")
bodyMatchers {
jsonPath('$.id', byRegex('[0-9]{1,10}'))
jsonPath('$.pizza', byType())
jsonPath('$.topping', byType())
jsonPath('$.topping[0]', byType())
}
}
}
このDSLは、request/responseで成り立っています。意味は、このリクエストの内容に対し、このようなレスポンスが帰ってくること、です。
実際に、このDSLは下記のようなテストを生成します。
@Test
public void validate_findPizzaContract() throws Exception {
// given:
MockMvcRequestSpecification request = given();
// when:
ResponseOptions response = given().spec(request)
.get("/reservation/2");
// then:
assertThat(response.statusCode()).isEqualTo(200);
assertThat(response.header("Content-Type")).isEqualTo("application/json");
// and:
DocumentContext parsedJson = JsonPath.parse(response.getBody().asString());
// and:
assertThat(parsedJson.read("$.id", String.class)).matches("[0-9]{1,10}");
assertThat((Object) parsedJson.read("$.pizza")).isInstanceOf(java.lang.String.class);
assertThat((Object) parsedJson.read("$.topping")).isInstanceOf(java.util.List.class);
assertThat((Object) parsedJson.read("$.topping[0]")).isInstanceOf(java.lang.String.class);
}
DSLの内容をそのままに内部結合試験、コンポーネントテスト相当の処理が実行されていることがわかります。DSLのbodyMatchers等のMatcherはいろいろな組合わせ、種類が用意されており、様々な検証を行うことが可能です。
異常系
異常系のDSLは下記のようになっています。
package contracts.pizz
// https://cloud.spring.io/spring-cloud-contract/2.0.x/multi/multi__contract_dsl.html
org.springframework.cloud.contract.spec.Contract.make {
request {
method 'GET'
urlPath '/reservation/404'
}
response {
status 404
headers {
header('Content-Type': 'application/json')
}
}
}
Consumer側
Produce側でDSLを定義、テストを通す事ができれば、mvn install
コマンドによりスタブ用のjarを生成することができます。
例えば、サンプルではmvn install時に下記のようなログがでます。
[INFO] --- maven-install-plugin:2.5.2:install (default-install) @ producer ---
[INFO] Installing /Users/angelica/Work/java/sample-cdc-test/producer/target/producer-0.0.3-SNAPSHOT.jar to /Users/angelica/.m2/repository/chalkboard/me/cdc/producer/0.0.3-SNAPSHOT/producer-0.0.3-SNAPSHOT.jar
[INFO] Installing /Users/angelica/Work/java/sample-cdc-test/producer/pom.xml to /Users/angelica/.m2/repository/chalkboard/me/cdc/producer/0.0.3-SNAPSHOT/producer-0.0.3-SNAPSHOT.pom
[INFO] Installing /Users/angelica/Work/java/sample-cdc-test/producer/target/producer-0.0.3-SNAPSHOT-stubs.jar to /Users/angelica/.m2/repository/chalkboard/me/cdc/producer/0.0.3-SNAPSHOT/producer-0.0.3-SNAPSHOT-stubs.jar
producer-0.0.3-SNAPSHOT-stubs.jarが、スタブ用のサーバーです。
このスタブを利用したクライアント側のテストは、サンプルではこのように書いています。
package chalkboard.me.cdc.consumer.infrastructure.external.pizza;
import chalkboard.me.cdc.consumer.domain.pizza.PizzaRepository;
import chalkboard.me.cdc.producer.model.Pizza;
import org.apache.commons.collections.CollectionUtils;
import org.apache.logging.log4j.util.Strings;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.cloud.contract.stubrunner.spring.AutoConfigureStubRunner;
import org.springframework.cloud.contract.stubrunner.spring.StubRunnerProperties;
import org.springframework.test.context.junit.jupiter.SpringExtension;
import java.util.Arrays;
import static org.junit.jupiter.api.Assertions.*;
@ExtendWith(SpringExtension.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
@AutoConfigureStubRunner(ids = {"chalkboard.me.cdc:producer:+:stubs:8585"},
stubsMode = StubRunnerProperties.StubsMode.LOCAL)
public class PizzaTransferTests {
@Autowired
private PizzaRepository pizzaRepository;
@Test
void 存在するピザにアクセスする() {
Pizza dto = pizzaRepository.findPizza(2); // ID=2がstubのkeyであることを知るにはcontractを見る必要がある...
assertNotNull(dto);
assertNotNull(dto.getId());
assertNotNull(dto.getPizza());
assertTrue(dto.getId() > 0 && Strings.isNotEmpty(dto.getPizza()));
assertTrue(CollectionUtils.isNotEmpty(dto.getTopping()));
}
@Test
void 存在しないピザにアクセスする() {
try{
Pizza dto = pizzaRepository.findPizza(404);
} catch (Exception e) {
// Httpステータスエラーとなるため
return;
}
fail();
}
@Test
void ピザを登録する() {
Pizza dto = new Pizza()
.pizza("ナポリ")
.topping(Arrays.asList("サラミ", "ウインナー", "フランクフルト","えび"));
try {
pizzaRepository.addPizza(dto);
} catch (Exception e) {
fail();
}
}
}
留意すべきこと
Producerでは、コンポーネントテスト相当になるため、DBに関して工夫が必要です。たとえば、サンプルではh2のインメモリDBにて予め返却されるデータの定義を行っています。
例えば、現実的にあり得る数値の範囲を正規表現でDSLに定義した場合、自動生成されたテストはその正規表現の範囲内でランダムな数値を投げてきます。こうなると、相当なデータ数が必要になってしまうのでテストが現実的ではなくなるのです。
Consumerでは、DSLで作られたスタブサーバに関して、そのURLパスパラメータまで考えてテスト設計をしなければなりません。
例えばサンプルでは、2ならば正常応答の定義が、404ならば異常応答の定義がありますので、それらを利用してテストを書くことになります。
最後に
インタフェースを効率よく守るためにはGeneration Gapが有用で、コンテキスト境界において、厳密に守りたいインタフェースはCDCによる契約を持ってテストすることが有用であることを紹介しました。
CDCによる運用はコストが低いとは言えませんので、DSLを設計ドキュメントの一部としてレビューするフローや、実際にAPIを利用する際のフローをきちんと決めるところから整備したほうが良いと思います。
なにせ、ProducerからみてConsumerがそのDSLをきちんと守るかは確かめる術がないわけで、簡易的な結合環境の提供くらいの利用にとどまるかもしれません。
それでも、DSLを網羅したテストをしておけば、Producerの要件変更時にはDSLの自動テストが失敗することになりますので、Consumer側でも変化に気づくことができます。
どこまで人が運用でカバーするか、というところはありますが、極力機械に任せていきたいですね。
Discussion