CRUDでプロパティが変わるモデルをOpenAPIで書くときの定義分割

こんにちは。インサイトテクノロジーの吉﨑です。

そこにはAPI実装と自然言語で書かれたAPIマニュアルがありました。
これを再利用しやすくするため機械で読めるOpenAPIのspecファイルを作りました。

このときに遭遇したschema定義とAPIのリクエストに使うパラメーターが同一でない場合に見通しをよくする方法について書きます。

OpenAPIそのものや関連ツールの説明は最小限にしているため、OpenAPIのサンプルコードが読める方や定義を書いてみたことがある方が対象です。

OpenAPI

https://swagger.io/specification/

OpenAPIはRESTful API記述の仕様の1つです。
フォーマットにしたがってJSONまたはYAMLでspecファイルを記述します。
specファイルから周辺ツールによってドキュメントページや(モック)サーバーコード、クライアントコードの生成が可能です。

エディタ

専用エディタまたはその他エディタ向けのプラグインはいくつかあります。

https://swagger.io/tools/swagger-editor/

の”Live Demo”でオンラインのエディタを試すことができます。

https://github.com/swagger-api/swagger-editor#running-the-image-from-dockerhub

Docker版も使用できます。

Swagger Editorは左にエディタ、右にリアルタイムプレビューの2ペイン構成です。



初期サンプルのフォーマットバージョンはSwagger2.0となっているので新規に書く場合はOpenAPI3.0仕様に変更をおすすめします。

変更前

変更後

仕様

記事データを操作する架空のAPIを例にします。

機能

  • このAPIは記事の作成・取得(全件・一件)・更新・削除ができます。
  • 記事作成時に承認者のuser_idを指定します。
  • 記事作成時にSNSへの通知の有無を指定します。

モデル

APIの扱うモデルを定義します。
APIクライアントとAPIサーバーの間の関心に基づいたモデルなので必ずしもデータベースなどへの格納形式とは対応しません。

このモデルには作成時のみに指定するプロパティ、作成時に指定するが変更不可のプロパティ、作成時に自動で作られるプロパティがあります。

名前作成時指定更新可能返却備考
integerid××記事ID
integeruser_id××投稿者のユーザーID
stringtitle
stringcontent
integerreviewer_user_id×投稿時承認者のユーザーID
booleannotify_by_sns××投稿時にSNS通知する

エンドポイント

APIのエンドポイントを定義します。
要求プロパティはモデルの表に対応します。

メソッドパス説明要求プロパティ備考
POST/articles作成作成時指定 = 〇
GET/articles全件取得無し返却は配列
GET/articles/{id}1件取得無し
PATCH/articles/{id}更新更新可能 = 〇
DELETE/articles/{id}自動生成無し

OpenAPI記述

定義したモデルとAPI仕様をOpenAPIで記述していきます。
info, host, schema, securityなどの項目は省略しています。

コードは以下のバージョン指定で確認しています。

  • OpenAPI 3.0.1
  • Swagger Editor v3.15.10

OpenAPIバージョンはspecファイルの先頭で、Swagger EditorバージョンはDocker版のタグで指定しました。
基本的にはオンラインのSwagger Editorに貼り付けて動作するはずです。

STEP1. 取得可能属性でschemaを定義してリクエストは個別に定義する

まずは最も使う頻度が多い返却データの形式でschemaを定義してレスポンスで利用します。
リクエストごとに異なる形式は個別に記述します。

openapi: 3.0.1
info:
  title: "STEP1"
  description: ""
  version: ""
paths:
  /articles:
    post:
      tags:
      - articles
      summary: 記事作成
      operationId: createArticle
      requestBody:
        description: 記事作成データ
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                title:
                  type: string
                  description: タイトル
                  example: CRUDでプロパティが変わるモデルをOpenAPIで書くときの定義分割
                content:
                  type: string
                  description: 内容
                  example: |
                    ヘッダー
                    内容1
                    内容2
                review_user_id:
                  type: integer
                  format: int64
                  description: 投稿時承認者のユーザーID
                  example: 2
                notify_by_sns:
                  type: boolean
                  description: 投稿時にSNS通知する
                  example: true
      responses:
        default:
          description: 作成済み記事
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Article'

    get:
      tags:
      - articles
      summary: 記事一覧取得
      operationId: getArticleList
      responses:
        default:
          description: 記事一覧
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/Article'


  /articles/{id}:
    get:
      tags:
      - articles
      summary: 記事取得
      operationId: getArticle
      parameters:
      - $ref: '#/components/parameters/ArticleId'
      responses:
        default:
          description: 記事
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Article'

    patch:
      tags:
      - articles
      summary: 記事更新
      operationId: updateArticle
      parameters:
      - $ref: '#/components/parameters/ArticleId'
      requestBody:
        description: 記事更新データ
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                title:
                  type: string
                  description: タイトル
                  example: CRUDでプロパティが変わるモデルをOpenAPIで書くときの定義分割
                content:
                  type: string
                  description: 内容
                  example: |
                    ヘッダー
                    内容1
                    内容2
      responses:
        default:
          description: 更新済み記事
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Article'

    delete:
      tags:
      - articles
      summary: 記事削除
      operationId: deleteArticle
      parameters:
      - $ref: '#/components/parameters/ArticleId'
      responses:
        default:
          description: 削除済み記事
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Article'

