Laravel APISpec Generatorの使い方

Laravelのカレンダー | Advent Calendar 2021 - Qiita の3日目の記事。

前回Laravel APISpec Generatorを作った記事を公開した。

https://zenn.dev/kotamat/articles/2a63e9958e0905

ただ、この内容だけだと具体的にどういう効果がありそうかが見えないので、具体的な使用方法を元に紹介してみる。

ちなみにNuxt3とのつなぎ込みの紹介はこちらのプレゼン資料を見てもらえると良いかもしれない

https://slides.com/kotamat/nuxt3-laravel-apispec-generator

使用方法1: サーバーで使ってるマスターデータをフロントでも使う

例えば下記の様に、configの中にmasterデータを保管しているとする。

config/master
├── aa.php
└── bb.php

aa.php

<?php
return [
    'hoge' => 'hogehoge',
    'fuga' => 'fugafuga'
];

bb.php

<?php
return [
    'cat' => '猫',
    'dog' => '犬'
];

そして、MasterControllerを下記のように設定しておき

<?php

namespace App\Http\Controllers;

class MasterController extends Controller
{
    public function __invoke()
    {
        return config('master');
    }
}

下記のテストケースを書いておく。

<?php

namespace Tests\Feature\Http\Controllers;

use Tests\TestCase;

class MasterControllerTest extends TestCase
{

    public function testInvoke()
    {
        $res = $this->getJson(route('master'));
        $res->assertOk();
    }
}

実際にOASを吐き出してみる

テストを実行すると下記のようなjsonが吐き出され

jsonを開く

{
    "openapi": "3.0.0",
    "info": {
        "title": "auto generated spec",
        "version": "0.0.0"
    },
    "paths": {
        "\/api\/master": {
            "get": {
                "summary": "\/api\/master",
                "description": "\/api\/master",
                "operationId": "\/api\/master:GET",
                "security": [],
                "responses": {
                    "200": {
                        "description": "",
                        "content": {
                            "application\/json": {
                                "schema": {
                                    "type": "object",
                                    "properties": {
                                        "aa": {
                                            "type": "object",
                                            "properties": {
                                                "hoge": {
                                                    "type": "string",
                                                    "example": "hogehoge"
                                                },
                                                "fuga": {
                                                    "type": "string",
                                                    "example": "fugafuga"
                                                }
                                            },
                                            "required": [
                                                "hoge",
                                                "fuga"
                                            ]
                                        },
                                        "bb": {
                                            "type": "object",
                                            "properties": {
                                                "cat": {
                                                    "type": "string",
                                                    "example": "\u732b"
                                                },
                                                "dog": {
                                                    "type": "string",
                                                    "example": "\u72ac"
                                                }
                                            },
                                            "required": [
                                                "cat",
                                                "dog"
                                            ]
                                        }
                                    },
                                    "required": [
                                        "aa",
                                        "bb"
                                    ],
                                    "title": "\/api\/master_GET_response_200"
                                }
                            }
                        }
                    }
                },
                "parameters": [
                    {
                        "in": "header",
                        "name": "Content-Type",
                        "schema": {
                            "type": "string"
                        },
                        "description": "application\/json"
                    },
                    {
                        "in": "header",
                        "name": "Accept",
                        "schema": {
                            "type": "string"
                        },
                        "description": "application\/json"
                    }
                ]
            }
        }
    }
}

例えばtypescript-axiosで吐き出したコードを使うと

import { DefaultApi } from "~/spec";

const api = new DefaultApi()
const { data } = await api.apiMasterGET()
data.aa.fuga // :string

というような形で参照することができる。

masterデータはプロダクトが大きくなるにつれて追加されていくものであり、個別で型を使いまわしたいケースもあると思うが、下記のようにすれば小さい単位で型を取り回すことができる。

import { ApiMasterGETResponse200 } from "~/spec";
type aa = ApiMasterGETResponse200['aa']
// こんなかんじで具体型も吐き出されているのでこれを使っても良い
import { ApiMasterGETResponse200Aa } from "~/spec";
type aa2 = ApiMasterGETResponse200Aa

レスポンスステータスごとに型を使い分ける

例えば200が返るパターンと、422(バリデーション)が返ってくるパターンでは当然返却されるdataの型は変わってくる。

例えば下記の様なAPIをかんがえてみる

Controller

<?php

class JobController extends Controller
{
    public function update(UpdateRequest $request, Job $job)
    {
        $job->fill($request->safe()->all());
        return $job;
    }
}

UpdateRequest

<?php

class UpdateRequest extends FormRequest
{
    public function authorize()
    {
        return $this->user() instanceof User;
    }

