🙄

フロントエンドついでにバックエンドのAPIモックも省力でつくる

2025/02/09に公開

フロントエンドエンジニアの悩み

何かWebアプリケーションをつくるとき、サーバーサイド(API)とフロントエンドで実装担当が分かれることが一般的だと思います。
事業系会社の場合は一つのチームで行うケースもあるようですが、受託会社の場合はそれぞれ担当する会社自体が変わるケースもよくあります。

私はこれまでAPIを利用するフロントエンド側を担当することもAPI側の設計と実装を担当することも経験があります。

どちらかというとこれまでのキャリア的にフロントエンドを担当するケースが多かったのですが、その中で下記のような悩みが発生しました。

  • API開発が予定通りの進行しない、度重なる遅延、しかしフロントエンド実装完了を含むプロジェクト全体の完了日程は変わらず[1]
  • API部分がブラックボックスのままでフロント制作を進行しにくい、仮にダミーデータをコード上のどこかに保持して進めても後のAPI接続フェーズで実装コストが余計に発生する

REST API[2]の仕様はOpenAPI形式で

実際の開発の現場では、API仕様がアプリケーションの要求に適切に応答できるものではなく、ただ単純にデータベースのテーブルをそのままエンドポイントにして格納された情報を提供しているだけのケースや、曖昧さが残ったままで、実際に動作を確認してみるまではどのような応答になるか判別できない仕様書であることは決して珍しくありません。[3]

パブリックに公開されたAPI仕様はOpenAPI(Swagger)形式で提供されることが多いのですが、BFF(Backend for Frontend)専用の内部APIは今でもMicrosoft Word, Excelで定義されていることがあり、この場合はオートコレクト機能によりフィールドの名前が改変されてしまっていたり、記号が全角化されてしまった状態で正しさを保証できる状態でないことが多いのです。
型やHTTPステータス、ヘッダー情報などの重要な情報が欠落していることもあります。
そしてこれらのファイルフォーマットでは、変更点の検知や履歴を追うことが難しくなります。

このため、なるべくコードとしてバージョン管理対象にできるプレーンテキスト形式での仕様書が良いでしょう。

様々な制限の元でWordやExcelを引き続き利用しなければならないケースも考えられますが、過去のプロジェクトにおいて、関係者の間で理解を得られた場合はOpenAPIを導入するよう方針転換して頂いたことが何度かあります。

BFF前提ならば利用者側からAPI仕様を考える

BFF前提なのであればAPI仕様はフロントエンドの担当者[4]が主導して策定し、API実装担当と会話からフィードバックを得る手法が最近では効率的だと思うようになりました。

これであれば、フロントエンドからの要求を適切に仕様に落とし込むことが可能で過不足も防ぎやすくなります。
その後、バックエンドエンジニアから実装コストの観点、データベース構造からの効率性のフィードバックを得て微調整すると良いでしょう。

サンプルデータを定義してモックサーバから返答させる

OpenAPIの素晴らしいところの一つに、そのままモックサーバーとして利用できることが挙げられます。
example(s) として yaml ファイルの中に定義すれば、その内容を返却するようになります。
実際には応答データは膨大な定義になりがちですが、管理しやすいようファイルを分割することも可能です。

ここでは商品一覧と商品詳細のエンドポイントのシンプルな例で考えてみます。

openapi/example.yaml
openapi/example.yaml
openapi: 3.0.3
info:
  title: Product API
  description: 商品情報を取得するためのAPI
  version: 1.0.0

servers:
  - url: http://0.0.0.0:3000/api/v1
    description: Local server (Next.js)
  - url: http://0.0.0.0:8000/api/v1
    description: Local server (PHP)

