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
に追加します。
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クラスを作った一番シンプルなやり方はこんな感じです。
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として設定するやり方もあります。
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
class DataSetList {
DataSetList({
this.total,
this.apis,
});
int? total;
List<DataSetListApis?>? apis;
}
- 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します。
abstract class MetaDataRepository {
Future<DataSetList> getDataSetList();
}
class MetaDataRepositoryImpl extends MetaDataRepository {
MetaDataRepositoryImpl(this._openApi);
final api.Openapi _openApi;
@override
Future<DataSetList> getDataSetList() async {
try {
final response = await _openApi.getMetadataApi().listDataSets();
final data = response.data;
if (data == null) {
throw Exception('data is null');
}
return DataSetList(
total: data.total,
apis: data.apis?.map((e) => e.toDataSetListApis()).toList(),
);
} catch (e) {
rethrow;
}
}
}
OpenAPIで生成されたクラスをFlutter Project側のクラスにMapする処理はRepositoryに書いてあげます。
今回はExceptionでてきとうに返してしまっていますが、プロジェクト用のエラークラスを作ったりしてHTTPステータスコード等から適切なエラー処理をここで行うと良いと思います。
個人的に変換用のmapperは拡張関数でそれぞれのModelクラスに書いています。今回の場合は dart_set_list_apis.dart
に追加します。
モデル名にOpenAPIで定義されている名前と同じものを使う場合は as
を使って名前をつけないとimport時にConflictするので気をつけてください。
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とかでやってます。
#!/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 .