    public function rules()
    {
        return [
            'name' => "required|string",
            'user_id' => "exists:" . User::class . ",id"
        ];
    }
}

今回は下記のような200, 403, 422がそれぞれ返ってくるようなテストケースをかんがえてみる

<?php

class JobControllerTest extends TestCase
{
    /**
     * @dataProvider provideUpdateParams
     */
    public function testUpdate(bool $auth, bool $hasName, int $statusCode)
    {
        /** @var Job $job */
        $job = Job::factory()->create();
        if ($auth) {
            $this->actingAs($job->user);
        }
        $params = Job::factory()->make()->toArray();
        if (!$hasName) {
            unset($params['name']);
        }
        $res = $this->putJson(route('job.update', ['job' => $job->id]), $params);
        $res->assertStatus($statusCode);
    }

    public function provideUpdateParams(): array
    {
        return [
            [
                "auth" => true,
                "name" => true,
                "expect" => 200
            ],
            [
                "auth" => false,
                "name" => true,
                "expect" => 403
            ],
            [
                "auth" => true,
                "name" => false,
                "expect" => 422
            ],
        ];

    }
}

実際にOASを吐き出してみる

今回はステータスコードごとにjsonファイルが吐き出される

storage/app/api/
└── job
    └── {job}
        ├── PUT.200.json
        ├── PUT.403.json
        └── PUT.422.json

全ファイルを php artisan apispec:aggregate マージしたものが下記

jsonを開く

{
    "openapi": "3.0.0",
    "info": {
        "title": "auto generated spec",
        "version": "0.0.0"
    },
    "paths": {
        "\/api\/job\/{job}": {
            "put": {
                "summary": "\/api\/job\/{job}",
                "description": "\/api\/job\/{job}",
                "operationId": "\/api\/job\/{job}:PUT",
                "security": [
                    {
                        "bearerAuth": []
                    }
                ],
                "responses": {
                    "200": {
                        "description": "",
                        "content": {
                            "application\/json": {
                                "schema": {
                                    "type": "object",
                                    "properties": {
                                        "id": {
                                            "type": "integer",
                                            "example": 22
                                        },
                                        "name": {
                                            "type": "string",
                                            "example": "Ms. Ana Rosenbaum"
                                        },
                                        "created_at": {
                                            "type": "string",
                                            "example": "2021-11-29T11:19:09.000000Z"
                                        },
                                        "updated_at": {
                                            "type": "string",
                                            "example": "2021-11-29T11:19:09.000000Z"
                                        },
                                        "user_id": {
                                            "type": "integer",
                                            "example": 47
                                        }
                                    },
                                    "required": [
                                        "id",
                                        "name",
                                        "created_at",
                                        "updated_at",
                                        "user_id"
                                    ],
                                    "title": "\/api\/job\/{job}_PUT_response_200"
                                }
                            }
                        }
                    },
                    "403": {
                        "description": "",
                        "content": {
                            "application\/json": {
                                "schema": {
                                    "type": "object",
                                    "properties": {
                                        "message": {
                                            "type": "string",
                                            "example": "This action is unauthorized."
                                        },
                                        "exception": {
                                            "type": "string",
                                            "example": "Symfony\\Component\\HttpKernel\\Exception\\AccessDeniedHttpException"
                                        },
                                        "file": {
                                            "type": "string",
                                            "example": "\/var\/www\/html\/vendor\/laravel\/framework\/src\/Illuminate\/Foundation\/Exceptions\/Handler.php"
                                        },
                                        "line": {
                                            "type": "integer",
                                            "example": 387
                                        },
                                        "trace": {
                                            "type": "array",
                                            "items": {
                                                "type": "object",
                                                "properties": {
                                                    "file": {
                                                        "type": "string",
                                                        "example": "\/var\/www\/html\/vendor\/laravel\/framework\/src\/Illuminate\/Foundation\/Exceptions\/Handler.php"
                                                    },
                                                    "line": {
                                                        "type": "integer",
                                                        "example": 332
                                                    },
                                                    "function": {
                                                        "type": "string",
                                                        "example": "prepareException"
                                                    },
                                                    "class": {
                                                        "type": "string",
                                                        "example": "Illuminate\\Foundation\\Exceptions\\Handler"
                                                    },
                                                    "type": {
                                                        "type": "string",
                                                        "example": "->"
                                                    }
                                                },
                                                "required": [
                                                    "file",
                                                    "line",
                                                    "function",
                                                    "class",
                                                    "type"
                                                ]
                                            }
                                        }
                                    },
                                    "required": [
                                        "message",
                                        "exception",
                                        "file",
                                        "line",
                                        "trace"
                                    ],
                                    "title": "\/api\/job\/{job}_PUT_response_403"
                                }
                            }
                        }
                    },
                    "422": {
                        "description": "",
                        "content": {
                            "application\/json": {
                                "schema": {
                                    "type": "object",
                                    "properties": {
                                        "message": {
                                            "type": "string",
                                            "example": "The given data was invalid."
                                        },
                                        "errors": {
                                            "type": "object",
                                            "properties": {
                                                "name": {
                                                    "type": "array",
                                                    "items": {
                                                        "type": "string",
                                                        "example": "The name field is required."
                                                    }
                                                }
                                            },
                                            "required": [
                                                "name"
                                            ]
                                        }
                                    },
                                    "required": [
                                        "message",
                                        "errors"
                                    ],
                                    "title": "\/api\/job\/{job}_PUT_response_422"
                                }
                            }
                        }
                    }
                },
                "parameters": [
                    {
                        "in": "header",
                        "name": "Content-Type",
                        "schema": {
                            "type": "string"
                        },
                        "description": "application\/json"
                    },
                    {
                        "in": "header",
                        "name": "Accept",
                        "schema": {
                            "type": "string"
                        },
                        "description": "application\/json"
                    },
                    {
                        "in": "path",
                        "name": "job",
                        "required": true,
                        "schema": {
                            "type": "integer"
                        },
                        "description": "22"
                    }
                ],
                "requestBody": {
                    "content": {
                        "application\/json": {
                            "schema": {
                                "type": "object",
                                "properties": {
                                    "name": {
                                        "type": "string",
                                        "example": "Ms. Ana Rosenbaum"
                                    },
                                    "user_id": {
                                        "type": "integer",
                                        "example": 47
                                    }
                                },
                                "required": [
                                    "name",
                                    "user_id"
                                ],
                                "title": "\/api\/job\/{job}_PUT_request"
                            }
                        }
                    }
                }
            }
        }
    },
    "components": {
        "securitySchemes": {
            "bearerAuth": {
                "type": "http",
                "scheme": "bearer",
                "bearerFormat": "JWT"
            }
        }
    }
}

