Closed8

100日チャレンジ day18 (Side Loadingに対応したAPI)

riddle_tecriddle_tec

昨日
https://zenn.dev/gin_nazo/scraps/dccf026e52feb2


https://blog.framinal.life/entry/2025/04/14/154104

100日チャレンジに感化されたので、アレンジして自分でもやってみます。

やりたいこと

  • 世の中のさまざまなドメインの簡易実装をつくり、バックエンドの実装に慣れる(dbスキーマ設計や、関数の分割、使いやすいインターフェイスの切り方に慣れる
  • 設計力(これはシステムのオーバービューを先に自分で作ってaiに依頼できるようにする
  • 生成aiをつかったバイブコーティングになれる
  • 実際にやったことはzennのスクラップにまとめ、成果はzennのブログにまとめる(アプリ自体の公開は必須ではないかコードはgithubにおく)

できたもの

https://github.com/lirlia/100day_challenge_backend/tree/main/day18_expandable_api

riddle_tecriddle_tec

「Expandable Objects」は “リソース展開 / サイドローディング” という設計パターン

Stripe が expand クエリを使ってネスト先のオブジェクトを 1 リクエストで返す方式は、REST API では昔から使われてきた “関連リソースをまとめて返す” 手法です。代表的な呼び名・バリエーションは以下のとおりです。

呼び名 採用している代表的な仕様 / サービス パラメータ例 メモ
$expand OData(Microsoft 系公式ガイドライン) ?$expand=Products 最古参。階層の多段展開を許可する実装が多い
include(side-loading) JSON:API、Zendesk、Contentful など ?include=comments,author 「サイドローディング」とも呼ばれる。関連リソースを別トップレベル配列にまとめる形式もある
Partial Response / Sparse Fieldsets Google 各種 API、Shopify など ?fields=items/title,kind 展開というより「必要なフィールドだけ返す」仕組み。ネストの深さ指定も可
expand Stripe、Shopify Admin REST など ?expand[]=customer OData の $expand に語感を合わせた実装。Stripe が最も有名

ポイント

  • GraphQL の「クライアントが欲しいフィールドだけ宣言する」という長所を、既存 REST エンドポイントを壊さずに 再現するプラクティス。
  • 正式な RFC 標準があるわけではなく、類似パラメータ名を各社が実装している デファクト手法
  • 最近は JSON:APIinclude + fields を参考にする例が増え、呼称としては Resource Expansion / Side-Loading がよく使われる。

設計時のヒント

  1. デフォルトは最小、必要なら展開
    • 通常レスポンスは ID だけ返し、expand / include を付けたときだけネストを返すことで帯域・パフォーマンスを両立。
  2. 深いネストのガード
    • max_depth=2 などで過剰なドリルダウンを防ぎ、巨大レスポンスや DoS を回避。
  3. キャッシュキーに注意
    • expand パラメータ込みでフルパスをキャッシュキー化し、展開有無でキャッシュが混ざらないようにする。
  4. ページネーションとの併用
    • Stripe 同様に「リスト API では expand は最大 3 項目まで」などガイドラインを置くと実装者が迷わない。

まとめ

  • 正式標準というより“パターン名”:一般には Resource Expansion / Embedding / Side-Loading と呼ばれる。
  • 実装は多い:OData → JSON:API → Stripe の流れで広まり、現在の SaaS API では珍しくない。
  • GraphQL の代替ではなく補完:REST を維持しつつ “フィールド選択性” を持たせたいときの軽量ソリューション。
riddle_tecriddle_tec

承知しました。仕様をさらに具体化しましょう。

1. データモデル定義

User, Post に加えて、ProfileComment モデルを追加し、よりネストした関連データを表現できるようにします。

// prisma/schema.prisma

model User {
  id      Int      @id @default(autoincrement())
  email   String   @unique
  name    String?
  posts   Post[]   // ユーザーが作成した投稿 (1対多)
  comments Comment[] // ユーザーが作成したコメント (1対多)
  profile Profile? // ユーザーのプロフィール (1対1)
}

model Profile {
  id     Int    @id @default(autoincrement())
  bio    String?
  userId Int    @unique // Userへのリレーション (必須)
  user   User   @relation(fields: [userId], references: [id])
}

model Post {
  id        Int      @id @default(autoincrement())
  title     String
  content   String?
  published Boolean  @default(false)
  authorId  Int      // Userへのリレーション (必須)
  author    User     @relation(fields: [authorId], references: [id])
  comments  Comment[] // 投稿へのコメント (1対多)
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
}

model Comment {
  id        Int      @id @default(autoincrement())
  text      String
  postId    Int      // Postへのリレーション (必須)
  post      Post     @relation(fields: [postId], references: [id])
  authorId  Int      // Userへのリレーション (必須)
  author    User     @relation(fields: [authorId], references: [id])
  createdAt DateTime @default(now())
}

このモデルにより、以下のような展開が可能になります。

  • Post から author (User) を展開
  • Post から comments (Comment[]) を展開
  • Post から author.profile (User -> Profile) を展開
  • Post から comments.author (Comment[] -> User) を展開
  • Post から comments.author.profile (Comment[] -> User -> Profile) を展開

2. max_depth パラメータの導入

API リクエスト時に max_depth クエリパラメータを受け付け、展開する関連データの階層の深さを制限できるようにします。

  • パラメータ: max_depth (数値)
  • デフォルト値: 例えば 2 に設定します。指定がない場合はこの値を使用します。
  • 動作:
    • expand パラメータで指定された関連を展開する際、現在の階層の深さが max_depth 以下の場合のみ展開します。
    • 例: /api/posts?expand=author.profile,comments.author.profile&max_depth=2 の場合
      • author: 展開 (深さ 1 <= 2)
      • author.profile: 展開 (深さ 2 <= 2)
      • comments: 展開 (深さ 1 <= 2)
      • comments.author: 展開 (深さ 2 <= 2)
      • comments.author.profile: 展開しない (深さ 3 > 2)

更新された作業計画:

  1. プロジェクト初期化: (変更なし)
    • day18_expandable_api ディレクトリ作成、template コピー、package.json 更新、基本レイアウト、README.md 概要記載。
  2. データモデリングとDB設定:
    • 上記の User, Profile, Post, Comment モデルを prisma/schema.prisma に定義します。
    • npx prisma migrate deploy でマイグレーションを実行します。
    • シードデータを作成・実行します (User, Profile, Post, Comment を含む)。
  3. API エンドポイント実装:
    • /api/posts/route.ts/api/posts/[id]/route.ts (詳細取得用) を作成します。
    • GET リクエストハンドラ内で expandmax_depth クエリパラメータを処理します。
    • expand 文字列(例: author.profile,comments.author)と max_depth を基に、Prisma の include オブジェクトを動的に生成するヘルパー関数を実装します。この関数は再帰的に深さをチェックします。
  4. UI コンポーネント実装:
    • /posts ページ (app/(pages)/posts/page.tsx) で投稿一覧を表示します。
    • /posts/[id] ページ (app/(pages)/posts/[id]/page.tsx) で投稿詳細を表示します。
    • 各ページに、expand パラメータを入力するテキストボックスと、max_depth を指定する数値入力フィールドを設けます。
    • 入力値に基づいて API にリクエストを送信し、取得したデータを整形して表示します(展開されたデータがあればそれも表示)。
  5. デバッグとテスト:
    • curl で様々な expandmax_depth の組み合わせを試し、API のレスポンスを確認します。特に深さ制限が正しく機能するかを重点的に見ます。
    • ブラウザで UI を操作し、入力に応じて表示が変化することを確認します。
  6. ドキュメント作成:
    • README.md に更新されたデータモデル、API の expand および max_depth パラメータの詳細な使い方を記載します。
    • .cursor/rules/knowledge.mdc を更新します。

この仕様と計画で進めて問題ないでしょうか? 問題なければ、プロジェクトの初期化から開始します。

riddle_tecriddle_tec
/**
 * Parses the 'expand' query parameter string and builds a Prisma 'include' object.
 * Supports nested relations (e.g., "comments.author") and limits expansion depth.
 *
 * @param expandString Comma-separated string of relations to expand (e.g., "author,comments.author").
 * @param maxDepth Maximum expansion depth. Defaults to 2.
 * @returns A Prisma 'include' object or undefined if expandString is null/empty.
 */
function parseExpandString(expandString: string | null, maxDepth: number = 2): Record<string, any> | undefined {
    if (!expandString) return undefined;

    const include: Record<string, any> = {};
    // クエリパラメータ前後の空白を削除し、空の要素を除去
    const paths = expandString.split(',').map(s => s.trim()).filter(Boolean);

    paths.forEach(path => {
        const parts = path.split('.');
        let currentLevel = include;
        let depth = 0;

        for (let i = 0; i < parts.length; i++) {
            const part = parts[i];
            depth++;

            // 深さ制限チェック
            if (depth > maxDepth) {
                console.warn(`Max depth (${maxDepth}) reached for part "${part}" in path "${path}". Skipping further expansion for this path.`);
                break; // このパスのこれ以上の展開はスキップ
            }

            const isLastPart = i === parts.length - 1;

            if (!currentLevel[part]) {
                // この部分が include オブジェクトにまだ存在しない場合
                currentLevel[part] = isLastPart ? true : { include: {} };
            } else if (currentLevel[part] === true && !isLastPart) {
                // 既に true として存在するが、さらにネストが必要な場合 (例: expand=comments,comments.author)
                currentLevel[part] = { include: {} };
            } else if (typeof currentLevel[part] === 'object' && !currentLevel[part].include && !isLastPart) {
                 // オブジェクトだが include プロパティがない場合 (他のパスによって作成された中間オブジェクトかもしれない)
                 // ネストのために include プロパティを追加
                 currentLevel[part].include = {};
            }

            // 次のネストレベルに進む (パスの最後でなく、現在のレベルがネスト可能なオブジェクトの場合)
            if (!isLastPart && typeof currentLevel[part] === 'object' && currentLevel[part].include) {
                 currentLevel = currentLevel[part].include;
            } else if (!isLastPart) {
                 // ネストが必要だが、進めない場合 (例: true になっている、または include がないオブジェクト)
                 // このパスの処理を中断
                 console.warn(`Cannot descend further into path "${path}" at part "${part}" because it's not a valid nested include structure.`);
                 break;
            }
        }
    });

    // console.log("Generated include:", JSON.stringify(include, null, 2));
    return Object.keys(include).length > 0 ? include : undefined;
}
このスクラップは4ヶ月前にクローズされました