paths:
  /products:
    get:
      summary: 商品一覧を取得
      description: 商品一覧をページネーション付きで取得します
      parameters:
        - name: productName
          in: query
          required: false
          schema:
            type: string
          description: 商品名で検索
        - name: offset
          in: query
          description: 取得開始位置(デフォルト:0)
          required: false
          schema:
            type: integer
            default: 0
            minimum: 0
        - name: limit
          in: query
          required: false
          schema:
            type: integer
            default: 20
            minimum: 1
            maximum: 100
          description: 1回の取得件数(デフォルト:20、最大:100)
      responses:
        '200':
          description: 成功
          content:
            application/json:
              schema:
                type: object
                properties:
                  total:
                    type: integer
                    description: 全商品数
                  offset:
                    type: integer
                    description: 取得開始位置
                  limit:
                    type: integer
                    description: 1回の取得件数
                  products:
                    type: array
                    items:
                      $ref: '#/components/schemas/Product'
              examples:
                ProductList:
                  value:
                    total: 100
                    offset: 0
                    limit: 20
                    products:
                      - id: 1
                        name: "スマートフォン X"
                        price: 89800
                        description: "最新のスマートフォン"
                        created_at: "2024-03-20T09:00:00Z"
                      - id: 2
                        name: "ワイヤレスイヤホン Y"
                        price: 19800
                        description: "ノイズキャンセリング対応イヤホン"
                        created_at: "2024-03-20T09:00:00Z"
  /products/{productId}:
    get:
      summary: 商品詳細を取得
      description: 指定されたIDの商品詳細を取得します
      parameters:
        - name: productId
          in: path
          description: 商品ID
          required: true
          schema:
            type: integer
      responses:
        '200':
          description: 成功
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Product'
              examples:
                1:
                  value:
                    id: 1
                    name: "スマートフォン X"
                    price: 89800
                    description: "最新のスマートフォン"
                    created_at: "2024-03-20T09:00:00Z"
                2:
                  value:
                    id: 2
                    name: "ワイヤレスイヤホン Y"
                    price: 19800
                    description: "ノイズキャンセリング対応イヤホン"
                    created_at: "2024-03-20T09:00:00Z"
        '404':
          description: 商品が見つかりません
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                code: "NOT_FOUND"
                message: "指定された商品が見つかりません"

components:
  schemas:
    Product:
      type: object
      properties:
        id:
          type: integer
          description: 商品ID
        name:
          type: string
          description: 商品名
        price:
          type: integer
          description: 価格(税抜)
        description:
          type: string
          description: 商品説明
        created_at:
          type: string
          format: date-time
          description: 作成日時
      required:
        - id
        - name
        - price
        - description
        - created_at

    Error:
      type: object
      properties:
        code:
          type: string
          description: エラーコード
        message:
          type: string
          description: エラーメッセージ
      required:
        - code
        - message

コマンドからモックサーバー立ち上げ

モックサーバーの立ち上げには様々な方法がありますが、例えばprismを用いると下記のようになります。

prism mock openapi/example.yaml
レスポンス
{
  "total": 100,
  "offset": 0,
  "limit": 20,
  "products": [
    {
      "id": 1,
      "name": "スマートフォン X",
      "price": 89800,
      "description": "最新のスマートフォン",
      "created_at": "2024-03-20T09:00:00Z"
    },
    {
      "id": 2,
      "name": "ワイヤレスイヤホン Y",
      "price": 19800,
      "description": "ノイズキャンセリング対応イヤホン",
      "created_at": "2024-03-20T09:00:00Z"
    }
  ]
}
{
  "id": 1,
  "name": "スマートフォン X",
  "price": 89800,
  "description": "最新のスマートフォン",
  "created_at": "2024-03-20T09:00:00Z"
}

Next.jsへ組み込み

サーバーサイドでのJavaScript実行を前提としたNext.jsプロジェクトであれば、このプロジェクトの中で yaml ファイルの読み込みとAPIレスポンスを担えばよいでしょう。

utils/openapi.ts
utils/openapi.ts
import fs from 'fs'
import yaml from 'js-yaml'
import path from 'path'

interface OpenAPIDocument {
  paths: {
    [key: string]: {
      [method: string]: {
        responses: {
          [code: string]: {
            content: {
              [contentType: string]: {
                examples?: {
                  [key: string]: {
                    value: unknown
                  }
                }
                example?: unknown
              }
            }
          }
        }
      }
    }
  }
}

