rest-to-graphql-migrator について
API チーム向けの REST から GraphQL への移行スキルです。既存の REST エンドポイントをラップするところから始め、既存クライアントとの互換性を保ちながら段階的な移行を支援します。
主な機能
- 01REST エンドポイントの分析と GraphQL スキーマ設計
- 02REST データソースによる既存 API のラップ
- 03リゾルバーとデータ変換の実装
- 04DataLoader による N+1 リクエストの削減
- 05段階的な移行と REST 廃止の計画
活用シーン
- 01バックエンドをすぐ変更せずに GraphQL を追加
- 02大規模な本番 API をエンドポイント単位で移行
- 03複数の REST サービスのスキーマを統合
- 04クライアントを REST から GraphQL へ段階的に移行
インストール
コマンドをコピーしてターミナルで実行します。
$
npx skills add https:/ / github.com/ patricio0312rev/ skills --skill rest-to-graphql-migratorインストール後、Claude Code で/rest-to-graphql-migratorを入力して読み込まれたか確認します。
詳細
REST から GraphQL への移行ツール
既存のクライアントを壊すことなく、REST API を段階的に GraphQL へ移行します。
基本的なワークフロー
- REST エンドポイントを分析する:既存の API を文書化します
- GraphQL スキーマを設計する:REST を型にマッピングします
- REST データソースを作成する:既存のエンドポイントをラップします
- リゾルバーを実装する:REST に接続します
- 段階的に移行する:一度に 1 つのエンドポイントを移行します
- REST を非推奨にする:段階的に終了します
移行戦略
戦略の比較
| 戦略 | 最適な用途 | 複雑さ |
|---|---|---|
| ラッパー | 手早く始めたい場合、バックエンドを変更しない場合 | 低 |
| 段階的移行 | 大規模な API、本番システム | 中 |
| 書き換え | グリーンフィールドの機会 | 高 |
REST データソースのラッパー
Apollo RESTDataSource
// datasources/users.datasource.ts
import { RESTDataSource } from '@apollo/datasource-rest';
export class UsersAPI extends RESTDataSource {
override baseURL = process.env.REST_API_URL;
// Add auth header
override willSendRequest(_path: string, request: AugmentedRequest) {
request.headers['Authorization'] = this.context.token;
}
// GET /api/users
async getUsers(params?: { page?: number; limit?: number }) {
const query = new URLSearchParams();
if (params?.page) query.set('page', String(params.page));
if (params?.limit) query.set('limit', String(params.limit));
return this.get<User[]>(`/api/users?${query}`);
}
// GET /api/users/:id
async getUser(id: string) {
return this.get<User>(`/api/users/${id}`);
}
// POST /api/users
async createUser(input: CreateUserInput) {
return this.post<User>('/api/users', { body: input });
}
// PATCH /api/users/:id
async updateUser(id: string, input: UpdateUserInput) {
return this.patch<User>(`/api/users/${id}`, { body: input });
}
// DELETE /api/users/:id
async deleteUser(id: string) {
await this.delete(`/api/users/${id}`);
return true;
}
// GET /api/users/:id/posts
async getUserPosts(userId: string) {
return this.get<Post[]>(`/api/users/${userId}/posts`);
}
}
REST を GraphQL の型にマッピングする
// REST Response
interface RESTUser {
id: number;
user_name: string;
email_address: string;
created_at: string;
profile_image_url: string | null;
}
// GraphQL Type (camelCase, proper types)
interface User {
id: string;
username: string;
email: string;
createdAt: Date;
avatar: string | null;
}
// Transformer
function transformUser(restUser: RESTUser): User {
return {
id: String(restUser.id),
username: restUser.user_name,
email: restUser.email_address,
createdAt: new Date(restUser.created_at),
avatar: restUser.profile_image_url,
};
}
キャッシュ付きデータソース
// datasources/products.datasource.ts
export class ProductsAPI extends RESTDataSource {
override baseURL = process.env.REST_API_URL;
// Cache for 1 hour
private cacheOptions = { ttl: 3600 };
async getProduct(id: string) {
const data = await this.get<RESTProduct>(`/api/products/${id}`, {
cacheOptions: this.cacheOptions,
});
return transformProduct(data);
}
async getProducts(filters: ProductFilters) {
const params = new URLSearchParams();
Object.entries(filters).forEach(([key, value]) => {
if (value !== undefined) params.set(key, String(value));
});
const data = await this.get<RESTProduct[]>(`/api/products?${params}`);
return data.map(transformProduct);
}
// Bust cache on mutation
async updateProduct(id: string, input: UpdateProductInput) {
const data = await this.patch<RESTProduct>(`/api/products/${id}`, {
body: transformToREST(input),
});
// Invalidate cache
this.delete(`/api/products/${id}`);
return transformProduct(data);
}
}
GraphQL スキーマの設計
REST エンドポイントからスキーマを作成する
# Map REST endpoints to GraphQL
# REST: GET /api/users
# REST: GET /api/users/:id
# REST: POST /api/users
# REST: PATCH /api/users/:id
# REST: DELETE /api/users/:id
type User {
id: ID!
username: String!
email: String!
avatar: String
createdAt: DateTime!
# Nested resource: GET /api/users/:id/posts
posts: [Post!]!
# Nested resource: GET /api/users/:id/comments
comments: [Comment!]!
}
type Query {
# GET /api/users
users(page: Int, limit: Int): [User!]!
# GET /api/users/:id
user(id: ID!): User
}
type Mutation {
# POST /api/users
createUser(input: CreateUserInput!): User!
# PATCH /api/users/:id
updateUser(id: ID!, input: UpdateUserInput!): User!
# DELETE /api/users/:id
deleteUser(id: ID!): Boolean!
}
input CreateUserInput {
username: String!
email: String!
password: String!
}
input UpdateUserInput {
username: String
email: String
avatar: String
}
REST バックエンドを使用するリゾルバー
// resolvers/user.resolvers.ts
import { Resolvers } from '../generated/types';
export const userResolvers: Resolvers = {
Query: {
users: async (_, { page, limit }, { dataSources }) => {
const users = await dataSources.usersAPI.getUsers({ page, limit });
return users.map(transformUser);
},
user: async (_, { id }, { dataSources }) => {
try {
const user = await dataSources.usersAPI.getUser(id);
return transformUser(user);
} catch (error) {
if (error.extensions?.response?.status === 404) {
return null;
}
throw error;
}
},
},
Mutation: {
createUser: async (_, { input }, { dataSources }) => {
const user = await dataSources.usersAPI.createUser(
transformInputToREST(input)
);
return transformUser(user);
},
updateUser: async (_, { id, input }, { dataSources }) => {
const user = await dataSources.usersAPI.updateUser(
id,
transformInputToREST(input)
);
return transformUser(user);
},
deleteUser: async (_, { id }, { dataSources }) => {
return dataSources.usersAPI.deleteUser(id);
},
},
User: {
// Resolve nested resources
posts: async (parent, _, { dataSources }) => {
const posts = await dataSources.usersAPI.getUserPosts(parent.id);
return posts.map(transformPost);
},
comments: async (parent, _, { dataSources }) => {
const comments = await dataSources.usersAPI.getUserComments(parent.id);
return comments.map(transformComment);
},
},
};
N+1 問題を防ぐ DataLoader
// loaders/user.loader.ts
import DataLoader from 'dataloader';
import { UsersAPI } from '../datasources/users.datasource';
export function createUserLoader(usersAPI: UsersAPI) {
return new DataLoader<string, User>(async (ids) => {
// Batch REST calls or use batch endpoint if available
// Option 1: Parallel individual calls
const users = await Promise.all(
ids.map((id) => usersAPI.getUser(id).catch(() => null))
);
return ids.map((id) => users.find((u) => u?.id === id) || null);
// Option 2: Use batch endpoint if available
// const users = await usersAPI.getUsersByIds([...ids]);
// return ids.map(id => users.find(u => u.id === id) || null);
});
}
// Usage in resolver
User: {
author: async (parent, _, { loaders }) => {
return loaders.users.load(parent.authorId);
},
}
段階的な移行
フェーズ 1:ラッパーレイヤー
// Start with GraphQL wrapping REST
// All data still flows through REST API
const server = new ApolloServer({
typeDefs,
resolvers,
dataSources: () => ({
usersAPI: new UsersAPI(),
postsAPI: new PostsAPI(),
commentsAPI: new CommentsAPI(),
}),
});
フェーズ 2:データベースへの直接アクセス
// Migrate critical paths to direct DB
// Keep REST as fallback
const userResolvers: Resolvers = {
Query: {
user: async (_, { id }, { dataSources, db }) => {
// Try direct DB first
const user = await db.user.findUnique({ where: { id } });
if (user) return user;
// Fallback to REST
return dataSources.usersAPI.getUser(id);
},
},
};
フェーズ 3:完全移行
// All data from database
// REST endpoints deprecated
const userResolvers: Resolvers = {
Query: {
user: async (_, { id }, { db }) => {
return db.user.findUnique({
where: { id },
});
},
users: async (_, { page = 1, limit = 20 }, { db }) => {
return db.user.findMany({
skip: (page - 1) * limit,
take: limit,
});
},
},
User: {
posts: async (parent, _, { loaders }) => {
return loaders.postsByAuthor.load(parent.id);
},
},
};
エラー処理
// Map REST errors to GraphQL errors
import { GraphQLError } from 'graphql';
function handleRESTError(error: any): never {
const status = error.extensions?.response?.status;
const body = error.extensions?.response?.body;
switch (status) {
case 400:
throw new GraphQLError(body?.message || 'Bad request', {
extensions: { code: 'BAD_USER_INPUT' },
});
case 401:
throw new GraphQLError('Not authenticated', {
extensions: { code: 'UNAUTHENTICATED' },
});
case 403:
throw new GraphQLError('Not authorized', {
extensions: { code: 'FORBIDDEN' },
});
case 404:
throw new GraphQLError('Resource not found', {
extensions: { code: 'NOT_FOUND' },
});
case 409:
throw new GraphQLError(body?.message || 'Conflict', {
extensions: { code: 'CONFLICT' },
});
default:
throw new GraphQLError('Internal server error', {
extensions: { code: 'INTERNAL_SERVER_ERROR' },
});
}
}
// Usage in resolver
async getUser(id: string) {
try {
return await this.dataSources.usersAPI.getUser(id);
} catch (error) {
handleRESTError(error);
}
}
スキーマのスティッチング
複数の REST サービスを統合する
// Stitch multiple REST backends
import { stitchSchemas } from '@graphql-tools/stitch';
const usersSchema = makeExecutableSchema({
typeDefs: usersTypeDefs,
resolvers: usersResolvers,
});
const productsSchema = makeExecutableSchema({
typeDefs: productsTypeDefs,
resolvers: productsResolvers,
});
const ordersSchema = makeExecutableSchema({
typeDefs: ordersTypeDefs,
resolvers: ordersResolvers,
});
const gatewaySchema = stitchSchemas({
subschemas: [
{ schema: usersSchema },
{ schema: productsSchema },
{ schema: ordersSchema },
],
typeMergingOptions: {
// Configure type merging
},
});
非推奨化の戦略
type Query {
# Old endpoint - deprecated
getUser(id: ID!): User @deprecated(reason: "Use user(id:) instead")
# New endpoint
user(id: ID!): User
}
type User {
# Deprecated field with migration path
userName: String @deprecated(reason: "Use username instead")
username: String!
}
ベストプラクティス
- ラッパーから始める:書き換えずに、ラップします
- 段階的に移行する:一度に 1 つのエンドポイントを移行します
- DataLoader を使用する:N+1 クエリを防ぎます
- データの形状を変換する:API の設計を改善します
- キャッシュを追加する:REST API への呼び出しを減らします
- エラーを適切に処理する:REST のエラーを GraphQL にマッピングします
- 段階的に非推奨にする:クライアントに移行の時間を与えます
- 両方を監視する:REST と GraphQL の使用状況を追跡します
出力チェックリスト
REST から GraphQL への移行には、次の項目をすべて含めてください。
- REST エンドポイントの文書化
- GraphQL スキーマの設計
- RESTDataSource の実装
- データトランスフォーマー(REST ↔ GraphQL)
- バッチ処理用の DataLoader
- エラー処理とマッピング
- キャッシュ戦略
- 段階的な移行計画
- 非推奨化アノテーション
- クライアント移行ガイド
コメント
Markdown 対応