1142 lines
26 KiB
Markdown
1142 lines
26 KiB
Markdown
|
|
# REST to GraphQL Migration Guide
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## When to Use This Guide
|
||
|
|
|
||
|
|
**Migrate to GraphQL when:**
|
||
|
|
- Multiple round-trips required for complex UI views
|
||
|
|
- Over-fetching or under-fetching data is problematic
|
||
|
|
- Supporting diverse client needs (mobile, web, desktop)
|
||
|
|
- Team boundaries require federated API architecture
|
||
|
|
- Real-time subscriptions are core requirements
|
||
|
|
- Type safety across client-server boundary needed
|
||
|
|
- API versioning complexity is growing
|
||
|
|
|
||
|
|
**Success indicators:**
|
||
|
|
- Client applications make many sequential REST calls
|
||
|
|
- Different clients need different data shapes
|
||
|
|
- Mobile apps suffer from bandwidth constraints
|
||
|
|
- Frontend teams wait on backend API changes
|
||
|
|
- Multiple REST versions exist concurrently
|
||
|
|
|
||
|
|
## When NOT to Use GraphQL
|
||
|
|
|
||
|
|
**Stick with REST when:**
|
||
|
|
- Simple CRUD operations with stable clients
|
||
|
|
- File upload/download is primary use case
|
||
|
|
- HTTP caching is critical (CDN, browser cache)
|
||
|
|
- Team lacks GraphQL expertise and training budget
|
||
|
|
- Existing REST API is well-designed and sufficient
|
||
|
|
- Third-party integrations require REST endpoints
|
||
|
|
- Query complexity would create security risks
|
||
|
|
|
||
|
|
**Warning signs:**
|
||
|
|
- Team of 1-2 developers (operational overhead)
|
||
|
|
- Primarily server-to-server communication
|
||
|
|
- Static content delivery is the main requirement
|
||
|
|
- No complex data relationship navigation needed
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Concept Mapping: REST to GraphQL
|
||
|
|
|
||
|
|
| REST Concept | GraphQL Equivalent | Notes |
|
||
|
|
|--------------|-------------------|-------|
|
||
|
|
| GET /users | Query users | Read operations |
|
||
|
|
| GET /users/:id | Query user(id: ID!) | Single entity fetch |
|
||
|
|
| POST /users | Mutation createUser | Create operations |
|
||
|
|
| PUT /users/:id | Mutation updateUser | Update operations |
|
||
|
|
| DELETE /users/:id | Mutation deleteUser | Delete operations |
|
||
|
|
| PATCH /users/:id | Mutation updateUserPartial | Partial updates |
|
||
|
|
| Query params (?filter=...) | Field arguments | Filtering/sorting |
|
||
|
|
| URL path segments | Nested field selection | Data relationships |
|
||
|
|
| Multiple endpoints | Single query | Eliminate round-trips |
|
||
|
|
| Webhook callbacks | Subscriptions | Real-time updates |
|
||
|
|
| HTTP status codes | Errors array + data | Partial success model |
|
||
|
|
| API versioning | Schema evolution | Deprecation over versions |
|
||
|
|
| /users?include=posts | users { posts } | Eager loading control |
|
||
|
|
| Offset pagination | Cursor-based connections | Relay specification |
|
||
|
|
| Accept header | Operation selection | Content negotiation |
|
||
|
|
| OAuth/JWT tokens | Context authentication | Same auth patterns |
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Pattern 1: GET Endpoints to Queries
|
||
|
|
|
||
|
|
### REST Endpoint
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// GET /api/users/:id
|
||
|
|
interface UserResponse {
|
||
|
|
id: string;
|
||
|
|
name: string;
|
||
|
|
email: string;
|
||
|
|
created_at: string;
|
||
|
|
posts: Array<{
|
||
|
|
id: string;
|
||
|
|
title: string;
|
||
|
|
published: boolean;
|
||
|
|
}>;
|
||
|
|
}
|
||
|
|
|
||
|
|
app.get('/api/users/:id', async (req, res) => {
|
||
|
|
const user = await db.users.findById(req.params.id);
|
||
|
|
const posts = await db.posts.findByUserId(user.id); // N+1 risk
|
||
|
|
|
||
|
|
res.json({
|
||
|
|
id: user.id,
|
||
|
|
name: user.name,
|
||
|
|
email: user.email,
|
||
|
|
created_at: user.createdAt.toISOString(),
|
||
|
|
posts: posts.map(p => ({
|
||
|
|
id: p.id,
|
||
|
|
title: p.title,
|
||
|
|
published: p.published
|
||
|
|
}))
|
||
|
|
});
|
||
|
|
});
|
||
|
|
```
|
||
|
|
|
||
|
|
### GraphQL Schema
|
||
|
|
|
||
|
|
```graphql
|
||
|
|
type User {
|
||
|
|
id: ID!
|
||
|
|
name: String!
|
||
|
|
email: String!
|
||
|
|
createdAt: DateTime!
|
||
|
|
posts: [Post!]!
|
||
|
|
}
|
||
|
|
|
||
|
|
type Post {
|
||
|
|
id: ID!
|
||
|
|
title: String!
|
||
|
|
published: Boolean!
|
||
|
|
author: User!
|
||
|
|
}
|
||
|
|
|
||
|
|
type Query {
|
||
|
|
user(id: ID!): User
|
||
|
|
users(filter: UserFilter, limit: Int = 20): [User!]!
|
||
|
|
}
|
||
|
|
|
||
|
|
input UserFilter {
|
||
|
|
nameContains: String
|
||
|
|
createdAfter: DateTime
|
||
|
|
}
|
||
|
|
|
||
|
|
scalar DateTime
|
||
|
|
```
|
||
|
|
|
||
|
|
### GraphQL Resolver with DataLoader
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
import DataLoader from 'dataloader';
|
||
|
|
import { IResolvers } from '@graphql-tools/utils';
|
||
|
|
|
||
|
|
// Batch loading to prevent N+1 queries
|
||
|
|
const createPostsByUserIdLoader = (db: Database) =>
|
||
|
|
new DataLoader<string, Post[]>(async (userIds) => {
|
||
|
|
const posts = await db.posts.findByUserIds([...userIds]);
|
||
|
|
|
||
|
|
// Group posts by userId
|
||
|
|
const postsByUserId = userIds.map(id =>
|
||
|
|
posts.filter(post => post.userId === id)
|
||
|
|
);
|
||
|
|
|
||
|
|
return postsByUserId;
|
||
|
|
});
|
||
|
|
|
||
|
|
const createUserByIdLoader = (db: Database) =>
|
||
|
|
new DataLoader<string, User>(async (ids) => {
|
||
|
|
const users = await db.users.findByIds([...ids]);
|
||
|
|
|
||
|
|
// Maintain order matching input ids
|
||
|
|
return ids.map(id => users.find(user => user.id === id));
|
||
|
|
});
|
||
|
|
|
||
|
|
interface Context {
|
||
|
|
db: Database;
|
||
|
|
loaders: {
|
||
|
|
userById: DataLoader<string, User>;
|
||
|
|
postsByUserId: DataLoader<string, Post[]>;
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
const resolvers: IResolvers<any, Context> = {
|
||
|
|
Query: {
|
||
|
|
user: async (_, { id }, { loaders }) => {
|
||
|
|
return loaders.userById.load(id);
|
||
|
|
},
|
||
|
|
|
||
|
|
users: async (_, { filter, limit }, { db }) => {
|
||
|
|
return db.users.find(filter, { limit });
|
||
|
|
},
|
||
|
|
},
|
||
|
|
|
||
|
|
User: {
|
||
|
|
posts: async (user, _, { loaders }) => {
|
||
|
|
// DataLoader batches and caches these calls
|
||
|
|
return loaders.postsByUserId.load(user.id);
|
||
|
|
},
|
||
|
|
},
|
||
|
|
|
||
|
|
Post: {
|
||
|
|
author: async (post, _, { loaders }) => {
|
||
|
|
return loaders.userById.load(post.userId);
|
||
|
|
},
|
||
|
|
},
|
||
|
|
};
|
||
|
|
|
||
|
|
// Apollo Server setup
|
||
|
|
import { ApolloServer } from '@apollo/server';
|
||
|
|
import { startStandaloneServer } from '@apollo/server/standalone';
|
||
|
|
|
||
|
|
const server = new ApolloServer<Context>({
|
||
|
|
typeDefs,
|
||
|
|
resolvers,
|
||
|
|
});
|
||
|
|
|
||
|
|
const { url } = await startStandaloneServer(server, {
|
||
|
|
context: async ({ req }) => {
|
||
|
|
const db = createDatabaseConnection();
|
||
|
|
|
||
|
|
return {
|
||
|
|
db,
|
||
|
|
loaders: {
|
||
|
|
userById: createUserByIdLoader(db),
|
||
|
|
postsByUserId: createPostsByUserIdLoader(db),
|
||
|
|
},
|
||
|
|
};
|
||
|
|
},
|
||
|
|
});
|
||
|
|
```
|
||
|
|
|
||
|
|
### Client Query Examples
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// Flexible field selection - client controls response shape
|
||
|
|
const MINIMAL_USER = gql`
|
||
|
|
query GetUser($id: ID!) {
|
||
|
|
user(id: $id) {
|
||
|
|
id
|
||
|
|
name
|
||
|
|
}
|
||
|
|
}
|
||
|
|
`;
|
||
|
|
|
||
|
|
const DETAILED_USER = gql`
|
||
|
|
query GetUserWithPosts($id: ID!) {
|
||
|
|
user(id: $id) {
|
||
|
|
id
|
||
|
|
name
|
||
|
|
email
|
||
|
|
createdAt
|
||
|
|
posts {
|
||
|
|
id
|
||
|
|
title
|
||
|
|
published
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
`;
|
||
|
|
|
||
|
|
// Single query replacing multiple REST calls
|
||
|
|
const DASHBOARD_DATA = gql`
|
||
|
|
query Dashboard($userId: ID!) {
|
||
|
|
user(id: $userId) {
|
||
|
|
name
|
||
|
|
posts {
|
||
|
|
id
|
||
|
|
title
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
# Would require separate REST endpoint
|
||
|
|
users(filter: { createdAfter: "2025-01-01" }, limit: 5) {
|
||
|
|
id
|
||
|
|
name
|
||
|
|
}
|
||
|
|
}
|
||
|
|
`;
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Pattern 2: POST/PUT/DELETE to Mutations
|
||
|
|
|
||
|
|
### REST Endpoints
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// POST /api/users
|
||
|
|
app.post('/api/users', async (req, res) => {
|
||
|
|
const { name, email, password } = req.body;
|
||
|
|
|
||
|
|
if (!name || !email) {
|
||
|
|
return res.status(400).json({ error: 'Missing required fields' });
|
||
|
|
}
|
||
|
|
|
||
|
|
const user = await db.users.create({ name, email, password });
|
||
|
|
res.status(201).json(user);
|
||
|
|
});
|
||
|
|
|
||
|
|
// PUT /api/users/:id
|
||
|
|
app.put('/api/users/:id', async (req, res) => {
|
||
|
|
const user = await db.users.update(req.params.id, req.body);
|
||
|
|
res.json(user);
|
||
|
|
});
|
||
|
|
|
||
|
|
// DELETE /api/users/:id
|
||
|
|
app.delete('/api/users/:id', async (req, res) => {
|
||
|
|
await db.users.delete(req.params.id);
|
||
|
|
res.status(204).send();
|
||
|
|
});
|
||
|
|
```
|
||
|
|
|
||
|
|
### GraphQL Schema
|
||
|
|
|
||
|
|
```graphql
|
||
|
|
type Mutation {
|
||
|
|
createUser(input: CreateUserInput!): CreateUserPayload!
|
||
|
|
updateUser(input: UpdateUserInput!): UpdateUserPayload!
|
||
|
|
deleteUser(id: ID!): DeleteUserPayload!
|
||
|
|
}
|
||
|
|
|
||
|
|
input CreateUserInput {
|
||
|
|
name: String!
|
||
|
|
email: String!
|
||
|
|
password: String!
|
||
|
|
}
|
||
|
|
|
||
|
|
type CreateUserPayload {
|
||
|
|
user: User
|
||
|
|
errors: [UserError!]!
|
||
|
|
}
|
||
|
|
|
||
|
|
input UpdateUserInput {
|
||
|
|
id: ID!
|
||
|
|
name: String
|
||
|
|
email: String
|
||
|
|
}
|
||
|
|
|
||
|
|
type UpdateUserPayload {
|
||
|
|
user: User
|
||
|
|
errors: [UserError!]!
|
||
|
|
}
|
||
|
|
|
||
|
|
type DeleteUserPayload {
|
||
|
|
deletedId: ID
|
||
|
|
errors: [UserError!]!
|
||
|
|
}
|
||
|
|
|
||
|
|
type UserError {
|
||
|
|
field: String
|
||
|
|
message: String!
|
||
|
|
code: ErrorCode!
|
||
|
|
}
|
||
|
|
|
||
|
|
enum ErrorCode {
|
||
|
|
VALIDATION_ERROR
|
||
|
|
NOT_FOUND
|
||
|
|
UNAUTHORIZED
|
||
|
|
INTERNAL_ERROR
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### GraphQL Mutation Resolvers
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
const resolvers: IResolvers<any, Context> = {
|
||
|
|
Mutation: {
|
||
|
|
createUser: async (_, { input }, { db, user }) => {
|
||
|
|
try {
|
||
|
|
// Validation
|
||
|
|
if (!isValidEmail(input.email)) {
|
||
|
|
return {
|
||
|
|
user: null,
|
||
|
|
errors: [{
|
||
|
|
field: 'email',
|
||
|
|
message: 'Invalid email format',
|
||
|
|
code: 'VALIDATION_ERROR',
|
||
|
|
}],
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
// Check for duplicate
|
||
|
|
const existing = await db.users.findByEmail(input.email);
|
||
|
|
if (existing) {
|
||
|
|
return {
|
||
|
|
user: null,
|
||
|
|
errors: [{
|
||
|
|
field: 'email',
|
||
|
|
message: 'Email already registered',
|
||
|
|
code: 'VALIDATION_ERROR',
|
||
|
|
}],
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
const hashedPassword = await bcrypt.hash(input.password, 10);
|
||
|
|
const newUser = await db.users.create({
|
||
|
|
name: input.name,
|
||
|
|
email: input.email,
|
||
|
|
password: hashedPassword,
|
||
|
|
});
|
||
|
|
|
||
|
|
return {
|
||
|
|
user: newUser,
|
||
|
|
errors: [],
|
||
|
|
};
|
||
|
|
} catch (error) {
|
||
|
|
return {
|
||
|
|
user: null,
|
||
|
|
errors: [{
|
||
|
|
message: 'Failed to create user',
|
||
|
|
code: 'INTERNAL_ERROR',
|
||
|
|
}],
|
||
|
|
};
|
||
|
|
}
|
||
|
|
},
|
||
|
|
|
||
|
|
updateUser: async (_, { input }, { db, user }) => {
|
||
|
|
if (!user || user.id !== input.id) {
|
||
|
|
return {
|
||
|
|
user: null,
|
||
|
|
errors: [{
|
||
|
|
message: 'Unauthorized',
|
||
|
|
code: 'UNAUTHORIZED',
|
||
|
|
}],
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
const updated = await db.users.update(input.id, {
|
||
|
|
...(input.name && { name: input.name }),
|
||
|
|
...(input.email && { email: input.email }),
|
||
|
|
});
|
||
|
|
|
||
|
|
return {
|
||
|
|
user: updated,
|
||
|
|
errors: [],
|
||
|
|
};
|
||
|
|
},
|
||
|
|
|
||
|
|
deleteUser: async (_, { id }, { db, user }) => {
|
||
|
|
if (!user || user.id !== id) {
|
||
|
|
return {
|
||
|
|
deletedId: null,
|
||
|
|
errors: [{ message: 'Unauthorized', code: 'UNAUTHORIZED' }],
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
await db.users.delete(id);
|
||
|
|
|
||
|
|
return {
|
||
|
|
deletedId: id,
|
||
|
|
errors: [],
|
||
|
|
};
|
||
|
|
},
|
||
|
|
},
|
||
|
|
};
|
||
|
|
```
|
||
|
|
|
||
|
|
### Client Mutation Examples
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
const CREATE_USER = gql`
|
||
|
|
mutation CreateUser($input: CreateUserInput!) {
|
||
|
|
createUser(input: $input) {
|
||
|
|
user {
|
||
|
|
id
|
||
|
|
name
|
||
|
|
email
|
||
|
|
createdAt
|
||
|
|
}
|
||
|
|
errors {
|
||
|
|
field
|
||
|
|
message
|
||
|
|
code
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
`;
|
||
|
|
|
||
|
|
// Usage with error handling
|
||
|
|
const [createUser] = useMutation(CREATE_USER);
|
||
|
|
|
||
|
|
const handleSubmit = async (formData) => {
|
||
|
|
const { data } = await createUser({
|
||
|
|
variables: {
|
||
|
|
input: formData,
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
if (data.createUser.errors.length > 0) {
|
||
|
|
// Handle validation errors
|
||
|
|
data.createUser.errors.forEach(error => {
|
||
|
|
setFieldError(error.field, error.message);
|
||
|
|
});
|
||
|
|
} else {
|
||
|
|
// Success - use the returned user
|
||
|
|
navigate(`/users/${data.createUser.user.id}`);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Pattern 3: Pagination Migration
|
||
|
|
|
||
|
|
### REST Offset Pagination
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// GET /api/posts?page=2&limit=20
|
||
|
|
app.get('/api/posts', async (req, res) => {
|
||
|
|
const page = parseInt(req.query.page) || 1;
|
||
|
|
const limit = parseInt(req.query.limit) || 20;
|
||
|
|
const offset = (page - 1) * limit;
|
||
|
|
|
||
|
|
const posts = await db.posts.find({
|
||
|
|
limit,
|
||
|
|
offset,
|
||
|
|
});
|
||
|
|
|
||
|
|
const total = await db.posts.count();
|
||
|
|
|
||
|
|
res.json({
|
||
|
|
data: posts,
|
||
|
|
pagination: {
|
||
|
|
page,
|
||
|
|
limit,
|
||
|
|
total,
|
||
|
|
totalPages: Math.ceil(total / limit),
|
||
|
|
},
|
||
|
|
});
|
||
|
|
});
|
||
|
|
```
|
||
|
|
|
||
|
|
### GraphQL Cursor-Based Pagination (Relay Connections)
|
||
|
|
|
||
|
|
```graphql
|
||
|
|
type Query {
|
||
|
|
posts(
|
||
|
|
first: Int
|
||
|
|
after: String
|
||
|
|
last: Int
|
||
|
|
before: String
|
||
|
|
filter: PostFilter
|
||
|
|
): PostConnection!
|
||
|
|
}
|
||
|
|
|
||
|
|
type PostConnection {
|
||
|
|
edges: [PostEdge!]!
|
||
|
|
pageInfo: PageInfo!
|
||
|
|
totalCount: Int!
|
||
|
|
}
|
||
|
|
|
||
|
|
type PostEdge {
|
||
|
|
node: Post!
|
||
|
|
cursor: String!
|
||
|
|
}
|
||
|
|
|
||
|
|
type PageInfo {
|
||
|
|
hasNextPage: Boolean!
|
||
|
|
hasPreviousPage: Boolean!
|
||
|
|
startCursor: String
|
||
|
|
endCursor: String
|
||
|
|
}
|
||
|
|
|
||
|
|
input PostFilter {
|
||
|
|
published: Boolean
|
||
|
|
authorId: ID
|
||
|
|
titleContains: String
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### Cursor Pagination Resolver
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
import { encodeCursor, decodeCursor } from './cursor-utils';
|
||
|
|
|
||
|
|
const resolvers: IResolvers = {
|
||
|
|
Query: {
|
||
|
|
posts: async (_, args, { db }) => {
|
||
|
|
const { first, after, last, before, filter } = args;
|
||
|
|
|
||
|
|
// Validate pagination args
|
||
|
|
if (first && last) {
|
||
|
|
throw new Error('Cannot specify both first and last');
|
||
|
|
}
|
||
|
|
|
||
|
|
const limit = first || last || 20;
|
||
|
|
const isForward = !!first || !last;
|
||
|
|
|
||
|
|
// Decode cursor to get offset
|
||
|
|
let offset = 0;
|
||
|
|
if (after) {
|
||
|
|
offset = decodeCursor(after) + 1;
|
||
|
|
} else if (before) {
|
||
|
|
offset = Math.max(0, decodeCursor(before) - limit);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Fetch one extra to determine hasNextPage
|
||
|
|
const posts = await db.posts.find({
|
||
|
|
filter,
|
||
|
|
limit: limit + 1,
|
||
|
|
offset,
|
||
|
|
orderBy: { createdAt: isForward ? 'DESC' : 'ASC' },
|
||
|
|
});
|
||
|
|
|
||
|
|
const hasMore = posts.length > limit;
|
||
|
|
const nodes = hasMore ? posts.slice(0, limit) : posts;
|
||
|
|
|
||
|
|
if (!isForward) {
|
||
|
|
nodes.reverse();
|
||
|
|
}
|
||
|
|
|
||
|
|
const edges = nodes.map((post, index) => ({
|
||
|
|
node: post,
|
||
|
|
cursor: encodeCursor(offset + index),
|
||
|
|
}));
|
||
|
|
|
||
|
|
const totalCount = await db.posts.count(filter);
|
||
|
|
|
||
|
|
return {
|
||
|
|
edges,
|
||
|
|
pageInfo: {
|
||
|
|
hasNextPage: isForward ? hasMore : offset > 0,
|
||
|
|
hasPreviousPage: !isForward ? hasMore : offset > 0,
|
||
|
|
startCursor: edges[0]?.cursor,
|
||
|
|
endCursor: edges[edges.length - 1]?.cursor,
|
||
|
|
},
|
||
|
|
totalCount,
|
||
|
|
};
|
||
|
|
},
|
||
|
|
},
|
||
|
|
};
|
||
|
|
|
||
|
|
// cursor-utils.ts
|
||
|
|
export const encodeCursor = (offset: number): string => {
|
||
|
|
return Buffer.from(`cursor:${offset}`).toString('base64');
|
||
|
|
};
|
||
|
|
|
||
|
|
export const decodeCursor = (cursor: string): number => {
|
||
|
|
const decoded = Buffer.from(cursor, 'base64').toString('utf-8');
|
||
|
|
return parseInt(decoded.replace('cursor:', ''));
|
||
|
|
};
|
||
|
|
```
|
||
|
|
|
||
|
|
### Client Pagination Query
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
const POSTS_QUERY = gql`
|
||
|
|
query Posts($first: Int!, $after: String, $filter: PostFilter) {
|
||
|
|
posts(first: $first, after: $after, filter: $filter) {
|
||
|
|
edges {
|
||
|
|
node {
|
||
|
|
id
|
||
|
|
title
|
||
|
|
published
|
||
|
|
author {
|
||
|
|
name
|
||
|
|
}
|
||
|
|
}
|
||
|
|
cursor
|
||
|
|
}
|
||
|
|
pageInfo {
|
||
|
|
hasNextPage
|
||
|
|
endCursor
|
||
|
|
}
|
||
|
|
totalCount
|
||
|
|
}
|
||
|
|
}
|
||
|
|
`;
|
||
|
|
|
||
|
|
// Infinite scroll implementation
|
||
|
|
const PostList = () => {
|
||
|
|
const { data, loading, fetchMore } = useQuery(POSTS_QUERY, {
|
||
|
|
variables: { first: 20 },
|
||
|
|
});
|
||
|
|
|
||
|
|
const loadMore = () => {
|
||
|
|
fetchMore({
|
||
|
|
variables: {
|
||
|
|
after: data.posts.pageInfo.endCursor,
|
||
|
|
},
|
||
|
|
updateQuery: (prev, { fetchMoreResult }) => {
|
||
|
|
if (!fetchMoreResult) return prev;
|
||
|
|
|
||
|
|
return {
|
||
|
|
posts: {
|
||
|
|
...fetchMoreResult.posts,
|
||
|
|
edges: [
|
||
|
|
...prev.posts.edges,
|
||
|
|
...fetchMoreResult.posts.edges,
|
||
|
|
],
|
||
|
|
},
|
||
|
|
};
|
||
|
|
},
|
||
|
|
});
|
||
|
|
};
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div>
|
||
|
|
{data?.posts.edges.map(({ node }) => (
|
||
|
|
<PostCard key={node.id} post={node} />
|
||
|
|
))}
|
||
|
|
|
||
|
|
{data?.posts.pageInfo.hasNextPage && (
|
||
|
|
<button onClick={loadMore}>Load More</button>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
};
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Pattern 4: Authentication Translation
|
||
|
|
|
||
|
|
### REST Authentication
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// REST middleware
|
||
|
|
app.use(async (req, res, next) => {
|
||
|
|
const token = req.headers.authorization?.replace('Bearer ', '');
|
||
|
|
|
||
|
|
if (token) {
|
||
|
|
try {
|
||
|
|
const payload = jwt.verify(token, process.env.JWT_SECRET);
|
||
|
|
req.user = await db.users.findById(payload.userId);
|
||
|
|
} catch (error) {
|
||
|
|
return res.status(401).json({ error: 'Invalid token' });
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
next();
|
||
|
|
});
|
||
|
|
```
|
||
|
|
|
||
|
|
### GraphQL Authentication Context
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
import { ApolloServer } from '@apollo/server';
|
||
|
|
import { GraphQLError } from 'graphql';
|
||
|
|
|
||
|
|
interface AuthContext {
|
||
|
|
user: User | null;
|
||
|
|
requireAuth: () => User;
|
||
|
|
}
|
||
|
|
|
||
|
|
const server = new ApolloServer<AuthContext>({
|
||
|
|
typeDefs,
|
||
|
|
resolvers,
|
||
|
|
});
|
||
|
|
|
||
|
|
await startStandaloneServer(server, {
|
||
|
|
context: async ({ req }) => {
|
||
|
|
const token = req.headers.authorization?.replace('Bearer ', '');
|
||
|
|
let user: User | null = null;
|
||
|
|
|
||
|
|
if (token) {
|
||
|
|
try {
|
||
|
|
const payload = jwt.verify(token, process.env.JWT_SECRET);
|
||
|
|
user = await db.users.findById(payload.userId);
|
||
|
|
} catch (error) {
|
||
|
|
// Token invalid - continue with user = null
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return {
|
||
|
|
user,
|
||
|
|
db,
|
||
|
|
loaders: createLoaders(db),
|
||
|
|
|
||
|
|
// Helper to enforce authentication
|
||
|
|
requireAuth: (): User => {
|
||
|
|
if (!user) {
|
||
|
|
throw new GraphQLError('Authentication required', {
|
||
|
|
extensions: { code: 'UNAUTHENTICATED' },
|
||
|
|
});
|
||
|
|
}
|
||
|
|
return user;
|
||
|
|
},
|
||
|
|
};
|
||
|
|
},
|
||
|
|
});
|
||
|
|
```
|
||
|
|
|
||
|
|
### Field-Level Authorization
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
import { GraphQLFieldResolver } from 'graphql';
|
||
|
|
|
||
|
|
// Authorization directive
|
||
|
|
const resolvers: IResolvers = {
|
||
|
|
Query: {
|
||
|
|
me: (_, __, { requireAuth }) => {
|
||
|
|
const user = requireAuth();
|
||
|
|
return user;
|
||
|
|
},
|
||
|
|
|
||
|
|
users: (_, __, { user }) => {
|
||
|
|
// Optional auth - different data based on auth state
|
||
|
|
if (user?.role === 'ADMIN') {
|
||
|
|
return db.users.findAll();
|
||
|
|
}
|
||
|
|
|
||
|
|
// Public view - limited fields
|
||
|
|
return db.users.findPublic();
|
||
|
|
},
|
||
|
|
},
|
||
|
|
|
||
|
|
User: {
|
||
|
|
email: (user, _, { user: currentUser }) => {
|
||
|
|
// Field-level privacy
|
||
|
|
if (currentUser?.id === user.id || currentUser?.role === 'ADMIN') {
|
||
|
|
return user.email;
|
||
|
|
}
|
||
|
|
return null;
|
||
|
|
},
|
||
|
|
},
|
||
|
|
};
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## BFF (Backend for Frontend) Architecture
|
||
|
|
|
||
|
|
### Multi-Client GraphQL Gateway
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// Schema stitching for different clients
|
||
|
|
import { stitchSchemas } from '@graphql-tools/stitch';
|
||
|
|
|
||
|
|
// Mobile-optimized schema
|
||
|
|
const mobileSchema = makeExecutableSchema({
|
||
|
|
typeDefs: `
|
||
|
|
type Query {
|
||
|
|
# Denormalized for fewer round-trips
|
||
|
|
dashboard: MobileDashboard!
|
||
|
|
}
|
||
|
|
|
||
|
|
type MobileDashboard {
|
||
|
|
user: User!
|
||
|
|
recentPosts: [Post!]!
|
||
|
|
notifications: [Notification!]!
|
||
|
|
# All data needed for mobile home screen
|
||
|
|
}
|
||
|
|
`,
|
||
|
|
resolvers: mobileResolvers,
|
||
|
|
});
|
||
|
|
|
||
|
|
// Web-optimized schema
|
||
|
|
const webSchema = makeExecutableSchema({
|
||
|
|
typeDefs: `
|
||
|
|
type Query {
|
||
|
|
# Granular for efficient caching
|
||
|
|
user(id: ID!): User
|
||
|
|
posts(filter: PostFilter): PostConnection!
|
||
|
|
notifications(unreadOnly: Boolean): [Notification!]!
|
||
|
|
}
|
||
|
|
`,
|
||
|
|
resolvers: webResolvers,
|
||
|
|
});
|
||
|
|
|
||
|
|
// Client-specific servers
|
||
|
|
const mobileServer = new ApolloServer({
|
||
|
|
schema: mobileSchema,
|
||
|
|
introspection: true,
|
||
|
|
});
|
||
|
|
|
||
|
|
const webServer = new ApolloServer({
|
||
|
|
schema: webSchema,
|
||
|
|
introspection: true,
|
||
|
|
});
|
||
|
|
|
||
|
|
// Route based on client header
|
||
|
|
app.use('/graphql', (req, res) => {
|
||
|
|
const client = req.headers['x-client-type'];
|
||
|
|
|
||
|
|
if (client === 'mobile') {
|
||
|
|
return mobileServer.handleRequest(req, res);
|
||
|
|
}
|
||
|
|
|
||
|
|
return webServer.handleRequest(req, res);
|
||
|
|
});
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Incremental Migration Strategy
|
||
|
|
|
||
|
|
### Phase 1: GraphQL Wrapper (Weeks 1-2)
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// Wrap existing REST endpoints with GraphQL
|
||
|
|
const resolvers: IResolvers = {
|
||
|
|
Query: {
|
||
|
|
user: async (_, { id }) => {
|
||
|
|
// Call existing REST API internally
|
||
|
|
const response = await fetch(`http://localhost:3000/api/users/${id}`);
|
||
|
|
return response.json();
|
||
|
|
},
|
||
|
|
},
|
||
|
|
};
|
||
|
|
|
||
|
|
// Allows GraphQL adoption without backend rewrites
|
||
|
|
// Clients can start using GraphQL immediately
|
||
|
|
```
|
||
|
|
|
||
|
|
### Phase 2: Parallel Implementation (Weeks 3-6)
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// Implement GraphQL resolvers with direct DB access
|
||
|
|
// Keep REST endpoints running
|
||
|
|
const resolvers: IResolvers = {
|
||
|
|
Query: {
|
||
|
|
user: async (_, { id }, { db }) => {
|
||
|
|
// New implementation - direct database
|
||
|
|
return db.users.findById(id);
|
||
|
|
},
|
||
|
|
},
|
||
|
|
};
|
||
|
|
|
||
|
|
// Feature flag to route traffic
|
||
|
|
const USE_GRAPHQL = process.env.GRAPHQL_ENABLED === 'true';
|
||
|
|
|
||
|
|
app.get('/api/users/:id', async (req, res) => {
|
||
|
|
if (USE_GRAPHQL) {
|
||
|
|
// Forward to GraphQL
|
||
|
|
const result = await graphqlServer.executeOperation({
|
||
|
|
query: `query GetUser($id: ID!) { user(id: $id) { ... } }`,
|
||
|
|
variables: { id: req.params.id },
|
||
|
|
});
|
||
|
|
return res.json(result.data?.user);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Legacy REST implementation
|
||
|
|
const user = await db.users.findById(req.params.id);
|
||
|
|
res.json(user);
|
||
|
|
});
|
||
|
|
```
|
||
|
|
|
||
|
|
### Phase 3: Client Migration (Weeks 7-12)
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// Gradual client migration with monitoring
|
||
|
|
import { setContext } from '@apollo/client/link/context';
|
||
|
|
|
||
|
|
const migrationLink = setContext((_, { headers }) => {
|
||
|
|
return {
|
||
|
|
headers: {
|
||
|
|
...headers,
|
||
|
|
'x-graphql-migration': 'phase-3',
|
||
|
|
},
|
||
|
|
};
|
||
|
|
});
|
||
|
|
|
||
|
|
// A/B test GraphQL vs REST in production
|
||
|
|
// Monitor performance, errors, client satisfaction
|
||
|
|
```
|
||
|
|
|
||
|
|
### Phase 4: REST Deprecation (Week 13+)
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// Deprecate REST endpoints gradually
|
||
|
|
app.get('/api/users/:id', (req, res) => {
|
||
|
|
res.status(410).json({
|
||
|
|
error: 'This endpoint is deprecated',
|
||
|
|
message: 'Please use GraphQL endpoint at /graphql',
|
||
|
|
migrationGuide: 'https://docs.example.com/graphql-migration',
|
||
|
|
sunsetDate: '2025-06-01',
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
// Eventually remove REST entirely
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Common Pitfalls
|
||
|
|
|
||
|
|
### Pitfall 1: N+1 Query Problem
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// BAD - Causes N+1 queries
|
||
|
|
const resolvers = {
|
||
|
|
User: {
|
||
|
|
posts: async (user, _, { db }) => {
|
||
|
|
// Called once per user - N queries if you fetch N users
|
||
|
|
return db.posts.findByUserId(user.id);
|
||
|
|
},
|
||
|
|
},
|
||
|
|
};
|
||
|
|
|
||
|
|
// GOOD - Use DataLoader
|
||
|
|
const resolvers = {
|
||
|
|
User: {
|
||
|
|
posts: async (user, _, { loaders }) => {
|
||
|
|
// Batched and cached
|
||
|
|
return loaders.postsByUserId.load(user.id);
|
||
|
|
},
|
||
|
|
},
|
||
|
|
};
|
||
|
|
```
|
||
|
|
|
||
|
|
### Pitfall 2: Exposing Database Schema Directly
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// BAD - Tightly coupled to database
|
||
|
|
type User {
|
||
|
|
user_id: Int! # Database column name
|
||
|
|
first_name: String # Database structure leaks
|
||
|
|
last_name: String
|
||
|
|
created_at: String # Raw DB type
|
||
|
|
}
|
||
|
|
|
||
|
|
// GOOD - API-first design
|
||
|
|
type User {
|
||
|
|
id: ID! # Abstract identifier
|
||
|
|
name: String! # Computed from first + last
|
||
|
|
createdAt: DateTime! # Proper type
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### Pitfall 3: Missing Error Handling
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// BAD - Errors kill entire response
|
||
|
|
const resolvers = {
|
||
|
|
Query: {
|
||
|
|
dashboard: async () => {
|
||
|
|
const user = await fetchUser(); // Throws on error
|
||
|
|
const posts = await fetchPosts(); // Never reached if user fails
|
||
|
|
return { user, posts };
|
||
|
|
},
|
||
|
|
},
|
||
|
|
};
|
||
|
|
|
||
|
|
// GOOD - Partial success model
|
||
|
|
const resolvers = {
|
||
|
|
Query: {
|
||
|
|
dashboard: async () => {
|
||
|
|
return {}; // Return empty object
|
||
|
|
},
|
||
|
|
},
|
||
|
|
|
||
|
|
Dashboard: {
|
||
|
|
user: async (_, __, context) => {
|
||
|
|
try {
|
||
|
|
return await fetchUser();
|
||
|
|
} catch (error) {
|
||
|
|
return null; // Client still gets posts
|
||
|
|
}
|
||
|
|
},
|
||
|
|
|
||
|
|
posts: async () => {
|
||
|
|
try {
|
||
|
|
return await fetchPosts();
|
||
|
|
} catch (error) {
|
||
|
|
return []; // Graceful degradation
|
||
|
|
}
|
||
|
|
},
|
||
|
|
},
|
||
|
|
};
|
||
|
|
```
|
||
|
|
|
||
|
|
### Pitfall 4: Ignoring Query Complexity
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// BAD - No limits on query depth/complexity
|
||
|
|
// Client can write expensive queries that DOS the server
|
||
|
|
|
||
|
|
// GOOD - Implement complexity limits
|
||
|
|
import { createComplexityLimitRule } from 'graphql-validation-complexity';
|
||
|
|
|
||
|
|
const server = new ApolloServer({
|
||
|
|
typeDefs,
|
||
|
|
resolvers,
|
||
|
|
validationRules: [
|
||
|
|
createComplexityLimitRule(1000, {
|
||
|
|
onCost: (cost) => {
|
||
|
|
console.log('Query cost:', cost);
|
||
|
|
},
|
||
|
|
}),
|
||
|
|
],
|
||
|
|
});
|
||
|
|
|
||
|
|
// Assign costs to fields
|
||
|
|
const typeDefs = `
|
||
|
|
type Query {
|
||
|
|
users: [User!]! @cost(complexity: 10)
|
||
|
|
user(id: ID!): User @cost(complexity: 1)
|
||
|
|
}
|
||
|
|
|
||
|
|
type User {
|
||
|
|
posts: [Post!]! @cost(complexity: 5, multipliers: ["first"])
|
||
|
|
}
|
||
|
|
`;
|
||
|
|
```
|
||
|
|
|
||
|
|
### Pitfall 5: Over-Normalization
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// BAD - Too granular, requires many queries
|
||
|
|
type Query {
|
||
|
|
userName(id: ID!): String
|
||
|
|
userEmail(id: ID!): String
|
||
|
|
userPosts(userId: ID!): [Post!]!
|
||
|
|
}
|
||
|
|
|
||
|
|
// GOOD - Logical grouping
|
||
|
|
type Query {
|
||
|
|
user(id: ID!): User
|
||
|
|
}
|
||
|
|
|
||
|
|
type User {
|
||
|
|
name: String!
|
||
|
|
email: String!
|
||
|
|
posts: [Post!]!
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Cross-References
|
||
|
|
|
||
|
|
**Related Skills:**
|
||
|
|
- **graphql-architect/references/schema-design.md** - Type system patterns and schema structure
|
||
|
|
- **graphql-architect/references/federation-guide.md** - Multi-service GraphQL architecture
|
||
|
|
- **backend-developer** - REST API implementation patterns
|
||
|
|
- **api-designer** - API design principles and consistency
|
||
|
|
|
||
|
|
**When to Escalate:**
|
||
|
|
- Federation across microservices → See federation-guide.md
|
||
|
|
- Schema design questions → See schema-design.md
|
||
|
|
- Complex subscription requirements → Consult graphql-architect
|
||
|
|
- Performance optimization → Partner with performance-engineer
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Migration Checklist
|
||
|
|
|
||
|
|
- [ ] Identify most-used REST endpoints
|
||
|
|
- [ ] Map REST resources to GraphQL types
|
||
|
|
- [ ] Design schema following best practices
|
||
|
|
- [ ] Implement DataLoaders for all relations
|
||
|
|
- [ ] Add authentication/authorization
|
||
|
|
- [ ] Implement pagination (cursor-based)
|
||
|
|
- [ ] Set up query complexity limits
|
||
|
|
- [ ] Create client migration plan
|
||
|
|
- [ ] Monitor performance metrics
|
||
|
|
- [ ] Document GraphQL queries for clients
|
||
|
|
- [ ] Train team on GraphQL patterns
|
||
|
|
- [ ] Plan REST endpoint sunset timeline
|
||
|
|
|
||
|
|
**Migration complete when:**
|
||
|
|
- All critical paths use GraphQL
|
||
|
|
- REST endpoints deprecated with sunset dates
|
||
|
|
- Client applications fully migrated
|
||
|
|
- Performance metrics meet or exceed REST baseline
|
||
|
|
- Team confident in GraphQL maintenance
|