components:
  parameters:
    ArticleId:
      name: id
      in: path
      description: 記事ID
      required: true
      schema:
        type: integer
        format: int64

  schemas:
    Article:
      type: object
      description: 記事
      properties:
        id:
          type: integer
          format: int64
          description: 記事ID
          example: 1
        user_id:
          type: integer
          format: int64
          description: 投稿者のユーザーID
          example: 4
        title:
          type: string
          description: タイトル
          example: CRUDでプロパティが変わるモデルをOpenAPIで書くときの定義分割
        content:
          type: string
          description: 内容
          example: |
            ヘッダー
            内容1
            内容2
        review_user_id:
          type: integer
          format: int64
          description: 投稿時承認者のユーザーID
          example: 2

長いですね。
コード化すると文章で書かれているものより構造が見えやすくなり共通箇所が目立つので使いまわせるようにしたくなります。

STEP2. 取得可能属性のschemaにreadOnly, writeOnly属性を追加する

次はモデルの属性をすべてschemaにまとめてreadOnlyとwriteOnly属性を使います。

  • readOnly
    • 取得のみで使用する。
    • Request Bodyに現れなくなり、Responseにのみ現れるようになります。
  • writeOnly
    • 送信でのみ使用する。
    • Responseに現れなくなり、Request Bodyにのみ現れるようになります。
openapi: 3.0.1
info:
  title: "STEP2"
  description: ""
  version: ""
paths:
  /articles:
    post:
      tags:
      - articles
      summary: 記事作成
      operationId: createArticle
      requestBody:
        description: 記事作成データ
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/Article'
      responses:
        default:
          description: 作成済み記事
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Article'

    get:
      tags:
      - articles
      summary: 記事一覧取得
      operationId: getArticleList
      responses:
        default:
          description: 記事一覧
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/Article'


  /articles/{id}:
    get:
      tags:
      - articles
      summary: 記事取得
      operationId: getArticle
      parameters:
      - $ref: '#/components/parameters/ArticleId'
      responses:
        default:
          description: 記事
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Article'
  
    patch:
      tags:
      - articles
      summary: 記事更新
      operationId: updateArticle
      parameters:
      - $ref: '#/components/parameters/ArticleId'
      requestBody:
        description: 記事更新データ
        required: true
        content:
          application/json:
            schema:
              allOf:
              - $ref: '#/components/schemas/Article'
              - properties:  # !!! アドホックな属性上書き
                  review_user_id:
                    readOnly: true
                  notify_by_sns:
                    writeOnly: false  # writeOnly, readOnlyがともにtrueは未定義 
                    readOnly: true
              
      responses:
        default:
          description: 更新済み記事
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Article'

    delete:
      tags:
      - articles
      summary: 記事削除
      operationId: deleteArticle
      parameters:
      - $ref: '#/components/parameters/ArticleId'
      responses:
        default:
          description: 削除済み記事
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Article'

components:
  parameters:
    ArticleId:
      name: id
      in: path
      description: 記事ID
      required: true
      schema:
        type: integer
        format: int64

  schemas:
    Article:
      type: object
      description: 記事
      properties:
        id:
          type: integer
          format: int64
          description: 記事ID
          example: 1
          readOnly: true
        user_id:
          type: integer
          format: int64
          description: 投稿者のユーザーID
          example: 4
          readOnly: true
        title:
          type: string
          description: タイトル
          example: CRUDでプロパティが変わるモデルをOpenAPIで書くときの定義分割
        content:
          type: string
          description: 内容
          example: |
            ヘッダー
            内容1
            内容2
        review_user_id:
          type: integer
          format: int64
          description: 投稿時承認者のユーザーID
          example: 2
        notify_by_sns:
          type: boolean
          description: 投稿時にSNS通知する
          example: true
          writeOnly: true

RequestとResponseでschemaを使いまわすことができました。

ただし、patchリクエストに場当たり的な対応が残っています。

           schema:
              allOf:
              - $ref: '#/components/schemas/Article'
              - properties:  # !!! アドホックな属性上書き
                  review_user_id:
                    readOnly: true
                  notify_by_sns:
                    writeOnly: false  # writeOnly, readOnlyがともにtrueは未定義 
                    readOnly: true