interface ProductResponse {
  total: number
  products: Array<{
    id: number
    name: string
    [key: string]: unknown
  }>
  [key: string]: unknown
}

// フィルタリングに使用するパラメータの定義
const filterKeys: Record<string, string[]> = {
  productName: ['name']
}

export function getOpenAPIExample(
  pathPattern: string,
  method: string = 'get',
  statusCode: string = '200',
  queryParams?: URLSearchParams
) {
  const openApiPath = path.join(process.cwd(), 'openapi/example.yaml')
  const fileContents = fs.readFileSync(openApiPath, 'utf8')
  const openApiDoc = yaml.load(fileContents) as OpenAPIDocument

  // パスパラメータを含むパスパターンのマッチング
  const matchingPath = Object.keys(openApiDoc.paths).find(specPath => {
    const specPathRegex = specPath
      .replace(/\{([^}]+)\}/g, '([^/]+)')
      .replace(/\//g, '\\/')
    return new RegExp(`^${specPathRegex}$`).test(pathPattern)
  })

  if (!matchingPath) {
    throw new Error(`Path ${pathPattern} not found in OpenAPI spec`)
  }

  // パスパラメータの値を抽出
  const paramRegex = matchingPath
    .replace(/\{([^}]+)\}/g, '([^/]+)')
    .replace(/\//g, '\\/')
  const matches = new RegExp(`^${paramRegex}$`).exec(pathPattern)
  const paramValues = matches ? matches.slice(1) : []

  const pathObject = openApiDoc.paths[matchingPath]
  const methodObject = pathObject[method.toLowerCase()]
  if (!methodObject) {
    throw new Error(`Method ${method} not found for path ${pathPattern}`)
  }

  const response = methodObject.responses[statusCode]
  if (!response) {
    throw new Error(`Status code ${statusCode} not found for ${method} ${pathPattern}`)
  }

  const content = response.content['application/json']
  let responseData = (content.examples
    ? (paramValues.length > 0
      ? content.examples[paramValues[0]]?.value
      : Object.values(content.examples)[0].value)
    : content.example) as ProductResponse

  // クエリパラメータによるフィルタリング
  if (queryParams) {
    for (const [param, targetFields] of Object.entries(filterKeys)) {
      const searchValue = queryParams.get(param)?.toLowerCase()
      if (searchValue && responseData.products) {
        responseData = {
          ...responseData,
          products: responseData.products.filter((item: any) =>
            targetFields.some(field =>
              item[field]?.toLowerCase().includes(searchValue)
            )
          )
        }
        // 全件数を更新
        responseData.total = responseData.products.length
      }
    }
  }

  return responseData
}
app/api/[...path]/route.ts
app/api/[...path]/route.ts
import { getOpenAPIExample } from '@/utils/openapi'
import { NextRequest, NextResponse } from 'next/server'

export async function GET(
  request: NextRequest,
  { params }: { params: { path: string[] } }
) {
  try {
    const pathSegments = params.path
    const apiPath = '/' + pathSegments.join('/')
    const searchParams = request.nextUrl.searchParams
    const mockData = getOpenAPIExample(apiPath, 'get', '200', searchParams)
    return NextResponse.json(mockData)
  } catch (error) {
    return NextResponse.json(
      { code: 'NOT_FOUND', message: 'エンドポイントが見つかりません' },
      { status: 404 }
    )
  }
}

これによって、先ほどprismで確認していたデータがNext.jsの開発サーバーの中で確認できるようになります。

また、上記のNext.jsの組み込み例では、prismが単純にyamlで定義されたサンプルの1件目だけを返却していたのを進化させて、一覧でのフィルタリングや詳細でのパスパラメータからサンプルのマッチング処理も追加しています。

OpenAPIの定義をベースとしながら、PoCやMockとしてある一定まではデモに耐えられるように肉付けができるということです。

