@buenojs/bueno 0.8.5 → 0.8.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +128 -1
- package/dist/cli/bin.js +1 -1
- package/dist/container/index.js +26 -3
- package/dist/graphql/index.js +2156 -0
- package/dist/index.js +520 -434
- package/dist/modules/index.js +514 -449
- package/dist/openapi/index.js +59 -40
- package/llms.txt +231 -0
- package/package.json +6 -2
- package/src/cli/ARCHITECTURE.md +3 -3
- package/src/cli/templates/project/website.ts +1 -1
- package/src/config/types.ts +21 -0
- package/src/graphql/built-in-engine.ts +598 -0
- package/src/graphql/context-builder.ts +110 -0
- package/src/graphql/decorators.ts +358 -0
- package/src/graphql/execution-pipeline.ts +227 -0
- package/src/graphql/graphql-module.ts +563 -0
- package/src/graphql/index.ts +101 -0
- package/src/graphql/metadata.ts +237 -0
- package/src/graphql/schema-builder.ts +319 -0
- package/src/graphql/subscription-handler.ts +283 -0
- package/src/graphql/types.ts +324 -0
- package/src/index.ts +3 -0
- package/src/modules/index.ts +48 -1
- package/tests/integration/cli.test.ts +19 -19
- package/tests/unit/cli.test.ts +1 -1
- package/tests/unit/graphql.test.ts +991 -0
|
@@ -0,0 +1,598 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Built-in GraphQL Engine
|
|
3
|
+
*
|
|
4
|
+
* A lightweight, zero-dependency GraphQL query/mutation executor.
|
|
5
|
+
* Suitable for development and simple use cases.
|
|
6
|
+
*
|
|
7
|
+
* ## Supported
|
|
8
|
+
* - Queries and mutations
|
|
9
|
+
* - Nested field selection
|
|
10
|
+
* - Arguments (string, number, boolean, null literals)
|
|
11
|
+
* - Variable substitution (bare $varName in query string)
|
|
12
|
+
*
|
|
13
|
+
* ## NOT Supported (use GraphQLJsAdapter for these)
|
|
14
|
+
* - Named operations: query MyQuery { ... }
|
|
15
|
+
* - Fragments and fragment spreads
|
|
16
|
+
* - Directives (@skip, @include, @deprecated)
|
|
17
|
+
* - Introspection (__schema, __type)
|
|
18
|
+
* - Union and interface types
|
|
19
|
+
* - Subscriptions
|
|
20
|
+
*
|
|
21
|
+
* The playground (GraphiQL) requires introspection and is auto-disabled
|
|
22
|
+
* when using this engine. The SDL is still served at GET <path>/schema.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import type {
|
|
26
|
+
GraphQLEngine,
|
|
27
|
+
GraphQLContext,
|
|
28
|
+
GraphQLResult,
|
|
29
|
+
GraphQLError,
|
|
30
|
+
ResolvedField,
|
|
31
|
+
ResolverFieldsByType,
|
|
32
|
+
FieldMetadata,
|
|
33
|
+
} from "./types";
|
|
34
|
+
|
|
35
|
+
// ============= AST Types =============
|
|
36
|
+
|
|
37
|
+
interface GQLArgument {
|
|
38
|
+
name: string;
|
|
39
|
+
value: unknown;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface GQLSelection {
|
|
43
|
+
name: string;
|
|
44
|
+
alias?: string;
|
|
45
|
+
arguments: GQLArgument[];
|
|
46
|
+
selections: GQLSelection[];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
interface GQLDocument {
|
|
50
|
+
operation: "query" | "mutation";
|
|
51
|
+
selections: GQLSelection[];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ============= Parser =============
|
|
55
|
+
|
|
56
|
+
class GraphQLParser {
|
|
57
|
+
private pos = 0;
|
|
58
|
+
|
|
59
|
+
constructor(private input: string) {}
|
|
60
|
+
|
|
61
|
+
parse(): GQLDocument {
|
|
62
|
+
this.skipWhitespaceAndComments();
|
|
63
|
+
|
|
64
|
+
// Detect operation type
|
|
65
|
+
let operation: "query" | "mutation" = "query";
|
|
66
|
+
|
|
67
|
+
if (this.peek("mutation")) {
|
|
68
|
+
this.pos += 8;
|
|
69
|
+
operation = "mutation";
|
|
70
|
+
this.skipWhitespaceAndComments();
|
|
71
|
+
// Skip optional operation name
|
|
72
|
+
if (this.input[this.pos] !== "{") {
|
|
73
|
+
this.skipName();
|
|
74
|
+
this.skipWhitespaceAndComments();
|
|
75
|
+
}
|
|
76
|
+
} else if (this.peek("query")) {
|
|
77
|
+
this.pos += 5;
|
|
78
|
+
operation = "query";
|
|
79
|
+
this.skipWhitespaceAndComments();
|
|
80
|
+
// Skip optional operation name
|
|
81
|
+
if (this.input[this.pos] !== "{") {
|
|
82
|
+
this.skipName();
|
|
83
|
+
this.skipWhitespaceAndComments();
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Skip variable definitions if present: (...)
|
|
88
|
+
if (this.input[this.pos] === "(") {
|
|
89
|
+
this.skipBalanced("(", ")");
|
|
90
|
+
this.skipWhitespaceAndComments();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const selections = this.parseSelectionSet();
|
|
94
|
+
|
|
95
|
+
return { operation, selections };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
private parseSelectionSet(): GQLSelection[] {
|
|
99
|
+
this.expect("{");
|
|
100
|
+
this.skipWhitespaceAndComments();
|
|
101
|
+
const selections: GQLSelection[] = [];
|
|
102
|
+
|
|
103
|
+
while (this.pos < this.input.length && this.input[this.pos] !== "}") {
|
|
104
|
+
// Check for fragment spread (not supported)
|
|
105
|
+
if (this.input[this.pos] === "." && this.input[this.pos + 1] === "." && this.input[this.pos + 2] === ".") {
|
|
106
|
+
throw new Error(
|
|
107
|
+
"Fragment spreads are not supported by the built-in GraphQL engine. " +
|
|
108
|
+
"Install the 'graphql' package and use GraphQLJsAdapter for full spec support.",
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Check for inline fragment (not supported)
|
|
113
|
+
if (this.peek("on ") || (this.peek("on\t")) || (this.peek("on{"))) {
|
|
114
|
+
throw new Error(
|
|
115
|
+
"Inline fragments are not supported by the built-in GraphQL engine. " +
|
|
116
|
+
"Install the 'graphql' package and use GraphQLJsAdapter for full spec support.",
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const sel = this.parseSelection();
|
|
121
|
+
selections.push(sel);
|
|
122
|
+
this.skipWhitespaceAndComments();
|
|
123
|
+
// Optional comma
|
|
124
|
+
if (this.input[this.pos] === ",") {
|
|
125
|
+
this.pos++;
|
|
126
|
+
this.skipWhitespaceAndComments();
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
this.expect("}");
|
|
131
|
+
return selections;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
private parseSelection(): GQLSelection {
|
|
135
|
+
// May have alias: field: realField
|
|
136
|
+
const firstName = this.parseName();
|
|
137
|
+
this.skipWhitespaceAndComments();
|
|
138
|
+
|
|
139
|
+
let name = firstName;
|
|
140
|
+
let alias: string | undefined;
|
|
141
|
+
|
|
142
|
+
if (this.input[this.pos] === ":") {
|
|
143
|
+
// firstName is alias
|
|
144
|
+
alias = firstName;
|
|
145
|
+
this.pos++;
|
|
146
|
+
this.skipWhitespaceAndComments();
|
|
147
|
+
name = this.parseName();
|
|
148
|
+
this.skipWhitespaceAndComments();
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Check for introspection fields
|
|
152
|
+
if (name.startsWith("__")) {
|
|
153
|
+
throw new Error(
|
|
154
|
+
`Introspection field '${name}' is not supported by the built-in GraphQL engine. ` +
|
|
155
|
+
"Install the 'graphql' package and use GraphQLJsAdapter for introspection support.",
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Arguments
|
|
160
|
+
const args: GQLArgument[] = [];
|
|
161
|
+
if (this.input[this.pos] === "(") {
|
|
162
|
+
args.push(...this.parseArguments());
|
|
163
|
+
this.skipWhitespaceAndComments();
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Sub-selections
|
|
167
|
+
let selections: GQLSelection[] = [];
|
|
168
|
+
if (this.input[this.pos] === "{") {
|
|
169
|
+
selections = this.parseSelectionSet();
|
|
170
|
+
this.skipWhitespaceAndComments();
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return { name, alias, arguments: args, selections };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
private parseArguments(): GQLArgument[] {
|
|
177
|
+
this.expect("(");
|
|
178
|
+
this.skipWhitespaceAndComments();
|
|
179
|
+
const args: GQLArgument[] = [];
|
|
180
|
+
|
|
181
|
+
while (this.pos < this.input.length && this.input[this.pos] !== ")") {
|
|
182
|
+
const argName = this.parseName();
|
|
183
|
+
this.skipWhitespaceAndComments();
|
|
184
|
+
this.expect(":");
|
|
185
|
+
this.skipWhitespaceAndComments();
|
|
186
|
+
const value = this.parseValue();
|
|
187
|
+
args.push({ name: argName, value });
|
|
188
|
+
this.skipWhitespaceAndComments();
|
|
189
|
+
if (this.input[this.pos] === ",") {
|
|
190
|
+
this.pos++;
|
|
191
|
+
this.skipWhitespaceAndComments();
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
this.expect(")");
|
|
196
|
+
return args;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
private parseValue(): unknown {
|
|
200
|
+
const ch = this.input[this.pos];
|
|
201
|
+
|
|
202
|
+
// String
|
|
203
|
+
if (ch === '"') {
|
|
204
|
+
return this.parseString();
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Number
|
|
208
|
+
if (ch === "-" || (ch >= "0" && ch <= "9")) {
|
|
209
|
+
return this.parseNumber();
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Boolean / null
|
|
213
|
+
if (this.peek("true")) {
|
|
214
|
+
this.pos += 4;
|
|
215
|
+
return true;
|
|
216
|
+
}
|
|
217
|
+
if (this.peek("false")) {
|
|
218
|
+
this.pos += 5;
|
|
219
|
+
return false;
|
|
220
|
+
}
|
|
221
|
+
if (this.peek("null")) {
|
|
222
|
+
this.pos += 4;
|
|
223
|
+
return null;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Variable reference
|
|
227
|
+
if (ch === "$") {
|
|
228
|
+
this.pos++;
|
|
229
|
+
const varName = this.parseName();
|
|
230
|
+
// Variable value must be resolved from the variables map at execution time
|
|
231
|
+
// Return as a special marker
|
|
232
|
+
return { __variable: varName };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Object literal (for inline input objects)
|
|
236
|
+
if (ch === "{") {
|
|
237
|
+
return this.parseObjectLiteral();
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// List literal
|
|
241
|
+
if (ch === "[") {
|
|
242
|
+
return this.parseListLiteral();
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Enum value (treat as string)
|
|
246
|
+
if (ch && /[A-Z_a-z]/.test(ch)) {
|
|
247
|
+
return this.parseName();
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
throw new Error(`Unexpected character '${ch}' at position ${this.pos}`);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
private parseString(): string {
|
|
254
|
+
this.expect('"');
|
|
255
|
+
let result = "";
|
|
256
|
+
while (this.pos < this.input.length && this.input[this.pos] !== '"') {
|
|
257
|
+
if (this.input[this.pos] === "\\") {
|
|
258
|
+
this.pos++;
|
|
259
|
+
const esc = this.input[this.pos];
|
|
260
|
+
const escapes: Record<string, string> = {
|
|
261
|
+
'"': '"', "\\": "\\", "/": "/",
|
|
262
|
+
n: "\n", r: "\r", t: "\t", b: "\b", f: "\f",
|
|
263
|
+
};
|
|
264
|
+
result += escapes[esc] ?? esc;
|
|
265
|
+
} else {
|
|
266
|
+
result += this.input[this.pos];
|
|
267
|
+
}
|
|
268
|
+
this.pos++;
|
|
269
|
+
}
|
|
270
|
+
this.expect('"');
|
|
271
|
+
return result;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
private parseNumber(): number {
|
|
275
|
+
let numStr = "";
|
|
276
|
+
if (this.input[this.pos] === "-") {
|
|
277
|
+
numStr += "-";
|
|
278
|
+
this.pos++;
|
|
279
|
+
}
|
|
280
|
+
while (this.pos < this.input.length && /[\d.]/.test(this.input[this.pos])) {
|
|
281
|
+
numStr += this.input[this.pos++];
|
|
282
|
+
}
|
|
283
|
+
return Number(numStr);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
private parseObjectLiteral(): Record<string, unknown> {
|
|
287
|
+
this.expect("{");
|
|
288
|
+
this.skipWhitespaceAndComments();
|
|
289
|
+
const obj: Record<string, unknown> = {};
|
|
290
|
+
while (this.pos < this.input.length && this.input[this.pos] !== "}") {
|
|
291
|
+
const key = this.parseName();
|
|
292
|
+
this.skipWhitespaceAndComments();
|
|
293
|
+
this.expect(":");
|
|
294
|
+
this.skipWhitespaceAndComments();
|
|
295
|
+
obj[key] = this.parseValue();
|
|
296
|
+
this.skipWhitespaceAndComments();
|
|
297
|
+
if (this.input[this.pos] === ",") {
|
|
298
|
+
this.pos++;
|
|
299
|
+
this.skipWhitespaceAndComments();
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
this.expect("}");
|
|
303
|
+
return obj;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
private parseListLiteral(): unknown[] {
|
|
307
|
+
this.expect("[");
|
|
308
|
+
this.skipWhitespaceAndComments();
|
|
309
|
+
const items: unknown[] = [];
|
|
310
|
+
while (this.pos < this.input.length && this.input[this.pos] !== "]") {
|
|
311
|
+
items.push(this.parseValue());
|
|
312
|
+
this.skipWhitespaceAndComments();
|
|
313
|
+
if (this.input[this.pos] === ",") {
|
|
314
|
+
this.pos++;
|
|
315
|
+
this.skipWhitespaceAndComments();
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
this.expect("]");
|
|
319
|
+
return items;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
private parseName(): string {
|
|
323
|
+
const start = this.pos;
|
|
324
|
+
while (
|
|
325
|
+
this.pos < this.input.length &&
|
|
326
|
+
/[\w]/.test(this.input[this.pos])
|
|
327
|
+
) {
|
|
328
|
+
this.pos++;
|
|
329
|
+
}
|
|
330
|
+
if (this.pos === start) {
|
|
331
|
+
throw new Error(
|
|
332
|
+
`Expected name at position ${this.pos}, got '${this.input[this.pos]}'`,
|
|
333
|
+
);
|
|
334
|
+
}
|
|
335
|
+
return this.input.slice(start, this.pos);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
private skipName(): void {
|
|
339
|
+
while (
|
|
340
|
+
this.pos < this.input.length &&
|
|
341
|
+
/[\w]/.test(this.input[this.pos])
|
|
342
|
+
) {
|
|
343
|
+
this.pos++;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
private skipBalanced(open: string, close: string): void {
|
|
348
|
+
this.expect(open);
|
|
349
|
+
let depth = 1;
|
|
350
|
+
while (this.pos < this.input.length && depth > 0) {
|
|
351
|
+
if (this.input[this.pos] === open) depth++;
|
|
352
|
+
else if (this.input[this.pos] === close) depth--;
|
|
353
|
+
this.pos++;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
private skipWhitespaceAndComments(): void {
|
|
358
|
+
while (this.pos < this.input.length) {
|
|
359
|
+
// Whitespace
|
|
360
|
+
if (/\s/.test(this.input[this.pos])) {
|
|
361
|
+
this.pos++;
|
|
362
|
+
continue;
|
|
363
|
+
}
|
|
364
|
+
// Line comments
|
|
365
|
+
if (this.input[this.pos] === "#") {
|
|
366
|
+
while (this.pos < this.input.length && this.input[this.pos] !== "\n") {
|
|
367
|
+
this.pos++;
|
|
368
|
+
}
|
|
369
|
+
continue;
|
|
370
|
+
}
|
|
371
|
+
break;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
private expect(char: string): void {
|
|
376
|
+
if (this.input[this.pos] !== char) {
|
|
377
|
+
throw new Error(
|
|
378
|
+
`Expected '${char}' at position ${this.pos}, got '${this.input[this.pos] ?? "EOF"}'`,
|
|
379
|
+
);
|
|
380
|
+
}
|
|
381
|
+
this.pos++;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
private peek(str: string): boolean {
|
|
385
|
+
return this.input.startsWith(str, this.pos);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// ============= Built-in Engine Schema =============
|
|
390
|
+
|
|
391
|
+
interface BuiltinSchema {
|
|
392
|
+
queries: Map<string, ResolvedField>;
|
|
393
|
+
mutations: Map<string, ResolvedField>;
|
|
394
|
+
sdl: string;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// ============= Built-in Engine =============
|
|
398
|
+
|
|
399
|
+
export class BuiltinGraphQLEngine implements GraphQLEngine {
|
|
400
|
+
readonly supportsIntrospection = false;
|
|
401
|
+
readonly supportsSubscriptions = false;
|
|
402
|
+
|
|
403
|
+
buildSchema(
|
|
404
|
+
resolvers: ResolverFieldsByType,
|
|
405
|
+
_types: Map<string, FieldMetadata[]>,
|
|
406
|
+
sdl: string,
|
|
407
|
+
): unknown {
|
|
408
|
+
const schema: BuiltinSchema = {
|
|
409
|
+
queries: resolvers.queries,
|
|
410
|
+
mutations: resolvers.mutations,
|
|
411
|
+
sdl,
|
|
412
|
+
};
|
|
413
|
+
return schema;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
async execute(
|
|
417
|
+
schema: unknown,
|
|
418
|
+
query: string,
|
|
419
|
+
variables: Record<string, unknown>,
|
|
420
|
+
context: GraphQLContext,
|
|
421
|
+
_operationName?: string,
|
|
422
|
+
): Promise<GraphQLResult> {
|
|
423
|
+
const s = schema as BuiltinSchema;
|
|
424
|
+
|
|
425
|
+
// Parse the query
|
|
426
|
+
let doc: GQLDocument;
|
|
427
|
+
try {
|
|
428
|
+
doc = new GraphQLParser(query).parse();
|
|
429
|
+
} catch (err) {
|
|
430
|
+
return {
|
|
431
|
+
data: null,
|
|
432
|
+
errors: [{ message: String((err as Error).message) }],
|
|
433
|
+
};
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
const fields = doc.operation === "mutation" ? s.mutations : s.queries;
|
|
437
|
+
const data: Record<string, unknown> = {};
|
|
438
|
+
const errors: GraphQLError[] = [];
|
|
439
|
+
|
|
440
|
+
for (const sel of doc.selections) {
|
|
441
|
+
try {
|
|
442
|
+
const resolvedValue = await this.resolveSelection(
|
|
443
|
+
sel,
|
|
444
|
+
fields,
|
|
445
|
+
variables,
|
|
446
|
+
context,
|
|
447
|
+
);
|
|
448
|
+
const resultKey = sel.alias ?? sel.name;
|
|
449
|
+
data[resultKey] = resolvedValue;
|
|
450
|
+
} catch (err) {
|
|
451
|
+
const resultKey = sel.alias ?? sel.name;
|
|
452
|
+
data[resultKey] = null;
|
|
453
|
+
errors.push({
|
|
454
|
+
message: String((err as Error).message),
|
|
455
|
+
path: [resultKey],
|
|
456
|
+
});
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
const result: GraphQLResult = { data };
|
|
461
|
+
if (errors.length > 0) {
|
|
462
|
+
result.errors = errors;
|
|
463
|
+
}
|
|
464
|
+
return result;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
private async resolveSelection(
|
|
468
|
+
sel: GQLSelection,
|
|
469
|
+
fields: Map<string, ResolvedField>,
|
|
470
|
+
variables: Record<string, unknown>,
|
|
471
|
+
context: GraphQLContext,
|
|
472
|
+
): Promise<unknown> {
|
|
473
|
+
const field = fields.get(sel.name);
|
|
474
|
+
if (!field) {
|
|
475
|
+
throw new Error(`Field '${sel.name}' does not exist on type`);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// Build args from selection
|
|
479
|
+
const args = this.buildArgsFromSelection(sel, variables);
|
|
480
|
+
|
|
481
|
+
// Call resolver method
|
|
482
|
+
const instance = field.resolverInstance as Record<string, (...a: unknown[]) => unknown>;
|
|
483
|
+
const methodArgs = this.buildMethodArgs(field, args, context);
|
|
484
|
+
const rawResult = await instance[field.methodName](...methodArgs);
|
|
485
|
+
|
|
486
|
+
// If sub-selections, recursively resolve object fields
|
|
487
|
+
if (sel.selections.length > 0 && rawResult !== null && rawResult !== undefined) {
|
|
488
|
+
if (Array.isArray(rawResult)) {
|
|
489
|
+
return Promise.all(
|
|
490
|
+
rawResult.map((item) =>
|
|
491
|
+
this.resolveObject(item, sel.selections, variables, context),
|
|
492
|
+
),
|
|
493
|
+
);
|
|
494
|
+
}
|
|
495
|
+
return this.resolveObject(rawResult, sel.selections, variables, context);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
return rawResult;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
private async resolveObject(
|
|
502
|
+
obj: unknown,
|
|
503
|
+
selections: GQLSelection[],
|
|
504
|
+
variables: Record<string, unknown>,
|
|
505
|
+
context: GraphQLContext,
|
|
506
|
+
): Promise<Record<string, unknown>> {
|
|
507
|
+
const result: Record<string, unknown> = {};
|
|
508
|
+
const record = obj as Record<string, unknown>;
|
|
509
|
+
|
|
510
|
+
for (const sel of selections) {
|
|
511
|
+
if (sel.name.startsWith("__")) {
|
|
512
|
+
throw new Error(
|
|
513
|
+
`Introspection field '${sel.name}' is not supported by the built-in engine.`,
|
|
514
|
+
);
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
const resultKey = sel.alias ?? sel.name;
|
|
518
|
+
const fieldValue = record[sel.name];
|
|
519
|
+
|
|
520
|
+
if (sel.selections.length > 0 && fieldValue !== null && fieldValue !== undefined) {
|
|
521
|
+
if (Array.isArray(fieldValue)) {
|
|
522
|
+
result[resultKey] = await Promise.all(
|
|
523
|
+
fieldValue.map((item) =>
|
|
524
|
+
this.resolveObject(item, sel.selections, variables, context),
|
|
525
|
+
),
|
|
526
|
+
);
|
|
527
|
+
} else {
|
|
528
|
+
result[resultKey] = await this.resolveObject(
|
|
529
|
+
fieldValue,
|
|
530
|
+
sel.selections,
|
|
531
|
+
variables,
|
|
532
|
+
context,
|
|
533
|
+
);
|
|
534
|
+
}
|
|
535
|
+
} else {
|
|
536
|
+
result[resultKey] = fieldValue ?? null;
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
return result;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
private buildArgsFromSelection(
|
|
544
|
+
sel: GQLSelection,
|
|
545
|
+
variables: Record<string, unknown>,
|
|
546
|
+
): Record<string, unknown> {
|
|
547
|
+
const args: Record<string, unknown> = {};
|
|
548
|
+
for (const arg of sel.arguments) {
|
|
549
|
+
args[arg.name] = this.resolveValue(arg.value, variables);
|
|
550
|
+
}
|
|
551
|
+
return args;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
private resolveValue(
|
|
555
|
+
value: unknown,
|
|
556
|
+
variables: Record<string, unknown>,
|
|
557
|
+
): unknown {
|
|
558
|
+
if (
|
|
559
|
+
value !== null &&
|
|
560
|
+
typeof value === "object" &&
|
|
561
|
+
"__variable" in (value as object)
|
|
562
|
+
) {
|
|
563
|
+
const varName = (value as { __variable: string }).__variable;
|
|
564
|
+
return variables[varName];
|
|
565
|
+
}
|
|
566
|
+
if (Array.isArray(value)) {
|
|
567
|
+
return value.map((v) => this.resolveValue(v, variables));
|
|
568
|
+
}
|
|
569
|
+
if (value !== null && typeof value === "object") {
|
|
570
|
+
const result: Record<string, unknown> = {};
|
|
571
|
+
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
|
|
572
|
+
result[k] = this.resolveValue(v, variables);
|
|
573
|
+
}
|
|
574
|
+
return result;
|
|
575
|
+
}
|
|
576
|
+
return value;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
private buildMethodArgs(
|
|
580
|
+
field: ResolvedField,
|
|
581
|
+
args: Record<string, unknown>,
|
|
582
|
+
context: GraphQLContext,
|
|
583
|
+
): unknown[] {
|
|
584
|
+
const methodArgs: unknown[] = [];
|
|
585
|
+
|
|
586
|
+
for (const param of field.paramMetadata) {
|
|
587
|
+
if (param.kind === "context") {
|
|
588
|
+
methodArgs[param.index] = context;
|
|
589
|
+
} else if (param.kind === "args" && param.argName) {
|
|
590
|
+
methodArgs[param.index] = args[param.argName];
|
|
591
|
+
} else if (param.kind === "argsObject" && param.argName) {
|
|
592
|
+
methodArgs[param.index] = args[param.argName];
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
return methodArgs;
|
|
597
|
+
}
|
|
598
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GraphQL Context Builder
|
|
3
|
+
*
|
|
4
|
+
* Builds the GraphQL context from an HTTP Context and enriches
|
|
5
|
+
* the HTTP context with GraphQL-specific metadata for guards/interceptors.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { Context } from "../context";
|
|
9
|
+
import type { GraphQLContext } from "./types";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Builds a GraphQL context from an HTTP Context.
|
|
13
|
+
* Extracts user, request, and exposes the raw httpContext.
|
|
14
|
+
*/
|
|
15
|
+
export function buildGraphQLContext(httpContext: Context): GraphQLContext {
|
|
16
|
+
return {
|
|
17
|
+
request: httpContext.req,
|
|
18
|
+
user: httpContext.get("user"),
|
|
19
|
+
httpContext,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Enriches the HTTP context with GraphQL-specific metadata.
|
|
25
|
+
* This allows guards and interceptors to inspect GraphQL operation details.
|
|
26
|
+
*
|
|
27
|
+
* Guards that read context.get('graphql:operation') can make operation-specific decisions.
|
|
28
|
+
* Guards that read context.get('graphql:type') can differentiate queries vs mutations.
|
|
29
|
+
*
|
|
30
|
+
* @example Guard usage:
|
|
31
|
+
* ```typescript
|
|
32
|
+
* class MyGuard implements CanActivate {
|
|
33
|
+
* canActivate(context: Context): boolean {
|
|
34
|
+
* const op = context.get('graphql:type');
|
|
35
|
+
* if (op === 'mutation') {
|
|
36
|
+
* return !!context.req.headers.get('Authorization');
|
|
37
|
+
* }
|
|
38
|
+
* return true; // Queries are public
|
|
39
|
+
* }
|
|
40
|
+
* }
|
|
41
|
+
* ```
|
|
42
|
+
*/
|
|
43
|
+
export function enrichContextForGraphQL(
|
|
44
|
+
httpContext: Context,
|
|
45
|
+
operationName: string,
|
|
46
|
+
operationType: "query" | "mutation" | "subscription",
|
|
47
|
+
resolverClass: new (...args: unknown[]) => unknown,
|
|
48
|
+
): void {
|
|
49
|
+
httpContext.set("graphql:operation", operationName);
|
|
50
|
+
httpContext.set("graphql:type", operationType);
|
|
51
|
+
httpContext.set("graphql:resolverClass", resolverClass);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Extracts the parsed request body for GraphQL.
|
|
56
|
+
* Handles both JSON body and GET query string (for playground requests).
|
|
57
|
+
*/
|
|
58
|
+
export async function parseGraphQLRequest(request: Request): Promise<{
|
|
59
|
+
query: string;
|
|
60
|
+
variables: Record<string, unknown>;
|
|
61
|
+
operationName?: string;
|
|
62
|
+
} | null> {
|
|
63
|
+
if (request.method === "POST") {
|
|
64
|
+
try {
|
|
65
|
+
const body = await request.clone().json() as {
|
|
66
|
+
query?: unknown;
|
|
67
|
+
variables?: unknown;
|
|
68
|
+
operationName?: unknown;
|
|
69
|
+
};
|
|
70
|
+
if (typeof body.query !== "string") {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
return {
|
|
74
|
+
query: body.query,
|
|
75
|
+
variables:
|
|
76
|
+
typeof body.variables === "object" && body.variables !== null
|
|
77
|
+
? (body.variables as Record<string, unknown>)
|
|
78
|
+
: {},
|
|
79
|
+
operationName:
|
|
80
|
+
typeof body.operationName === "string"
|
|
81
|
+
? body.operationName
|
|
82
|
+
: undefined,
|
|
83
|
+
};
|
|
84
|
+
} catch {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (request.method === "GET") {
|
|
90
|
+
const url = new URL(request.url);
|
|
91
|
+
const query = url.searchParams.get("query");
|
|
92
|
+
if (!query) return null;
|
|
93
|
+
const varsStr = url.searchParams.get("variables");
|
|
94
|
+
let variables: Record<string, unknown> = {};
|
|
95
|
+
if (varsStr) {
|
|
96
|
+
try {
|
|
97
|
+
variables = JSON.parse(varsStr) as Record<string, unknown>;
|
|
98
|
+
} catch {
|
|
99
|
+
// ignore parse error
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return {
|
|
103
|
+
query,
|
|
104
|
+
variables,
|
|
105
|
+
operationName: url.searchParams.get("operationName") ?? undefined,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return null;
|
|
110
|
+
}
|