May 31, 2021

FlutterでOpen Api Generator(Swagger)を使う

WEBサービスを作るとクライアントとサーバの通信は必須となります。 通信方式に依りますが、API Clientの選択肢はいろいろあって、プロダクト環境で使う場合はほぼこの4つに絞られるかなと思います。

  • gRPC
  • GraphQL
  • OpenApiGenerator
  • FireStore等のFirebase系

FlutterではFirebase系の利用が多い気がします。
一方でgRPCを補助的ではなくメインで使っているサービスは現状まだ少ないのかなという印象です。
ただ、gRPCに関してはDart自体が対応している ので導入はそこまで難しくないと思います。

GraphQLは以前書いたのでそちらを参考にしてみてください。

今回はOpen Api GeneratorでFlutterのAPI Clientを作る方法を解説します。

導入

ディレクトリ構成

人やプロジェクトによって様々ですが、今回はopenapiというディレクトリをルートに作成して、 Scheme定義をdefinitions、生成されたファイルをgeneratedに入れたいと思います。

.
├── android
├── ios
├── lib
├── openapi
│   ├── definitions <= 定義ファイル
│   │   └── scheme.yml
│   └── generated <= 生成ファイル
│       └── client
├── pubspec.yaml
└── test

openapi-generator

Schemeからコードを生成するためにPC側にopenapi-generatorを導入します。
macであればbrew経由でインストールできます。

$ brew install openapi-generator

Scheme(yamlファイル)の準備

GitHubにあるOpenApiExamplesの定義を使います。

https://github.com/OAI/OpenAPI-Specification/blob/main/examples/v3.0/uspto.yaml

このyamlファイルを /openapi/definitions に追加します。