productNameでの一覧の絞り込み例
{
  "total": 1,
  "offset": 0,
  "limit": 20,
  "products": [
    {
      "id": 2,
      "name": "ワイヤレスイヤホン Y",
      "price": 19800,
      "description": "ノイズキャンセリング対応イヤホン",
      "created_at": "2024-03-20T09:00:00Z"
    }
  ]
}
パスパラメータからのマッチング(2つめの製品情報が確認できる)
{
  "id": 2,
  "name": "ワイヤレスイヤホン Y",
  "price": 19800,
  "description": "ノイズキャンセリング対応イヤホン",
  "created_at": "2024-03-20T09:00:00Z"
}

SSGした静的ファイルとPHPファイルで組み込み

Next.js(SSG設定)、AstroGatsbyなどを用いてフロントエンド側は完全にStaticなものとして出力して運用するケースもあるでしょう。
そして暫定的にOpenAPIのyamlからモックサーバーを起動したいが、対象のサーバーでnodeを実行できない、設定する権限がないケースも考えられます。

このようにサーバーサイドでnodeを動かせない環境下でもPHPファイルは動作させられることはよくあります。
この場合はPHPにyamlを読み込んでもらいモックサーバーとして動作させると良いでしょう。[5]

composer.json
composer.json
{
  "require": {
    "cebe/php-openapi": "^1.5",
    "symfony/http-foundation": "^4.4",
    "symfony/uid": "^5.4"
  }
}
mock-server.php
mock-server.php
<?php
require 'vendor/autoload.php';

use cebe\openapi\Reader;
use cebe\openapi\spec\OpenApi;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;

// OpenAPIファイルのパス
$openApiFilePath = __DIR__ . '/../../openapi/example.yaml';

// OpenAPIファイルを読み込む
if (!file_exists($openApiFilePath)) {
    die("OpenAPIファイルが見つかりません: {$openApiFilePath}\n");
}

$openApi = Reader::readFromYamlFile($openApiFilePath);
if (!$openApi instanceof OpenApi || !$openApi->validate()) {
    die("OpenAPIの形式が無効です。\n");
}

// リクエスト処理
$request = Request::createFromGlobals();
$method = strtolower($request->getMethod());
$path = $request->getPathInfo();

// 削除するプレフィックスを定義
$prefixesToRemove = [
    '/api/v1',
];

// 定義されたプレフィックスを順番に確認し、マッチしたものを除去
foreach ($prefixesToRemove as $prefix) {
    if (strpos($path, $prefix) === 0) {
        $path = preg_replace('#^' . preg_quote($prefix, '#') . '#', '', $path);
        break;
    }
}

$responseData = [
    'message' => 'Mock response not defined for this endpoint.',
    'method' => $method,
    'path' => $path,
];
$statusCode = 404;

// フィルタリングに用いるパラメータ
$filterKeys = [
  "productName" => ["name"]
];


// OpenAPIのpathsセクションを探索
foreach ($openApi->paths as $pathPattern => $pathItem) {
    // パスパラメータをregexパターンに変換
    $pattern = preg_replace('/\{([^}]+)\}/', '([^/]+)', $pathPattern);
    $pattern = '#^' . str_replace('/', '\/', $pattern) . '$#';

    if (preg_match($pattern, $path)) {
        if (isset($pathItem->$method)) {
            $operation = $pathItem->$method;
            if (isset($operation->responses['200'])) {
                $response200 = $operation->responses['200'];
                if (isset($response200->content['application/json'])) {
                    $responseContent = $response200->content['application/json'];

                    if ($responseContent->examples !== null) {
                        // examplesをローカル変数にコピー
                        $examples = $responseContent->examples;

                        // パスパラメータを実際の値に置き換えたパターンからパラメータ値を抽出
                        preg_match($pattern, $path, $matches);
                        array_shift($matches); // 最初の完全マッチを除去

                        // パスパラメータの値を取得
                        $paramValues = $matches;
                        $matchedExample = null;

                        // パスパラメータの値とexample名を照合
                        foreach ($examples as $exampleName => $example) {
                            foreach ($paramValues as $paramValue) {
                                $searchName = strtolower($paramValue);

                                if (strtolower($exampleName) === $searchName) {
                                    $matchedExample = $example;
                                    break 2;
                                }
                            }
                        }

                        // マッチしない場合は最初の例を使用
                        if ($matchedExample === null && !empty($examples)) {
                            $matchedExample = reset($examples);
                        }

                        if ($matchedExample && isset($matchedExample->value)) {
                            $responseData = $matchedExample->value;

                            // フィルタリング処理を追加
                            foreach ($filterKeys as $queryParam => $targetFields) {
                                if ($request->query->has($queryParam)) {
                                    $searchValue = strtolower($request->query->get($queryParam));

                                    // resultsの各要素に対してフィルタリング
                                    if (isset($responseData['results'])) {
                                        $responseData['results'] = array_filter(
                                            $responseData['results'],
                                            function($item) use ($targetFields, $searchValue) {
                                                foreach ($targetFields as $field) {
                                                    if (isset($item[$field]) &&
                                                        str_contains(strtolower($item[$field]), $searchValue)) {
                                                        return true;
                                                    }
                                                }
                                                return false;
                                            }
                                        );
                                        // 配列のインデックスを振り直す
                                        $responseData['results'] = array_values($responseData['results']);
                                    }
                                }
                            }

                            $statusCode = 200;
                        }
                    }
                }
            }
        }
    }
}

