Skip to content

Commit 6d4f672

Browse files
authored
Merge pull request #107 from t3rr11/feature/adding-basic-graphql-support
Added basic GraphQL support
2 parents 5a8b074 + 8bf3f7a commit 6d4f672

161 files changed

Lines changed: 3546 additions & 277 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/backend/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
"axios": "^1.18.1",
1818
"fastify": "^5.10.0",
1919
"form-data": "^4.0.6",
20+
"graphql": "^16.14.2",
2021
"node-fetch": "^3.3.2",
2122
"simple-git": "^3.36.0",
2223
"zod": "^4.4.3"

apps/backend/src/controllers/collection.controller.ts

Lines changed: 21 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import { FastifyPluginAsync } from 'fastify';
22
import { CollectionService } from '../services/collection.service';
33
import { OpenApiService } from '../services/openapi.service';
4-
import type { AuthConfig, BodyType, FormDataEntry } from '../models/proxy';
4+
import type { SavedRequest } from '../models/collection';
55
import type { SyncApplyBody } from '../models/openapi-sync';
6+
import { createRequestPayloadSchema, updateRequestPayloadSchema } from '../dtos/collection.dto';
67

78
interface Options {
89
collectionService: CollectionService;
@@ -35,48 +36,43 @@ const collectionController: FastifyPluginAsync<Options> = async (server, opts) =
3536
async (request, _reply) => {
3637
const collection = await collectionService.update(request.params.id, request.body);
3738
return collection;
38-
},
39+
}
3940
);
4041

4142
server.delete<{ Params: { id: string } }>('/collections/:id', async (request, _reply) => {
4243
await collectionService.delete(request.params.id);
4344
return { success: true };
4445
});
4546

46-
server.post<{ Params: { id: string } }>(
47-
'/collections/:id/duplicate',
48-
async (request, reply) => {
49-
const collection = await collectionService.duplicateCollection(request.params.id);
50-
return reply.code(201).send(collection);
51-
},
52-
);
47+
server.post<{ Params: { id: string } }>('/collections/:id/duplicate', async (request, reply) => {
48+
const collection = await collectionService.duplicateCollection(request.params.id);
49+
return reply.code(201).send(collection);
50+
});
5351

5452
server.put<{ Params: { id: string }; Body: { targetOrder: number } }>(
5553
'/collections/:id/move',
5654
async (request, _reply) => {
5755
const { targetOrder } = request.body;
5856
const collection = await collectionService.moveCollection(request.params.id, targetOrder);
5957
return collection;
60-
},
58+
}
6159
);
6260

6361
server.post<{
6462
Params: { id: string };
65-
Body: { name: string; method: string; url: string; headers?: Record<string, string>; body?: string; bodyType?: BodyType; formDataEntries?: FormDataEntry[]; auth?: AuthConfig; folderId?: string };
63+
Body: SavedRequest;
6664
}>('/collections/:id/requests', async (request, reply) => {
67-
const { name, method, url, headers, body, bodyType, formDataEntries, auth, folderId } = request.body;
68-
if (!name || !method || !url) {
69-
return reply.code(400).send({ error: 'Name, method, and URL are required' });
70-
}
71-
const saved = await collectionService.addRequest(request.params.id, { name, method, url, headers, body, bodyType, formDataEntries, auth, folderId });
65+
const data = createRequestPayloadSchema.parse(request.body);
66+
const saved = await collectionService.addRequest(request.params.id, data);
7267
return reply.code(201).send(saved);
7368
});
7469

7570
server.put<{
7671
Params: { id: string; requestId: string };
77-
Body: { name?: string; method?: string; url?: string; headers?: Record<string, string>; body?: string; auth?: AuthConfig; folderId?: string };
72+
Body: Partial<SavedRequest>;
7873
}>('/collections/:id/requests/:requestId', async (request, _reply) => {
79-
const saved = await collectionService.updateRequest(request.params.id, request.params.requestId, request.body);
74+
const updates = updateRequestPayloadSchema.parse(request.body);
75+
const saved = await collectionService.updateRequest(request.params.id, request.params.requestId, updates);
8076
return saved;
8177
});
8278

@@ -85,15 +81,15 @@ const collectionController: FastifyPluginAsync<Options> = async (server, opts) =
8581
async (request, _reply) => {
8682
await collectionService.deleteRequest(request.params.id, request.params.requestId);
8783
return { success: true };
88-
},
84+
}
8985
);
9086

9187
server.post<{ Params: { id: string; requestId: string } }>(
9288
'/collections/:id/requests/:requestId/duplicate',
9389
async (request, reply) => {
9490
const saved = await collectionService.duplicateRequest(request.params.id, request.params.requestId);
9591
return reply.code(201).send(saved);
96-
},
92+
}
9793
);
9894

9995
server.post<{ Params: { id: string }; Body: { name: string; parentId?: string } }>(
@@ -105,23 +101,23 @@ const collectionController: FastifyPluginAsync<Options> = async (server, opts) =
105101
}
106102
const saved = await collectionService.addFolder(request.params.id, { name, parentId });
107103
return reply.code(201).send(saved);
108-
},
104+
}
109105
);
110106

111107
server.put<{ Params: { id: string; folderId: string }; Body: { name?: string; parentId?: string } }>(
112108
'/collections/:id/folders/:folderId',
113109
async (request, _reply) => {
114110
const saved = await collectionService.updateFolder(request.params.id, request.params.folderId, request.body);
115111
return saved;
116-
},
112+
}
117113
);
118114

119115
server.delete<{ Params: { id: string; folderId: string } }>(
120116
'/collections/:id/folders/:folderId',
121117
async (request, _reply) => {
122118
await collectionService.deleteFolder(request.params.id, request.params.folderId);
123119
return { success: true };
124-
},
120+
}
125121
);
126122

127123
server.put<{
@@ -162,7 +158,7 @@ const collectionController: FastifyPluginAsync<Options> = async (server, opts) =
162158
}
163159
const result = await openApiService.importSpec(source, { name, linkSpec });
164160
return reply.code(201).send(result);
165-
},
161+
}
166162
);
167163

168164
server.get<{ Params: { id: string } }>('/collections/:id/export', async (request, _reply) => {
@@ -178,7 +174,7 @@ const collectionController: FastifyPluginAsync<Options> = async (server, opts) =
178174
'/collections/:id/sync-openapi/apply',
179175
async (request, _reply) => {
180176
return openApiService.applySync(request.params.id, request.body);
181-
},
177+
}
182178
);
183179