uspto.yaml
openapi: 3.0.1 servers: - url: '{scheme}://developer.uspto.gov/ds-api' variables: scheme: description: 'The Data Set API is accessible via https and http' enum: - 'https' - 'http' default: 'https' info: description: >- The Data Set API (DSAPI) allows the public users to discover and search USPTO exported data sets. This is a generic API that allows USPTO users to make any CSV based data files searchable through API. With the help of GET call, it returns the list of data fields that are searchable. With the help of POST call, data can be fetched based on the filters on the field names. Please note that POST call is used to search the actual data. The reason for the POST call is that it allows users to specify any complex search criteria without worry about the GET size limitations as well as encoding of the input parameters. version: 1.0.0 title: USPTO Data Set API contact: name: Open Data Portal url: 'https://developer.uspto.gov' email: developer@uspto.gov tags: - name: metadata description: Find out about the data sets - name: search description: Search a data set paths: /: get: tags: - metadata operationId: list-data-sets summary: List available data sets responses: '200': description: Returns a list of data sets content: application/json: schema: $ref: '#/components/schemas/dataSetList' example: { "total": 2, "apis": [ { "apiKey": "oa_citations", "apiVersionNumber": "v1", "apiUrl": "https://developer.uspto.gov/ds-api/oa_citations/v1/fields", "apiDocumentationUrl": "https://developer.uspto.gov/ds-api-docs/index.html?url=https://developer.uspto.gov/ds-api/swagger/docs/oa_citations.json" }, { "apiKey": "cancer_moonshot", "apiVersionNumber": "v1", "apiUrl": "https://developer.uspto.gov/ds-api/cancer_moonshot/v1/fields", "apiDocumentationUrl": "https://developer.uspto.gov/ds-api-docs/index.html?url=https://developer.uspto.gov/ds-api/swagger/docs/cancer_moonshot.json" } ] } /{dataset}/{version}/fields: get: tags: - metadata summary: >- Provides the general information about the API and the list of fields that can be used to query the dataset. description: >- This GET API returns the list of all the searchable field names that are in the oa_citations. Please see the 'fields' attribute which returns an array of field names. Each field or a combination of fields can be searched using the syntax options shown below. operationId: list-searchable-fields parameters: - name: dataset in: path description: 'Name of the dataset.' required: true example: "oa_citations" schema: type: string - name: version in: path description: Version of the dataset. required: true example: "v1" schema: type: string responses: '200': description: >- The dataset API for the given version is found and it is accessible to consume. content: application/json: schema: type: string '404': description: >- The combination of dataset name and version is not found in the system or it is not published yet to be consumed by public. content: application/json: schema: type: string /{dataset}/{version}/records: post: tags: - search summary: >- Provides search capability for the data set with the given search criteria. description: >- This API is based on Solr/Lucene Search. The data is indexed using SOLR. This GET API returns the list of all the searchable field names that are in the Solr Index. Please see the 'fields' attribute which returns an array of field names. Each field or a combination of fields can be searched using the Solr/Lucene Syntax. Please refer https://lucene.apache.org/core/3_6_2/queryparsersyntax.html#Overview for the query syntax. List of field names that are searchable can be determined using above GET api. operationId: perform-search parameters: - name: version in: path description: Version of the dataset. required: true schema: type: string default: v1 - name: dataset in: path description: 'Name of the dataset. In this case, the default value is oa_citations' required: true schema: type: string default: oa_citations responses: '200': description: successful operation content: application/json: schema: type: array items: type: object additionalProperties: type: object '404': description: No matching record found for the given criteria. requestBody: content: application/x-www-form-urlencoded: schema: type: object properties: criteria: description: >- Uses Lucene Query Syntax in the format of propertyName:value, propertyName:[num1 TO num2] and date range format: propertyName:[yyyyMMdd TO yyyyMMdd]. In the response please see the 'docs' element which has the list of record objects. Each record structure would consist of all the fields and their corresponding values. type: string default: '*:*' start: description: Starting record number. Default value is 0. type: integer default: 0 rows: description: >- Specify number of rows to be returned. If you run the search with default values, in the response you will see 'numFound' attribute which will tell the number of records available in the dataset. type: integer default: 100 required: - criteria components: schemas: dataSetList: type: object properties: total: type: integer apis: type: array items: type: object properties: apiKey: type: string description: To be used as a dataset parameter value apiVersionNumber: type: string description: To be used as a version parameter value apiUrl: type: string format: uriref description: "The URL describing the dataset's fields" apiDocumentationUrl: type: string format: uriref description: A URL to the API console for each API

生成

コマンドでyamlファイルからDartのコードを生成します。

$ openapi-generator generate -i openapi/definitions/scheme.yml -g dart-dio-next -o ./openapi/generated/client/ 

generatorはFlutterで使う場合は dart-dio がメジャーだと思います。
dart-dio-nextはNullSafety対応しているので、Flutter2.x以降の場合はこちらを使うと良いでしょう。

  • option

生成時にoptionを設定できます。
dart-dio-nextで設定できるoptionの一覧はここに載っています。

DartはDateクラスでTimeZoneの扱いが難しいので個人的にtimemachine というライブラリが好きなのですが、 5.1.1 からDateの変換クラスを選べるようになっていました。
optionを追加する時は --additional-propertiesをつけます。

--additional-properties="dateLibrary=timemachine"

Mock Server

環境依存の起動が楽なのでServer側はPrism を使います。

  • Install
$ npm install -g @stoplight/prism-cli

# OR

$ yarn global add @stoplight/prism-cli
  • 起動

$ prism mock openapi/definitions/scheme.yml

用意したSchemeからデフォルトだと4010番ポートにMock Serverが立ち上がるのでFlutterアプリからアクセスします。

※ HTTPを使う場合はAndroidもiOSもClear Textの設定が要るので注意してください

Flutterでの使い方

上記の生成コマンドを実行すると /openapi/generated/client にyamlから生成したDartコードがあると思います。
生成したコードをFlutterから呼び出して実際に通信します。

pubspec.yaml