// レスポンスを送信
$response = new Response(
    json_encode($responseData),
    $statusCode,
    [
        'Content-Type' => 'application/json',
        'Access-Control-Allow-Origin' => '*',
        'Access-Control-Allow-Methods' => 'GET, POST, PUT, DELETE, OPTIONS',
        'Access-Control-Allow-Headers' => 'Content-Type, Authorization'
    ]
);
$response->send();

こちらのPHPプログラムも前述のTypeScriptと同様に一覧のフィルタリングやパスパラメータからサンプルのマッチング処理も追加しています。

ローカル環境で開発を進める際には、 npm run dev などでフロントエンドの開発サーバーを起動するほか、ローカルでの確認時には php -S 0.0.0.0:8000 api/v1/mock-server.php のような形でPHPビルドインサーバーを起動すると良いでしょう。

サーバーへのデプロイはDocumentRoot配下に api/v1/mock-server.php の設置と api/v1/.htaccess の設置(またはそれに準ずる設定)と composer install を行うだけです。

api/v1/.htaccess
api/v1/.htaccess
RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^(.*)$ mock-server.php [QSA,L]

まとめ

  • API設計はサーバーサイドエンジニアの役割と境界線を固定化せず、場合によってはフロントエンドエンジニアが設計を主導しても良さそう
  • OpenAPI形式のyamlを関係者で一元管理することにより、共通認識を確立できる
  • OpenAPIを用いればモックサーバーにそのまま転用でき、PoCやMockとして十分に体験を提供できる
  • Redocly CLIなどを用いて、ヒューマンフレンドリーなHTML文書としてコンバートでき、かつそこからサンプルデータを参照することも可能となる(CI/CDの一連の流れで最新のAPI HTMLドキュメントを見られるURLを提供すると良いでしょう)
  • AIアシスタントとの親和性が高い(エンドポイントの追加や仕様変更は自然言語でAIに依頼することができるようになります)
脚注
  1. 十数年前は今よりもフロントエンドエンジニアはサーバーサイドに比べ軽視されやすい傾向にあったような気がしますが、今はフロントエンドへの要求水準が上がり、このようなケースは起きにくくなったかもしれません。 ↩︎

  2. ここではREST APIと記載しましたが、この文書ではGraphQLについては言及していないためそれを明示的に表すための表現です。Web APIのほうが適切かもしれません。厳格で狭義のRESTを示すものではありません。 ↩︎

  3. 私も過去にフロントエンドエンジニアを悩ませる側になっていたことを反省しています。『Web API: The Good Parts』https://www.oreilly.co.jp/books/9784873116860/ を読んでからは少しはマシになったはず。 ↩︎

  4. モバイルアプリケーションやデスクトップのネイティブアプリケーション担当者のこともあるでしょう。 ↩︎

  5. 月数百円の格安レンタルサーバーでも動作可能でしょう。 ↩︎

Discussion