@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.
@@ -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
+ }