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を吐き出してみる
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つのケースをベースに紹介させてもらった。 もしかしたらこういう使い方もできるかも?というのがあればぜひコメントとかいただけると嬉しいです。