rest-to-graphql-migrator

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

REST to GraphQL Migrator

REST 转 GraphQL 迁移工具

Incrementally migrate REST APIs to GraphQL without breaking existing clients.
在不影响现有客户端的前提下,逐步将REST API迁移至GraphQL。

Core Workflow

核心工作流程

  1. Analyze REST endpoints: Document existing API
  2. Design GraphQL schema: Map REST to types
  3. Create REST data source: Wrap existing endpoints
  4. Implement resolvers: Connect to REST
  5. Migrate incrementally: One endpoint at a time
  6. Deprecate REST: Gradual sunset
  1. 分析REST端点:记录现有API信息
  2. 设计GraphQL Schema:将REST接口映射为GraphQL类型
  3. 创建REST数据源:包装现有端点
  4. 实现解析器:连接至REST接口
  5. 渐进式迁移:逐个迁移端点
  6. 弃用REST:逐步停用旧接口

Migration Strategies

迁移策略

Strategy Comparison

策略对比

StrategyBest ForComplexity
WrapperQuick start, no backend changesLow
GradualLarge APIs, production systemsMedium
RewriteGreenfield opportunityHigh
策略适用场景复杂度
包装器快速启动,无需修改后端
渐进式大型API、生产系统
重写全新项目机会

REST Data Source Wrapper

REST数据源包装器

Apollo RESTDataSource

Apollo RESTDataSource

typescript
// 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`);
  }
}
typescript
// 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`);
  }
}

Map REST to GraphQL Types

将REST映射为GraphQL类型

typescript
// 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,
  };
}
typescript
// 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,
  };
}

Data Source with Caching

带缓存的数据源

typescript
// 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);
  }
}
typescript
// 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 Schema Design

GraphQL Schema设计

Schema from REST Endpoints

基于REST端点的Schema

graphql
undefined
graphql
undefined

Map REST endpoints to GraphQL

Map REST endpoints to GraphQL

REST: GET /api/users

REST: GET /api/users

REST: GET /api/users/:id

REST: GET /api/users/:id

REST: POST /api/users

REST: POST /api/users

REST: PATCH /api/users/:id

REST: PATCH /api/users/:id

REST: DELETE /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 }
undefined
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 }
undefined

Resolvers with REST Backend

基于REST后端的解析器

typescript
// 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);
    },
  },
};
typescript
// 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);
    },
  },
};

DataLoader for N+1 Prevention

DataLoader 解决N+1查询问题

typescript
// 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);
  },
}
typescript
// 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);
  },
}

Incremental Migration

渐进式迁移

Phase 1: Wrapper Layer

阶段1:包装层

typescript
// 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(),
  }),
});
typescript
// 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(),
  }),
});

Phase 2: Direct Database Access

阶段2:直接访问数据库

typescript
// 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);
    },
  },
};
typescript
// 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);
    },
  },
};

Phase 3: Full Migration

阶段3:完全迁移

typescript
// 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);
    },
  },
};
typescript
// 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);
    },
  },
};

Error Handling

错误处理

typescript
// 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);
  }
}
typescript
// 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);
  }
}

Schema Stitching

Schema Stitching

Combine Multiple REST Services

合并多个REST服务

typescript
// 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
  },
});
typescript
// 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
  },
});

Deprecation Strategy

弃用策略

graphql
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!
}
graphql
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!
}

Best Practices

最佳实践

  1. Start with wrapper: Don't rewrite, wrap
  2. Migrate incrementally: One endpoint at a time
  3. Use DataLoader: Prevent N+1 queries
  4. Transform data shapes: Improve API design
  5. Add caching: Reduce REST API calls
  6. Handle errors properly: Map REST errors to GraphQL
  7. Deprecate gradually: Give clients time to migrate
  8. Monitor both: Track REST and GraphQL usage
  1. 从包装器开始:不要直接重写,先做包装
  2. 渐进式迁移:逐个迁移端点
  3. 使用DataLoader:避免N+1查询问题
  4. 转换数据结构:优化API设计
  5. 添加缓存:减少REST API调用次数
  6. 妥善处理错误:将REST错误映射为GraphQL错误
  7. 逐步弃用:给客户端足够的迁移时间
  8. 同时监控:跟踪REST和GraphQL的使用情况

Output Checklist

输出检查清单

Every REST to GraphQL migration should include:
  • REST endpoints documented
  • GraphQL schema designed
  • RESTDataSource implementations
  • Data transformers (REST ↔ GraphQL)
  • DataLoader for batching
  • Error handling and mapping
  • Caching strategy
  • Incremental migration plan
  • Deprecation annotations
  • Client migration guide
每一次REST转GraphQL迁移都应包含以下内容:
  • REST端点文档
  • GraphQL Schema设计
  • RESTDataSource实现
  • 数据转换器(REST ↔ GraphQL)
  • DataLoader批量查询配置
  • 错误处理与映射
  • 缓存策略
  • 渐进式迁移计划
  • 弃用注解
  • 客户端迁移指南