pubspecに生成されたコードを追加しましょう。
生成コードのpathとdioを追加します。

dependencies:
  flutter:
    sdk: flutter

  openapi:
    path: ./openapi/generated/client
  dio: 4.0.0

呼び出し方

API Client

実際のプロダクト環境ではprovider等を使ってDI(Dependency Injection)すると思うので、Factoryクラス的なのを作って環境ごとにHostのURLを分けてDIするのが良いと思います。

Factoryクラスを作った一番シンプルなやり方はこんな感じです。

openapi_factory.dart
class OpenApiFactory { OpenApiFactory(); Openapi build({ required String baseUrl, }) { return Openapi( basePathOverride: baseUrl, interceptors: [ if (kDebugMode) LogInterceptor(requestBody: true, responseBody: true), ], ); } }

ポイントはInterceptorで通信時にやりたい処理などは基本的にInterceptor経由で行います。
ちなみにLogInterceptor はDioに定義されているクラスです。

他にはDioのoptionとして設定するやり方もあります。

openapi_factory.dart
class OpenApiFactory { OpenApiFactory(); Openapi build({ required String baseUrl, }) { final BaseOptions options = BaseOptions( baseUrl: baseUrl, connectTimeout: 5000, receiveTimeout: 5000, headers: <String, String>{ 'Content-Type': 'application/json', 'User-Agent': 'OpenApiExample', }, ); final dio = Dio(options); if (kDebugMode) { dio.interceptors .add(LogInterceptor(requestBody: true, responseBody: true)); } dio.interceptors.add(_AuthInterceptor('token_example')); return Openapi( dio: dio, ); } } class _AuthInterceptor extends Interceptor { _AuthInterceptor(this.token); String token; @override Future<void> onRequest( RequestOptions options, RequestInterceptorHandler handler, ) async { options.headers['Authorization'] = 'Bearer $token'; return super.onRequest(options, handler); } }

自分でDioのクライアントを生成する場合はこんな感じで設定します。
User-Agentとか設定する場合はこちらのやり方のほうがシンプルで良いかもしれません。

Model

実際のプロダクトではfreezed を使っているのですが、今回はサンプルなのでシンプルに作ります。

  • data_set__list.dart
data_set_list.dart
class DataSetList { DataSetList({ this.total, this.apis, }); int? total; List<DataSetListApis?>? apis; }
  • data_set_list_apis.dart
data_set_list_apis.dart
class DataSetListApis { DataSetListApis({ this.apiKey, this.apiVersionNumber, this.apiUrl, this.apiDocumentationUrl, }); String? apiKey; String? apiVersionNumber; String? apiUrl; String? apiDocumentationUrl; @override String toString() { return "apiKey:$apiKey, apiVersionNumber:$apiVersionNumber, apiUrl:$apiUrl, apiDocumentationUrl: $apiDocumentationUrl"; } }

GitHubにあるサンプルのSchemeファイルはrequiredが設定されていないのでnullableになってしまっていますが、Scheme自体にrequiredを設定すればフィールドをnon-nullにできます。

Repository

Repositoryクラスには生成したAPIClientをDIします。

metadata_repository.dart

OpenAPIで生成されたクラスをFlutter Project側のクラスにMapする処理はRepositoryに書いてあげます。
今回はExceptionでてきとうに返してしまっていますが、プロジェクト用のエラークラスを作ったりしてHTTPステータスコード等から適切なエラー処理をここで行うと良いと思います。

個人的に変換用のmapperは拡張関数でそれぞれのModelクラスに書いています。今回の場合は dart_set_list_apis.dartに追加します。
モデル名にOpenAPIで定義されている名前と同じものを使う場合は as を使って名前をつけないとimport時にConflictするので気をつけてください。

