| name | rest-to-graphql-migrator |
| description | Migrates REST APIs to GraphQL incrementally with schema stitching, REST datasources, and gradual endpoint migration. Use when users request "migrate to GraphQL", "REST to GraphQL", "GraphQL wrapper", or "API modernization". |
REST to GraphQL Migrator
Incrementally migrate REST APIs to GraphQL without breaking existing clients.
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
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 |
REST Data Source Wrapper
Apollo RESTDataSource
import { RESTDataSource } from '@apollo/datasource-rest';
export class UsersAPI extends RESTDataSource {
override baseURL = process.env.REST_API_URL;
override willSendRequest(_path: string, request: AugmentedRequest) {
request.headers['Authorization'] = this.context.token;
}
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}`);
}
async getUser(id: string) {
return this.get<User>(`/api/users/${id}`);
}
async createUser(input: CreateUserInput) {
return this.post<User>('/api/users', { body: input });
}
async updateUser(id: string, input: UpdateUserInput) {
return this.patch<User>(`/api/users/${id}`, { body: input });
}
async deleteUser(id: string) {
await this.delete(`/api/users/${id}`);
return true;
}
async getUserPosts(userId: string) {
return this.get<Post[]>(`/api/users/${userId}/posts`);
}
}
Map REST to GraphQL Types
interface RESTUser {
id: number;
user_name: string;
email_address: string;
created_at: string;
profile_image_url: string | null;
}
interface User {
id: string;
username: string;
email: string;
createdAt: Date;
avatar: string | null;
}
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
export class ProductsAPI extends RESTDataSource {
override baseURL = process.env.REST_API_URL;
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<[]>();
data.(transformProduct);
}
() {
data = .<>(, {
: (input),
});
.();
(data);
}
}
GraphQL Schema Design
Schema from REST Endpoints
type User {
id: ID!
username: String!
email: String!
avatar: String
createdAt: DateTime!
posts: [Post!]!
comments: [Comment!]!
}
type Query {
users(page: Int, limit: Int): [User!]!
user( ID User
createUser CreateUserInput User
updateUser ID, UpdateUserInput User
deleteUser ID Boolean
CreateUserInput
String
String
String
UpdateUserInput
String
String
String
Resolvers with REST Backend
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);
},
: (_, { id, input }, { dataSources }) => {
user = dataSources..(
id,
(input)
);
(user);
},
: (_, { id }, { dataSources }) => {
dataSources..(id);
},
},
: {
: (parent, _, { dataSources }) => {
posts = dataSources..(parent.);
posts.(transformPost);
},
: (parent, _, { dataSources }) => {
comments = dataSources..(parent.);
comments.(transformComment);
},
},
};
DataLoader for N+1 Prevention
import DataLoader from 'dataloader';
import { UsersAPI } from '../datasources/users.datasource';
export function createUserLoader(usersAPI: UsersAPI) {
return new DataLoader<string, User>(async (ids) => {
const users = await Promise.all(
ids.map((id) => usersAPI.getUser(id).catch(() => null))
);
return ids.map((id) => users.find((u) => u?.id === id) || null);
});
}
User: {
author: (parent, _, { loaders }) => {
loaders..(parent.);
},
}
Incremental Migration
Phase 1: Wrapper Layer
const server = new ApolloServer({
typeDefs,
resolvers,
dataSources: () => ({
usersAPI: new UsersAPI(),
postsAPI: new PostsAPI(),
commentsAPI: new CommentsAPI(),
}),
});
Phase 2: Direct Database Access
const userResolvers: Resolvers = {
Query: {
user: async (_, { id }, { dataSources, db }) => {
const user = await db.user.findUnique({ where: { id } });
if (user) return user;
return dataSources.usersAPI.getUser(id);
},
},
};
Phase 3: Full Migration
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
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 (, {
: { : },
});
:
(body?. || , {
: { : },
});
:
(, {
: { : },
});
}
}
() {
{
...(id);
} (error) {
(error);
}
}
Schema Stitching
Combine Multiple REST Services
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: {
},
});
Deprecation Strategy
type Query {
getUser(id: ID!): User @deprecated(reason: "Use user(id:) instead")
user(id: ID!): User
}
type User {
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
Output Checklist
Every REST to GraphQL migration should include: