はじめに
Dart/Flutter連載の2記事目です。
はじめまして、TIGの宮崎将太です。
突然ですがみなさん、Swagger使いたいですよね。当社でもGo言語などでSwaggerを用いてREST APIサーバ/クライアントコードを生成する機会が増えています。
たまたま Flutter+Rails でアプリケーション構築をする機会があったので、今回Flutterのみに着目してSwagger(OpenAPISpec)を使用する方法をコード付きで解説していきます。(Railsは需要があったら書こうと思いますので、SNSでリアクションもらえるとです!)
Swaggerとは?
Swagger(OpenAPISpec)とはREST API仕様をyamlやjsonベースで定義できるフォーマットを定めたツールで、定義書を書くとAPI仕様書やサーバ、クライアントコードを生成できちゃう優れものです。
2系、3系いろいろありますが、今回はエコシステムが充実している2系を使用していきます。
Swaggerの詳しい説明は、敬愛する武田さんが以前記載してくださっているので、そちらをチェックしてください。m(__)m
https://future-architect.github.io/articles/20191008/
0からクライアントコード実装までやってみる
百聞は一見に如かず。
0の状態からクライアントコード実装までやってみます。
なお、flutter/dartはインストール済みの前提として進めます。まだの方は公式にインストール方法がありますので、準備してからやってみてください。
各バージョン情報は以下の通りです。
- 開発機OS: Mac Catalina
- Flutter: 2.0.4
- Dart: 2.12.2
- Swagger: 2.0
openapi-generatorインストール
後ほどSwaggerからコードを生成するので、まずは生成ツールであるopenapi-generator
をインストールします。
生成ツールはjar、dockerなどいろいろな形式で提供されていますが、今回は楽にHomebrew経由でインストールします。
1
| brew install openapi-generator
|
その他の形式については下記参考に導入してください。
https://openapi-generator.tech/docs/installation/
Flutterプロジェクト作成
ツール導入が完了したので、プロジェクトを作成。
flutter_swagger
という名称でプロジェクトを作成します。
1
| flutter create flutter_swagger
|
以下のようなプロジェクトが生成されるはずです。
あくまでAPIリクエスト実行までを実装するので、今回いじるのはpubspec.yaml
とlib/main.dart
のみです。
※Flutter基本的なディレクトリ構造に関しての説明は今回は割愛します。
swagger.yaml配置
プロジェクト作成が完了したので、プロジェクトルートにswagger.yaml
を作成します。
swaggerはヘルスチェックに対して200OKを返すのみの簡単なもの。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30
| swagger: "2.0" info: title: "api" version: "0.0.1-SNAPSHOT"
basePath: /api/v1 schemes: - http consumes: - "application/json" produces: - "application/json" tags: - name: System description: "システム共通機能" paths: /health: get: tags: - System responses: 200: description: OK schema: $ref: "#/definitions/health" definitions: health: type: string enum: [OK]
|
APIクライアントコード生成
swagger.yaml
の配置が終わったのでopenapi-generator
でAPIクライアントコードを生成します。
dartのクライアントコードはパッケージ形式で生成されるので、lib
配下には生成せず別ディレクトリに生成(client
配下)し、後ほどimportします。
terminalにて以下を実行してください。
1
| openapi-generator generate -i ./swagger.yaml -g dart -o ./client
|
各オプションは以下の通りです。
クライアントコードの生成が完了すると、以下のようにclient
配下に別パッケージが確認できます。
※コンパイルエラーが発生している場合はclient
配下でflutter pub get
を実行して依存ライブラリを解決してください。
主たる生成コードの役割は以下の通りです。
lib/api/xxx_api.dart
: swaggerのtag
ごとに生成されます。APIレスポンスのモデルバインド等を実行するAPIクライアントラッパーが定義されます。
lib/auth/xxx.dart
: 認証系の生成コードです。APIキー認証、basic認証、Bearer認証、OAuth認証が可能。今回は使用しません。
lib/model/xxx.dart
: swaggerのdefinition
ディレクティブで定義するAPIリクエスト/レスポンスがモデルクラスとして生成されます。
lib/api_client.dart
: APIクライアントが定義されます。
openapiパッケージの導入
生成されたコードはopenapi
という名称のパッケージになっているので、プロジェクトルートのpubspec.yaml
にて依存定義を記載します。
以下、最後2行をpubspec.yaml
に記載後、プロジェクトルートにてflutter pub get
を実行してください。
1 2 3 4 5 6 7 8 9 10 11 12
| dependencies: flutter: sdk: flutter
cupertino_icons: ^1.0.2
openapi: path: ./client/
|
APIリクエスト実行
ここまででようやくAPIリクエストを実行する準備が整いました。
あとは通常通りopenapi
パッケージをimportし、main.dart
など任意の箇所にコーディングするだけです。
以下、参考コードになります。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| import 'package:openapi/api.dart'; import 'package:http/http.dart';
var client = ApiClient(basePath: "http://localhost:8080");
client.addDefaultHeader("key", "value");
var api = SystemApi(client);
Health health = await api.healthGet(); print(health.value);
Response res = await api.healthGetWithHttpInfo(); print(res.statusCode); print(res.headers); print(res.body);
|
- ベースとなるAPIクライアント生成
ApiClient
をインスタンス化しています。アクセスの設定や共通ヘッダを実装したい場合は此処に実装することになります。
ApiClient
の定義はclient/lib/api_client.dart
に生成されます。
- APIクライアントラッパーを生成
swaggerのtag
ごとに生成されるクラスです。APIレスポンスのモデルへバインド等を実行します。swaggerのpath
一つにつき後述の3と4の2メソッドが生成されます。クラス定義はclient/lib/api/xxx_api.dart
に生成されます。
- リクエスト発行(レスポンスボディのみが欲しい場合)
単純にレスポンスボディのみが欲しい場合は${パス名+HTTPメッソド名}
のメソッドをcallします。(この場合はhealthGet
)。HTTPステータスが400以上の場合やレスポンスボディがnullの場合は例外(ApiException
)をthrowしてくれます。
- リクエスト発行(ヘッダも含めて欲しい場合)
③のメソッドではHTTPヘッダ情報が取得できなかったり、HTTPステータスが400以上の場合には例外をthrowしてしまうので、この挙動が嫌な場合は${パス名+HTTPメッソド名}WithHttpInfo
をcallします。(例の場合はhealthGetWithHttpInfo
)
ただし、返り値はhttp/http.dart
パッケージのResponse
インスタンスとなるので、レスポンスボディのモデルバインドは自前で実装する必要がある点に注意してください。生成コード的には③の中で④をcallするような構造になっています。
さいごに
お手軽にSwaggerからAPIクライアントコードの生成&実装ができました。
今回はスキップしましたが、認証機構も生成されていたり、APIクライアントのカスタマイズも可能なので自動生成コードの中身は是非見てみてください。
Flutterに関しては他にもいろいろ知見を深めることができたので、別の機会があれば記事にできればと。m(__)m
おまけ
需要があるかわかりませんが、サンプルとして載せたSwaggerから生成されたコードを載せておきます。
コード見てみたいけど手元に環境がない、なんて方の参考になればと。
▽Swaggerから生成したコード(クリックで開けます)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60
|
part of openapi.api;
class SystemApi { final ApiClient apiClient;
SystemApi([ApiClient apiClient]) : apiClient = apiClient ?? defaultApiClient;
Future<Response> healthGetWithHttpInfo() async { Object postBody;
String path = "/health".replaceAll("{format}","json");
List<QueryParam> queryParams = []; Map<String, String> headerParams = {}; Map<String, String> formParams = {};
List<String> contentTypes = [];
String nullableContentType = contentTypes.isNotEmpty ? contentTypes[0] : null; List<String> authNames = [];
if(nullableContentType != null && nullableContentType.startsWith("multipart/form-data")) { bool hasFields = false; MultipartRequest mp = MultipartRequest(null, null); if(hasFields) postBody = mp; } else { }
var response = await apiClient.invokeAPI(path, 'GET', queryParams, postBody, headerParams, formParams, nullableContentType, authNames); return response; }
Future<Health> healthGet() async { Response response = await healthGetWithHttpInfo(); if(response.statusCode >= 400) { throw ApiException(response.statusCode, _decodeBodyBytes(response)); } else if(response.body != null) { return apiClient.deserialize(_decodeBodyBytes(response), 'Health') as Health; } else { return null; } }
}
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34
|
part of openapi.api;
class Health { final String value;
const Health._internal(this.value);
static const Health oK_ = const Health._internal("OK");
static Health fromJson(String value) { return new HealthTypeTransformer().decode(value); }
static List<Health> listFromJson(List<dynamic> json) { return json == null ? new List<Health>() : json.map((value) => Health.fromJson(value)).toList(); } }
class HealthTypeTransformer {
dynamic encode(Health data) { return data.value; }
Health decode(dynamic data) { switch (data) { case "OK": return Health.oK_; default: throw('Unknown enum value to decode: $data'); } } }
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155
| part of openapi.api;
class QueryParam { String name; String value;
QueryParam(this.name, this.value); }
class ApiClient {
String basePath; var client = Client();
Map<String, String> _defaultHeaderMap = {}; Map<String, Authentication> _authentications = {};
final _regList = RegExp(r'^List<(.*)>$'); final _regMap = RegExp(r'^Map<String,(.*)>$');
ApiClient({this.basePath = "http://localhost/api/v1"}) { }
void addDefaultHeader(String key, String value) { _defaultHeaderMap[key] = value; }
dynamic _deserialize(dynamic value, String targetType) { try { switch (targetType) { case 'String': return '$value'; case 'int': return value is int ? value : int.parse('$value'); case 'bool': return value is bool ? value : '$value'.toLowerCase() == 'true'; case 'double': return value is double ? value : double.parse('$value'); case 'Health': return new HealthTypeTransformer().decode(value); default: { Match match; if (value is List && (match = _regList.firstMatch(targetType)) != null) { var newTargetType = match[1]; return value.map((v) => _deserialize(v, newTargetType)).toList(); } else if (value is Map && (match = _regMap.firstMatch(targetType)) != null) { var newTargetType = match[1]; return Map.fromIterables(value.keys, value.values.map((v) => _deserialize(v, newTargetType))); } } } } on Exception catch (e, stack) { throw ApiException.withInner(500, 'Exception during deserialization.', e, stack); } throw ApiException(500, 'Could not find a suitable class for deserialization'); }
dynamic deserialize(String json, String targetType) { targetType = targetType.replaceAll(' ', '');
if (targetType == 'String') return json;
var decodedJson = jsonDecode(json); return _deserialize(decodedJson, targetType); }
String serialize(Object obj) { String serialized = ''; if (obj == null) { serialized = ''; } else { serialized = json.encode(obj); } return serialized; }
Future<Response> invokeAPI(String path, String method, Iterable<QueryParam> queryParams, Object body, Map<String, String> headerParams, Map<String, String> formParams, String nullableContentType, List<String> authNames) async {
_updateParamsForAuth(authNames, queryParams, headerParams);
var ps = queryParams .where((p) => p.value != null) .map((p) => '${p.name}=${Uri.encodeQueryComponent(p.value)}');
String queryString = ps.isNotEmpty ? '?' + ps.join('&') : '';
String url = basePath + path + queryString;
headerParams.addAll(_defaultHeaderMap); if (nullableContentType != null) { final contentType = nullableContentType; headerParams['Content-Type'] = contentType; }
if(body is MultipartRequest) { var request = MultipartRequest(method, Uri.parse(url)); request.fields.addAll(body.fields); request.files.addAll(body.files); request.headers.addAll(body.headers); request.headers.addAll(headerParams); var response = await client.send(request); return Response.fromStream(response); } else { var msgBody = nullableContentType == "application/x-www-form-urlencoded" ? formParams : serialize(body); final nullableHeaderParams = (headerParams.isEmpty)? null: headerParams; switch(method) { case "POST": return client.post(url, headers: nullableHeaderParams, body: msgBody); case "PUT": return client.put(url, headers: nullableHeaderParams, body: msgBody); case "DELETE": return client.delete(url, headers: nullableHeaderParams); case "PATCH": return client.patch(url, headers: nullableHeaderParams, body: msgBody); case "HEAD": return client.head(url, headers: nullableHeaderParams); default: return client.get(url, headers: nullableHeaderParams); } } }
void _updateParamsForAuth(List<String> authNames, List<QueryParam> queryParams, Map<String, String> headerParams) { authNames.forEach((authName) { Authentication auth = _authentications[authName]; if (auth == null) throw ArgumentError("Authentication undefined: " + authName); auth.applyToParams(queryParams, headerParams); }); }
T getAuthentication<T extends Authentication>(String name) { var authentication = _authentications[name];
return authentication is T ? authentication : null; } }
|
Dart/Flutter連載の2記事目でした。次回は澁川さんの Goのサーバーの管理画面をFlutter Webで作ってみるための調査 です。