184180
server.delete<{ Params: { id: string } }>('/collections/:id/openapi-link', async (request, _reply) => {
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import type { FastifyPluginAsync } from 'fastify';
2+
import type { CreateGraphQLSchemaProfile } from '../models/graphql-schema-profile';
3+
import { GraphQLSchemaProfileService } from '../services/graphql-schema-profile.service';
4+
import { graphqlSchemaCacheInputSchema, graphqlSchemaProfileInputSchema } from '../dtos/graphql-schema-profile.dto';
5+
6+
interface Options {
7+
graphqlSchemaProfileService: GraphQLSchemaProfileService;
8+
}
9+
10+
const graphqlSchemaProfileController: FastifyPluginAsync<Options> = async (server, options) => {
11+
const { graphqlSchemaProfileService } = options;
12+
13+
server.get('/graphql/schema-profiles', async () => graphqlSchemaProfileService.getAll());
14+
15+
server.get<{ Params: { id: string } }>('/graphql/schema-profiles/:id', async request =>
16+
graphqlSchemaProfileService.getById(request.params.id),
17+
);
18+
19+
server.post<{ Body: CreateGraphQLSchemaProfile }>('/graphql/schema-profiles', async (request, reply) => {
20+
const profile = graphqlSchemaProfileService.create(graphqlSchemaProfileInputSchema.parse(request.body));
21+
return reply.code(201).send(profile);
22+
});
23+
24+
server.put<{ Params: { id: string }; Body: CreateGraphQLSchemaProfile }>(
25+
'/graphql/schema-profiles/:id',
26+
async request => graphqlSchemaProfileService.update(
27+
request.params.id,
28+
graphqlSchemaProfileInputSchema.parse(request.body),
29+
),
30+
);
31+
32+
server.delete<{ Params: { id: string } }>('/graphql/schema-profiles/:id', async request => {
33+
graphqlSchemaProfileService.delete(request.params.id);
34+
return { success: true };
35+
});
36+
37+
server.get<{ Params: { id: string } }>('/graphql/schema-profiles/:id/cache', async request => ({
38+
cache: graphqlSchemaProfileService.getCache(request.params.id),
39+
}));
40+
41+
server.put<{
42+
Params: { id: string };
43+
Body: { sourceUrl: string; introspection: unknown };
44+
}>('/graphql/schema-profiles/:id/cache', async request => {
45+
const input = graphqlSchemaCacheInputSchema.parse(request.body);
46+
return {
47+
cache: graphqlSchemaProfileService.saveCache(
48+
request.params.id,
49+
input.sourceUrl,
50+
input.introspection,
51+
),
52+
};
53+
});
54+
};
55+
56+
export default graphqlSchemaProfileController;
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import type { FastifyPluginAsync } from 'fastify';
2+
import { buildSchema, graphql } from 'graphql';
3+
4+
const schema = buildSchema(`
5+
type User {
6+
id: ID!
7+
name: String!
8+
email: String!
9+
role: Role!
10+
}
11+
12+
enum Role {
13+
ADMIN
14+
MEMBER
15+
}
16+
17+
type Query {
18+
users: [User!]!
19+
user(id: ID!): User
20+
fieldError: String
21+
}
22+
23+
type Mutation {
24+
updateUserName(id: ID!, name: String!): User
25+
}
26+
`);
27+
28+
const users = [
29+
{ id: '1', name: 'Ada Lovelace', email: 'ada@example.com', role: 'ADMIN' },
30+
{ id: '2', name: 'Grace Hopper', email: 'grace@example.com', role: 'MEMBER' },
31+
];
32+
33+
const rootValue = {
34+
users: () => users,
35+
user: ({ id }: { id: string }) => users.find(user => user.id === id) ?? null,
36+
updateUserName: ({ id, name }: { id: string; name: string }) => {
37+
const user = users.find(item => item.id === id);
38+
return user ? { ...user, name } : null;
39+
},
40+
fieldError: () => {
41+
throw new Error('This field intentionally failed');
42+
},
43+
};
44+
45+
export const graphqlTestRoutes: FastifyPluginAsync = async server => {
46+
server.post<{
47+
Body: { query?: string; variables?: Record<string, unknown>; operationName?: string };
48+
}>('/test/graphql', async (request, reply) => {
49+
if (!request.body.query) return reply.code(400).send({ error: 'GraphQL query is required' });
50+
return graphql({
51+
schema,
52+
source: request.body.query,
53+
rootValue,
54+
variableValues: request.body.variables,
55+
operationName: request.body.operationName,
56+
});
57+
});
58+
59+
server.get<{
60+
Querystring: { query?: string; variables?: string; operationName?: string };
61+
}>('/test/graphql', async (request, reply) => {
62+
if (!request.query.query) return reply.code(400).send({ error: 'GraphQL query is required' });
63+
let variables: Record<string, unknown> | undefined;
64+
if (request.query.variables) {
65+
try {
66+
variables = JSON.parse(request.query.variables) as Record<string, unknown>;
67+
} catch {
68+
return reply.code(400).send({ error: 'GraphQL variables must be valid JSON' });
69+
}
70+
}
71+
return graphql({
72+
schema,
73+
source: request.query.query,
74+
rootValue,
75+
variableValues: variables,
76+
operationName: request.query.operationName,
77+
});
78+
});
79+
};

apps/backend/src/dtos/collection.dto.ts

Lines changed: 46 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,10 @@ export const updateCollectionSchema = z.object({
1919
.optional(),
2020
});
2121

22-
export const addRequestSchema = z.object({
22+
const requestFieldsSchema = z.object({
2323
id: z.string(),
2424
name: z.string().min(1, 'Request name is required').trim(),
25+
requestType: z.enum(['http', 'graphql']).optional(),
2526
method: z.string().min(1, 'HTTP method is required'),
2627
url: z.string(),
2728
headers: z.record(z.string(), z.string()).optional(),
@@ -33,11 +34,53 @@ export const addRequestSchema = z.object({
3334
folderId: z.string().optional(),
3435
order: z.number().optional(),
3536
operationId: z.string().optional(),
37+
preRequestScript: z.string().optional(),
38+
testScript: z.string().optional(),
39+
graphql: z
40+
.object({
41+
document: z.string(),
42+
variables: z.string(),
43+
operationName: z.string().optional(),
44+
transport: z.enum(['post', 'get']),
45+
schemaProfileId: z.string().optional(),
46+
})
47+
.optional(),
3648
});
3749

38-
export const updateRequestSchema = addRequestSchema
50+
function validateRequestShape(
51+
value: Pick<
52+
Partial<z.infer<typeof requestFieldsSchema>>,
53+
'requestType' | 'graphql' | 'body' | 'formDataEntries'
54+
>,
55+
context: z.RefinementCtx,
56+
): void {
57+
if (value.requestType === 'graphql') {
58+
if (!value.graphql) {
59+
context.addIssue({ code: 'custom', path: ['graphql'], message: 'GraphQL request configuration is required' });
60+
}
61+
if (value.body !== undefined || value.formDataEntries !== undefined) {
62+
context.addIssue({ code: 'custom', path: ['body'], message: 'HTTP body fields are not valid for GraphQL requests' });
63+
}
64+
} else if (value.requestType === 'http' && value.graphql !== undefined) {
65+
context.addIssue({ code: 'custom', path: ['graphql'], message: 'GraphQL configuration is not valid for HTTP requests' });
66+
}
67+
}
68+
69+
export const addRequestSchema = requestFieldsSchema.superRefine(validateRequestShape);
70+
71+
export const createRequestPayloadSchema = requestFieldsSchema
72+
.omit({ id: true, collectionId: true })
73+
.superRefine(validateRequestShape);
74+
75+
export const updateRequestSchema = requestFieldsSchema
3976
.omit({ id: true })
40-
.partial();
77+
.partial()
78+
.superRefine(validateRequestShape);
79+
80+
export const updateRequestPayloadSchema = requestFieldsSchema
81+
.omit({ id: true, collectionId: true })
82+
.partial()
83+
.superRefine(validateRequestShape);
4184

4285
export const moveRequestSchema = z.object({
4386
targetCollectionId: z.string(),
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { z } from 'zod';
2+
3+
export const graphqlSchemaProfileInputSchema = z.object({
4+
name: z.string().trim().min(1, 'Schema profile name is required'),
5+
sourceType: z.enum(['endpoint', 'sdl', 'introspection-json']),
6+
sourceUrl: z.string().optional(),
7+
content: z.string().optional(),
8+
});
9+
10+
export const graphqlSchemaCacheInputSchema = z.object({
11+
sourceUrl: z.string().min(1, 'Schema source URL is required'),
12+
introspection: z.unknown(),
13+
});

apps/backend/src/models/collection.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,20 @@ export interface Collection {
2626
/** Stable id of the system-managed catch-all collection. */
2727
export const UNCATEGORIZED_COLLECTION_ID = 'uncategorized';
2828

29+
export type RequestType = 'http' | 'graphql';
30+
31+
export interface GraphQLRequestConfig {
32+
document: string;
33+
variables: string;
34+
operationName?: string;
35+
transport: 'post' | 'get';
36+
schemaProfileId?: string;
37+
}
38+
2939
export interface SavedRequest {
3040
id: string;
3141
name: string;
42+
requestType?: RequestType;
3243
method: string;
3344
url: string;
3445
headers?: Record<string, string>;
@@ -42,6 +53,7 @@ export interface SavedRequest {
4253
operationId?: string;
4354
preRequestScript?: string;
4455
testScript?: string;
56+
graphql?: GraphQLRequestConfig;
4557
}
4658

4759
export interface OpenApiEnvironmentVariable {

0 commit comments

Comments
 (0)