schemaの定義でのwriteOnly属性では、リクエストによって使用するしないが異なるプロパティの対応ができないので隠したい属性の箇所をreadOnly属性で上書きしています。

STEP3. プロパティの属性ごとにモデルを分けて結合する

大きな1つのモデルに対してreadOnly/writeOnlyの指定だけで取り回すと扱いにくい場所があることがわかりました。
そこで、プロパティの扱いごとにモデルを分けて必要に応じてallOfで結合します。

openapi: 3.0.1
info:
  title: "STEP3"
  description: ""
  version: ""
paths:
  /articles:
    post:
      tags:
      - articles
      summary: 記事作成
      operationId: createArticle
      requestBody:
        description: 記事作成データ
        required: true
        content:
          application/json:
            schema:
              allOf:
              - $ref: '#/components/schemas/ArticleDynamicProps'
              - $ref: '#/components/schemas/ArticleStaticProps'
              - $ref: '#/components/schemas/ArticlePostOption'
      responses:
        default:
          description: 作成済み記事
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Article'

    get:
      tags:
      - articles
      summary: 記事一覧取得
      operationId: getArticleList
      responses:
        default:
          description: 記事一覧
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/Article'


  /articles/{id}:
    get:
      tags:
      - articles
      summary: 記事取得
      operationId: getArticle
      parameters:
      - $ref: '#/components/parameters/ArticleId'
      responses:
        default:
          description: 記事
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Article'
  
    patch:
      tags:
      - articles
      summary: 記事更新
      operationId: updateArticle
      parameters:
      - $ref: '#/components/parameters/ArticleId'
      requestBody:
        description: 記事更新データ
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ArticleDynamicProps'

              
      responses:
        default:
          description: 更新済み記事
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Article'

    delete:
      tags:
      - articles
      summary: 記事削除
      operationId: deleteArticle
      parameters:
      - $ref: '#/components/parameters/ArticleId'
      responses:
        default:
          description: 削除済み記事
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Article'

components:
  parameters:
    ArticleId:
      name: id
      in: path
      description: 記事ID
      required: true
      schema:
        type: integer
        format: int64

  schemas:
    ArticleDynamicProps:
      type: object
      properties:
        title:
          type: string
          description: タイトル
          example: CRUDでプロパティが変わるモデルをOpenAPIで書くときの定義分割
        content:
          type: string
          description: 内容
          example: |
            ヘッダー
            内容1
            内容2

    ArticleStaticProps:
      type: object
      properties:
       review_user_id:
          type: integer
          format: int64
          description: 投稿時承認者のユーザーID
          example: 2

    Article:
      type: object
      description: 記事
      allOf:
      - properties:
          id:
            type: integer
            format: int64
            description: 記事ID
            example: 1
          user_id:
            type: integer
            format: int64
            description: 投稿者のユーザーID
            example: 4
      - $ref: "#/components/schemas/ArticleDynamicProps"
      - $ref: "#/components/schemas/ArticleStaticProps"

    ArticlePostOption:
      type: object
      properties:
        notify_by_sns:
          type: boolean
          description: 投稿時にSNS通知する
          example: true

作成時指定可能で変更可能なプロパティのArticleDynamicPropsモデル、作成時指定で変更不可能なプロパティのArticleStaticPropsモデルを定義します。
そして、それらをallOfで結合して自動生成されるプロパティを加えてArticleモデルとします。

このように分割することでリクエストごとに必要なモデルを選択して利用しやすくなります。

ここでは作成時のみに使用するプロパティもArticlePostOptionモデルとして定義しました。
場合によっては自動生成されるプロパティも別モデルにしたりもできます。

終わりに

APIごとにモデルに要求するプロパティが異なる場合に便利になるモデル分割の例について、ストレートに書く場合、readOnly/writeOnly属性を使う場合と並べて紹介しました。

当然のことながらモデルの分割が適しているかはAPI仕様によって変わってきます。このような方法もあると思い出してもらえると幸いです。

また、こういったフォーマットに落とし込むのが難しいと感じたときはもしかしたらAPI仕様そのものに手を入れるときかもしれません。機械で読めるようにルール化された仕様で書くことは人間が仕様を整理して理解するためにも役立ちます。

その他

今回は紹介できませんでしたがドキュメントページ生成にはReDocがおすすめです。
https://github.com/Redocly/redoc

関連最新記事

TOP インサイトブログ 開発 CRUDでプロパティが変わるモデルをOpenAPIで書くときの定義分割

Recruit 採用情報

Contact お問い合わせ

  購入済みの製品サポートはこちら