May 31, 2021

FlutterでOpen Api Generator(Swagger)を使う

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

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

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

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

FlutterでGraphQLを実用的に使う - AAbrain

Flutterでgraphql_flutterとartemisを使って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

CLI Installation | OpenAPI Generator

There are a number of ways to use OpenAPI Generator. This page documents how to install the CLI artifact.

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します。

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 .

© AAkira 2023