rest-to-graphql-migrator
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseREST to GraphQL Migrator
REST 转 GraphQL 迁移工具
Incrementally migrate REST APIs to GraphQL without breaking existing clients.
在不影响现有客户端的前提下,逐步将REST API迁移至GraphQL。
Core Workflow
核心工作流程
- Analyze REST endpoints: Document existing API
- Design GraphQL schema: Map REST to types
- Create REST data source: Wrap existing endpoints
- Implement resolvers: Connect to REST
- Migrate incrementally: One endpoint at a time
- Deprecate REST: Gradual sunset
- 分析REST端点:记录现有API信息
- 设计GraphQL Schema:将REST接口映射为GraphQL类型
- 创建REST数据源:包装现有端点
- 实现解析器:连接至REST接口
- 渐进式迁移:逐个迁移端点
- 弃用REST:逐步停用旧接口
Migration Strategies
迁移策略
Strategy Comparison
策略对比
| Strategy | Best For | Complexity |
|---|---|---|
| Wrapper | Quick start, no backend changes | Low |
| Gradual | Large APIs, production systems | Medium |
| Rewrite | Greenfield opportunity | High |
| 策略 | 适用场景 | 复杂度 |
|---|---|---|
| 包装器 | 快速启动,无需修改后端 | 低 |
| 渐进式 | 大型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
undefinedgraphql
undefinedMap 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
}
undefinedtype 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
}
undefinedResolvers 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
最佳实践
- Start with wrapper: Don't rewrite, wrap
- Migrate incrementally: One endpoint at a time
- Use DataLoader: Prevent N+1 queries
- Transform data shapes: Improve API design
- Add caching: Reduce REST API calls
- Handle errors properly: Map REST errors to GraphQL
- Deprecate gradually: Give clients time to migrate
- Monitor both: Track REST and GraphQL usage
- 从包装器开始:不要直接重写,先做包装
- 渐进式迁移:逐个迁移端点
- 使用DataLoader:避免N+1查询问题
- 转换数据结构:优化API设计
- 添加缓存:减少REST API调用次数
- 妥善处理错误:将REST错误映射为GraphQL错误
- 逐步弃用:给客户端足够的迁移时间
- 同时监控:跟踪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批量查询配置
- 错误处理与映射
- 缓存策略
- 渐进式迁移计划
- 弃用注解
- 客户端迁移指南