mcpField
R
rest-to-graphql-migrator

rest-to-graphql-migrator

github.com/patricio0312rev開発63265 回インストール41 週間前に更新
リポジトリ

rest-to-graphql-migrator について

API チーム向けの REST から GraphQL への移行スキルです。既存の REST エンドポイントをラップするところから始め、既存クライアントとの互換性を保ちながら段階的な移行を支援します。

主な機能

  1. 01REST エンドポイントの分析と GraphQL スキーマ設計
  2. 02REST データソースによる既存 API のラップ
  3. 03リゾルバーとデータ変換の実装
  4. 04DataLoader による N+1 リクエストの削減
  5. 05段階的な移行と REST 廃止の計画

活用シーン

  1. 01バックエンドをすぐ変更せずに GraphQL を追加
  2. 02大規模な本番 API をエンドポイント単位で移行
  3. 03複数の REST サービスのスキーマを統合
  4. 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 へ移行します。

基本的なワークフロー

  1. REST エンドポイントを分析する:既存の API を文書化します
  2. GraphQL スキーマを設計する:REST を型にマッピングします
  3. REST データソースを作成する:既存のエンドポイントをラップします
  4. リゾルバーを実装する:REST に接続します
  5. 段階的に移行する:一度に 1 つのエンドポイントを移行します
  6. 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. ラッパーから始める:書き換えずに、ラップします
  2. 段階的に移行する:一度に 1 つのエンドポイントを移行します
  3. DataLoader を使用する:N+1 クエリを防ぎます
  4. データの形状を変換する:API の設計を改善します
  5. キャッシュを追加する:REST API への呼び出しを減らします
  6. エラーを適切に処理する:REST のエラーを GraphQL にマッピングします
  7. 段階的に非推奨にする:クライアントに移行の時間を与えます
  8. 両方を監視する:REST と GraphQL の使用状況を追跡します

出力チェックリスト

REST から GraphQL への移行には、次の項目をすべて含めてください。

  • REST エンドポイントの文書化
  • GraphQL スキーマの設計
  • RESTDataSource の実装
  • データトランスフォーマー(REST ↔ GraphQL)
  • バッチ処理用の DataLoader
  • エラー処理とマッピング
  • キャッシュ戦略
  • 段階的な移行計画
  • 非推奨化アノテーション
  • クライアント移行ガイド

コメント