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

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

rest api なんだけどレスポンスを expand できるのが便利だったのでこれを学びたい。

「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:API の
include
+fields
を参考にする例が増え、呼称としては Resource Expansion / Side-Loading がよく使われる。
設計時のヒント
-
デフォルトは最小、必要なら展開
- 通常レスポンスは ID だけ返し、
expand
/include
を付けたときだけネストを返すことで帯域・パフォーマンスを両立。
- 通常レスポンスは ID だけ返し、
-
深いネストのガード
-
max_depth=2
などで過剰なドリルダウンを防ぎ、巨大レスポンスや DoS を回避。
-
-
キャッシュキーに注意
-
expand
パラメータ込みでフルパスをキャッシュキー化し、展開有無でキャッシュが混ざらないようにする。
-
-
ページネーションとの併用
- Stripe 同様に「リスト API では
expand
は最大 3 項目まで」などガイドラインを置くと実装者が迷わない。
- Stripe 同様に「リスト API では
まとめ
- 正式標準というより“パターン名”:一般には Resource Expansion / Embedding / Side-Loading と呼ばれる。
- 実装は多い:OData → JSON:API → Stripe の流れで広まり、現在の SaaS API では珍しくない。
- GraphQL の代替ではなく補完:REST を維持しつつ “フィールド選択性” を持たせたいときの軽量ソリューション。

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

できた

/**
* 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ヶ月前にクローズされました