で、これをどう使うのかというと

import { ApiJobJobPUTRequest, ApiJobJobPUTResponse403, ApiJobJobPUTResponse422, DefaultApi } from "~~/spec"

export default async () => {
    const api = new DefaultApi
    const param: ApiJobJobPUTRequest = {
        name: "user name",
        user_id: 1
    }
    const { data, status } = await api.apiJobJobPUT({
        job: 1,
        apiJobJobPUTRequest: param
    })
    switch (status) {
        case 403:
            return { data: data as any as ApiJobJobPUTResponse403, status }
        case 422:
            return { data: data as any as ApiJobJobPUTResponse422, status }
        default:
            return { data: data, status: status as 200 }
    }
}

上記のように、リクエストパラメータに ApiJobJobPUTRequest 型を付けて送るのは通常パターンではあるが、その返却されたステータスコードを元に switch 文で型を詰め直して返却している。 この関数の返り値は下記のようになり、ステータスコードと中のデータが合わさったunion型のPromiseが返る

() => Promise<{
    data: ApiJobJobPUTResponse403;
    status: 403;
} | {
    data: ApiJobJobPUTResponse422;
    status: 422;
} | {
    data: ApiJobJobPUTResponse200;
    status: 200;
}>

この返り値は下記のようにif文で分岐させることによってほしいデータの型を得ることができる

const res = await fn()

if (res.status === 200) {
    // ApiJobJobPUTResponse200
    res.data.name
}
if (res.status === 422) {
    // ApiJobJobPUTResponse422
    res.data.message
}
if (res.status === 403) {
    // ApiJobJobPUTResponse403
    res.data.trace
}

ちなみにVue3 script setupでは下記のように呼び出すと v-if での分岐で参照データの切り分けができるようになる。(useFetchはNuxt3の関数)

<template>
    <div>
        <div v-if="data.status === 200">{{ data.data.name }}</div>
        <div v-if="data.status === 422">{{ data.data.message }}</div>
        <div v-if="data.status === 403">{{ data.data.trace }}</div>
    </div>
</template>
<script setup lang="ts">
const { data } = await useFetch("/api/job/update")

</script>

こうすることでフロントエンドでステータスコードごとに型安全な表示の切り替えを行うことができるようになる

まとめ

今回は利用ケースとして有り得そうな2つのケースをベースに紹介させてもらった。 もしかしたらこういう使い方もできるかも?というのがあればぜひコメントとかいただけると嬉しいです。