data_set_list_apis.dart
import 'package:openapi/openapi.dart' as api; class DataSetListApis { DataSetListApis({ this.apiKey, this.apiVersionNumber, this.apiUrl, this.apiDocumentationUrl, }); String? apiKey; String? apiVersionNumber; String? apiUrl; String? apiDocumentationUrl; @override String toString() { return "apiKey:$apiKey, apiVersionNumber:$apiVersionNumber, apiUrl:$apiUrl, apiDocumentationUrl: $apiDocumentationUrl"; } } extension DataSetListApisExt on api.DataSetListApis { DataSetListApis toDataSetListApis() { return DataSetListApis( apiKey: apiKey, apiVersionNumber: apiVersionNumber, apiUrl: apiUrl, apiDocumentationUrl: apiDocumentationUrl, ); } }

ViewModel, UseCase(Service)

ここは大きく人によって分かれます。
今やっているプロダクトではServiceという名前ですがUseCaseの層を作ってViewModelからはRepositoryは触らないようにしています。
今回は例なのでDIなどは特に考えずに触ってみます。

Prismを使って立ち上げたMock Serverに対してアクセスしてみます。
(AndroidからHostにアクセスするには10.0.2.2を使います)

Mock Serverから返ってきた結果をprintで表示しています。

final openApiClient = OpenApiFactory().build(baseUrl: 'http://10.0.2.2:4010');
final repository = MetaDataRepositoryImpl(openApiClient);
final DataSetList list = await repository.getDataSetList();

print(list.total);
print('-----');
list.apis?.forEach((e) => print(e));

// 2
// -----
// apiKey:oa_citations, apiVersionNumber:v1, apiUrl:https://developer.uspto.gov/ds-api/oa_citations/v1/fields, apiDocumentationUrl: https://developer.uspto.gov/ds-api-docs/index.html?url=https://developer.uspto.gov/ds-api/swagger/docs/oa_citations.json
// apiKey:cancer_moonshot, apiVersionNumber:v1, apiUrl:https://developer.uspto.gov/ds-api/cancer_moonshot/v1/fields, apiDocumentationUrl: https://developer.uspto.gov/ds-api-docs/index.html?url=https://developer.uspto.gov/ds-api/swagger/docs/cancer_moonshot.json

まとめ

APIのScheme定義から自動で生成されるので、リクエスト生成部分は全く手を付けていません。
今作っているプロダクトではOpenApiを使っていて、たまにDartの仕組み上微妙なクラスが生成されるときもあるのですが、うまくプロジェクト側のMapper部分で変換してあげればアプリ側には影響を与えずに利用できます。 SwaggerのAPI定義がブラウザで確認できますし、パラメータ等も自動生成されるのでサーバ側との差異が生まれないのも素晴らしいです。
Flutterで使う場合はGraphQLよりも使いやすいかなーと思いました。

おまけ

  • 自動生成されたコードはgitで管理化するべき?

個人的には都度生成するのが面倒なので管理したい派です。

.gitattributes ファイルを作成してそこに生成ファイルのディレクトリを書いておくとdiffが省略されるのでオススメです。

openapi/generated/* -diff
  • 毎回生成する時どうしてる?

Shell Script書いてMakefileとかでやってます。

generate_api_client.sh
#!/usr/bin/env bash mkdir -p openapi cd openapi || exit 1 # 生成ファイル用 mkdir -p generated # API定義ファイル用 mkdir -p definitions cd definitions || exit 1 curl -o scheme.yml https://raw.githubusercontent.com/OAI/OpenAPI-Specification/main/examples/v3.0/uspto.yaml cd ../.. rm -fR ./openapi/generated/* mkdir -p ./openapi/generated/client openapi-generator generate -i openapi/definitions/scheme.yml -g dart-dio-next -o ./openapi/generated/client/ --additional-properties="dateLibrary=core" cd ./openapi/generated/client || exit 1 flutter packages pub get flutter packages pub run build_runner build --delete-conflicting-outputs flutter format .

© AAkira 2023