@tinkerstack/graphql-annotation 0.0.4 → 0.0.7

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.
@@ -1,315 +1,318 @@
1
- import { stitchSchemas } from "@graphql-tools/stitch";
2
- import { MapperKind, mapSchema } from "@graphql-tools/utils";
3
- import { FilterRootFields, wrapSchema } from "@graphql-tools/wrap";
4
- import { Injectable, Logger, ParamData } from "@nestjs/common";
5
- import {
6
- ExternalContextCreator,
7
- MetadataScanner,
8
- ParamsFactory,
9
- } from "@nestjs/core";
10
- import { ParamMetadata } from "@nestjs/core/helpers/interfaces/params-metadata.interface";
11
- import {
12
- GqlContextType,
13
- GraphQLExecutionContext,
14
- PARAM_ARGS_METADATA,
15
- } from "@nestjs/graphql";
16
- import { GqlParamtype } from "@nestjs/graphql/dist/enums/gql-paramtype.enum";
17
- import { GraphQLResolveInfo, GraphQLSchema } from "graphql";
18
- import { GraphQLClient } from "graphql-request";
19
- import {
20
- ANNOTATION_RESOLVER_METADATA,
21
- AnnotationResolverMetadata,
22
- AnnotationSchemaSources,
23
- ResolverName,
24
- } from "./annotation.constants";
25
- import {
26
- ANNOTATION_QUERY_NAME,
27
- GraphQLAnnotationMeta,
28
- GraphQLAnnotationResolver,
29
- } from "./annotation.resolver";
30
- import {
31
- isPlainObject,
32
- loadGraphQLSchema,
33
- shallowMerge,
34
- split,
35
- } from "./annotation.utils";
36
- import { ResolverDiscoveryService } from "./resolver.explorer";
37
- import { instanceToPlain } from "class-transformer";
38
-
39
- @Injectable()
40
- export class GraphQLAnnotatedSchemaLoader {
41
- private _logger = new Logger("GraphQLAnnotationModule");
42
-
43
- constructor(
44
- private readonly resolverDiscoveryService: ResolverDiscoveryService,
45
- private readonly metadataScanner: MetadataScanner,
46
- private readonly externalContextCreator: ExternalContextCreator,
47
- ) {}
48
-
49
- public async load(
50
- sources: AnnotationSchemaSources,
51
- opts?: { ignoreMissingSources?: boolean },
52
- ) {
53
- const loadedSources = await this.batchLoadSources(sources, {
54
- ignoreMissingSources: opts?.ignoreMissingSources ?? false,
55
- });
56
-
57
- const schema = stitchSchemas({
58
- subschemas: loadedSources.map((s) => s.subschema),
59
- });
60
- const annotations = shallowMerge(
61
- loadedSources.map((s) => s.annotation),
62
- ) as GraphQLAnnotationMeta;
63
- const annotationResolvers = await this.discoverAnnotationResolvers();
64
-
65
- return this.embedAnnotationResolversToSchema(
66
- schema,
67
- annotations,
68
- annotationResolvers,
69
- );
70
- }
71
-
72
- private async discoverAnnotationResolvers() {
73
- type AnnotationName = string;
74
- type AnnotationCallback = (...args: any[]) => any;
75
- const annotationResolvers = new Map<AnnotationName, AnnotationCallback>();
76
-
77
- const paramFactory = new GraphQLAnnotationResolverParamsFactory();
78
- const instances = this.resolverDiscoveryService.explore();
79
-
80
- for (const { instance } of instances)
81
- for (const methodName of this.metadataScanner.getAllMethodNames(
82
- Object.getPrototypeOf(instance),
83
- )) {
84
- const resolverMeta = Reflect.getMetadata(
85
- ANNOTATION_RESOLVER_METADATA,
86
- instance[methodName],
87
- ) as AnnotationResolverMetadata;
88
-
89
- if (!resolverMeta) continue; // Skip non-annotated
90
-
91
- this._logger.log(
92
- `Discovered resolver for annotation "${resolverMeta.annotation}"`,
93
- );
94
- annotationResolvers.set(
95
- resolverMeta.annotation,
96
- this.externalContextCreator.create<
97
- Record<number, ParamMetadata>,
98
- GqlContextType
99
- >(
100
- instance,
101
- instance[methodName],
102
- methodName,
103
- PARAM_ARGS_METADATA,
104
- paramFactory,
105
- undefined,
106
- undefined,
107
- undefined,
108
- "graphql",
109
- ),
110
- );
111
- }
112
-
113
- return annotationResolvers;
114
- }
115
-
116
- private async batchLoadSources(
117
- sources: AnnotationSchemaSources,
118
- opts?: { ignoreMissingSources?: boolean },
119
- ) {
120
- const pending = Object.entries(sources).map(async ([name, url]) => {
121
- const loadId = `${name}@${url}`;
122
- try {
123
- const loaded = {
124
- id: loadId,
125
- url: url,
126
- name: name,
127
- subschema: await loadGraphQLSchema(url),
128
- annotation: await this.loadAnnotations(url),
129
- };
130
-
131
- // Cleanup
132
- // Remove `_annotations` from being merged to main schema
133
- loaded.subschema.transforms = [
134
- new FilterRootFields(
135
- (op, fieldName) => !fieldName.endsWith(ANNOTATION_QUERY_NAME),
136
- ),
137
- ];
138
-
139
- this._logger.log(`Loaded schema for ${loadId}`);
140
- return loaded;
141
- } catch (err) {
142
- this._logger.error(`Unable to load schema for ${loadId}`);
143
- throw err;
144
- }
145
- });
146
-
147
- const [loaded, failed] = split(
148
- await Promise.allSettled(pending),
149
- (q) => q.status === "fulfilled",
150
- );
151
-
152
- const shouldThrow = opts?.ignoreMissingSources ?? true;
153
- if (failed.length > 0) {
154
- if (shouldThrow)
155
- throw new Error(
156
- `Failed to load ${failed.length} schema(s) from sources.`,
157
- );
158
- else this._logger.warn(`Failed to load ${failed.length}, ignoring.`);
159
- }
160
-
161
- return loaded.map((l) => l.value);
162
- }
163
-
164
- private async loadAnnotations(url: string) {
165
- const client = new GraphQLClient(url);
166
-
167
- try {
168
- type ExpectedResult = {
169
- [ANNOTATION_QUERY_NAME]: GraphQLAnnotationMeta;
170
- };
171
- const res = await client.rawRequest<ExpectedResult>(`
172
- query IntrospectAnnotations {
173
- ${ANNOTATION_QUERY_NAME} {
174
- name
175
- resolvers
176
- }
177
- }
178
- `);
179
-
180
- this._logger.log(`Loaded annotations for ${url}`);
181
- return res.data[ANNOTATION_QUERY_NAME];
182
- } catch (err) {
183
- this._logger.warn(`Failed to load annotations at ${url}, assuming empty`);
184
- return {} as GraphQLAnnotationMeta;
185
- }
186
- }
187
-
188
- private async embedAnnotationResolversToSchema(
189
- schema: GraphQLSchema,
190
- annotations: GraphQLAnnotationMeta,
191
- annotationResolvers: Map<ResolverName, (...args: any[]) => any>,
192
- ) {
193
- return mapSchema(schema, {
194
- [MapperKind.ROOT_FIELD]: (fieldSchema, name, type) => {
195
- const fieldAnnotations = (annotations.resolvers[name] ?? [])
196
- .map((info) => ({
197
- ...info,
198
- resolver: annotationResolvers.get(info.annotation)!, // NOTE: should be validated in filter
199
- }))
200
- .filter((info) => {
201
- const annotationHasResolver = info.resolver !== undefined;
202
- if (!annotationHasResolver)
203
- this._logger.warn(
204
- `Found unhandled annotation "${info.annotation}", skipping linking process`,
205
- );
206
-
207
- return annotationHasResolver;
208
- });
209
-
210
- if (fieldAnnotations.length <= 0) return fieldSchema; // No transform if there's no annotation
211
-
212
- // If there exist a resolver for an annotate parameter, remove it from schema to prevent external injection
213
- for (const annotation of fieldAnnotations)
214
- if (annotation.target.type === "parameter" && fieldSchema.args)
215
- delete fieldSchema.args[annotation.target.paramName];
216
-
217
- const defaultResolver = fieldSchema.resolve!;
218
- fieldSchema.resolve = async function (
219
- parent,
220
- args: Record<string, any>,
221
- context: GraphQLExecutionContext,
222
- info,
223
- ) {
224
- const annotationCallbacks = await Promise.all(
225
- fieldAnnotations.map(async (annotation) => ({
226
- annotation,
227
- return: await annotation.resolver(
228
- ...([
229
- info.rootValue,
230
- annotation.data as Record<string, unknown>,
231
- context,
232
- info,
233
- ] satisfies GraphQLAnnotationResolverArgs),
234
- ),
235
- })),
236
- );
237
-
238
- const resolvedParams = annotationCallbacks.reduce(
239
- (params, call) => {
240
- if (call.annotation.target.type !== "parameter") return params;
241
-
242
- const returnedClassType =
243
- typeof call.return === "object" && !isPlainObject(call.return);
244
- const returnValue = returnedClassType
245
- ? instanceToPlain(call.return)
246
- : call.return;
247
- params[call.annotation.target.paramName] = returnValue;
248
-
249
- return params;
250
- },
251
- {} as Record<string, any>,
252
- );
253
-
254
- return defaultResolver(
255
- parent,
256
- { ...args, ...resolvedParams },
257
- context,
258
- info,
259
- );
260
- };
261
-
262
- return fieldSchema;
263
- },
264
- });
265
- }
266
- }
267
-
268
- type GraphQLAnnotationResolverArgs = [
269
- // Root/parent (follows graphql context to match)
270
- any,
271
- // Data
272
- Record<string, unknown>,
273
- // GraphQL Context
274
- GraphQLExecutionContext,
275
- // GraphQL Resolver Info
276
- GraphQLResolveInfo,
277
- ];
278
- class GraphQLAnnotationResolverParamsFactory implements ParamsFactory {
279
- // This param factory extends graphql's own just with different inputs from annotations instead.
280
- // https://github.com/nestjs/graphql/blob/master/packages/graphql/lib/factories/params.factory.ts#L9
281
- exchangeKeyForValue(
282
- type: number,
283
- possibleKey: ParamData,
284
- // NOTE: nestjs passes this in array, so we have to "get the first element"
285
- argsContext: GraphQLAnnotationResolverArgs,
286
- ) {
287
- if (!argsContext) return null;
288
-
289
- const args = {
290
- parentValue: argsContext[0],
291
- data: argsContext[1],
292
- context: argsContext[2],
293
- info: argsContext[3],
294
- };
295
-
296
- switch (type as GqlParamtype) {
297
- case GqlParamtype.ROOT:
298
- return args.parentValue;
299
- case GqlParamtype.ARGS:
300
- return possibleKey && args.data
301
- ? args.data[possibleKey as string]
302
- : args.data;
303
- case GqlParamtype.CONTEXT:
304
- return possibleKey && args.context
305
- ? args.context[possibleKey as string]
306
- : args.context;
307
- case GqlParamtype.INFO:
308
- return possibleKey && args.context
309
- ? args.info[possibleKey as string]
310
- : args.info;
311
- default:
312
- return null;
313
- }
314
- }
315
- }
1
+ import { stitchSchemas } from "@graphql-tools/stitch";
2
+ import { MapperKind, mapSchema } from "@graphql-tools/utils";
3
+ import { FilterRootFields, wrapSchema } from "@graphql-tools/wrap";
4
+ import { Injectable, Logger, ParamData } from "@nestjs/common";
5
+ import {
6
+ ExternalContextCreator,
7
+ MetadataScanner,
8
+ ParamsFactory,
9
+ } from "@nestjs/core";
10
+ import { ParamMetadata } from "@nestjs/core/helpers/interfaces/params-metadata.interface";
11
+ import {
12
+ GqlContextType,
13
+ GraphQLExecutionContext,
14
+ PARAM_ARGS_METADATA,
15
+ } from "@nestjs/graphql";
16
+ import { GqlParamtype } from "@nestjs/graphql/dist/enums/gql-paramtype.enum";
17
+ import { GraphQLResolveInfo, GraphQLSchema } from "graphql";
18
+ import { GraphQLClient } from "graphql-request";
19
+ import {
20
+ ANNOTATION_RESOLVER_METADATA,
21
+ AnnotationResolverMetadata,
22
+ AnnotationSchemaSources,
23
+ ResolverName,
24
+ } from "./annotation.constants";
25
+ import {
26
+ ANNOTATION_QUERY_NAME,
27
+ GraphQLAnnotationMeta,
28
+ GraphQLAnnotationResolver,
29
+ } from "./annotation.resolver";
30
+ import {
31
+ isPlainObject,
32
+ loadGraphQLSchema,
33
+ shallowMerge,
34
+ split,
35
+ } from "./annotation.utils";
36
+ import { ResolverDiscoveryService } from "./resolver.explorer";
37
+ import { instanceToPlain } from "class-transformer";
38
+
39
+ @Injectable()
40
+ export class GraphQLAnnotatedSchemaLoader {
41
+ private _logger = new Logger("GraphQLAnnotationModule");
42
+
43
+ constructor(
44
+ private readonly resolverDiscoveryService: ResolverDiscoveryService,
45
+ private readonly metadataScanner: MetadataScanner,
46
+ private readonly externalContextCreator: ExternalContextCreator,
47
+ ) {}
48
+
49
+ public async load(
50
+ sources: AnnotationSchemaSources,
51
+ opts?: { ignoreMissingSources?: boolean },
52
+ ) {
53
+ const loadedSources = await this.batchLoadSources(sources, {
54
+ ignoreMissingSources: opts?.ignoreMissingSources ?? false,
55
+ });
56
+
57
+ const schema = stitchSchemas({
58
+ subschemas: loadedSources.map((s) => s.subschema),
59
+ });
60
+ const annotations = shallowMerge(
61
+ loadedSources.map((s) => s.annotation),
62
+ ) as GraphQLAnnotationMeta;
63
+ const annotationResolvers = await this.discoverAnnotationResolvers();
64
+
65
+ return this.embedAnnotationResolversToSchema(
66
+ schema,
67
+ annotations,
68
+ annotationResolvers,
69
+ );
70
+ }
71
+
72
+ private async discoverAnnotationResolvers() {
73
+ type AnnotationName = string;
74
+ type AnnotationCallback = (...args: any[]) => any;
75
+ const annotationResolvers = new Map<AnnotationName, AnnotationCallback>();
76
+
77
+ const paramFactory = new GraphQLAnnotationResolverParamsFactory();
78
+ const instances = this.resolverDiscoveryService.explore();
79
+
80
+ for (const { instance } of instances)
81
+ for (const methodName of this.metadataScanner.getAllMethodNames(
82
+ Object.getPrototypeOf(instance),
83
+ )) {
84
+ const resolverMeta = Reflect.getMetadata(
85
+ ANNOTATION_RESOLVER_METADATA,
86
+ instance[methodName],
87
+ ) as AnnotationResolverMetadata;
88
+
89
+ if (!resolverMeta) continue; // Skip non-annotated
90
+
91
+ this._logger.log(
92
+ `Discovered resolver for annotation "${resolverMeta.annotation}"`,
93
+ );
94
+ annotationResolvers.set(
95
+ resolverMeta.annotation,
96
+ this.externalContextCreator.create<
97
+ Record<number, ParamMetadata>,
98
+ GqlContextType
99
+ >(
100
+ instance,
101
+ instance[methodName],
102
+ methodName,
103
+ PARAM_ARGS_METADATA,
104
+ paramFactory,
105
+ undefined,
106
+ undefined,
107
+ undefined,
108
+ "graphql",
109
+ ),
110
+ );
111
+ }
112
+
113
+ return annotationResolvers;
114
+ }
115
+
116
+ private async batchLoadSources(
117
+ sources: AnnotationSchemaSources,
118
+ opts?: { ignoreMissingSources?: boolean },
119
+ ) {
120
+ const pending = Object.entries(sources).map(async ([name, url]) => {
121
+ const loadId = `${name}@${url}`;
122
+ try {
123
+ const loaded = {
124
+ id: loadId,
125
+ url: url,
126
+ name: name,
127
+ subschema: await loadGraphQLSchema(url),
128
+ annotation: await this.loadAnnotations(url),
129
+ };
130
+
131
+ // Cleanup
132
+ // Remove `_annotations` from being merged to main schema
133
+ loaded.subschema.transforms = [
134
+ new FilterRootFields(
135
+ (op, fieldName) => !fieldName.endsWith(ANNOTATION_QUERY_NAME),
136
+ ),
137
+ ];
138
+
139
+ this._logger.log(`Loaded schema for ${loadId}`);
140
+ return loaded;
141
+ } catch (err) {
142
+ this._logger.error(`Unable to load schema for ${loadId}`);
143
+ throw err;
144
+ }
145
+ });
146
+
147
+ const [loaded, failed] = split(
148
+ await Promise.allSettled(pending),
149
+ (q) => q.status === "fulfilled",
150
+ );
151
+
152
+ const shouldThrow = !(opts?.ignoreMissingSources ?? false);
153
+ if (failed.length > 0) {
154
+ if (shouldThrow)
155
+ throw new Error(
156
+ `Failed to load ${failed.length} schema(s) from sources.`,
157
+ );
158
+ else
159
+ this._logger.warn(
160
+ `Failed to load ${failed.length} schema(s) from sources.`,
161
+ );
162
+ }
163
+
164
+ return loaded.map((l) => l.value);
165
+ }
166
+
167
+ private async loadAnnotations(url: string) {
168
+ const client = new GraphQLClient(url);
169
+
170
+ try {
171
+ type ExpectedResult = {
172
+ [ANNOTATION_QUERY_NAME]: GraphQLAnnotationMeta;
173
+ };
174
+ const res = await client.rawRequest<ExpectedResult>(`
175
+ query IntrospectAnnotations {
176
+ ${ANNOTATION_QUERY_NAME} {
177
+ name
178
+ resolvers
179
+ }
180
+ }
181
+ `);
182
+
183
+ this._logger.log(`Loaded annotations for ${url}`);
184
+ return res.data[ANNOTATION_QUERY_NAME];
185
+ } catch (err) {
186
+ this._logger.warn(`Failed to load annotations at ${url}, assuming empty`);
187
+ return {} as GraphQLAnnotationMeta;
188
+ }
189
+ }
190
+
191
+ private async embedAnnotationResolversToSchema(
192
+ schema: GraphQLSchema,
193
+ annotations: GraphQLAnnotationMeta,
194
+ annotationResolvers: Map<ResolverName, (...args: any[]) => any>,
195
+ ) {
196
+ return mapSchema(schema, {
197
+ [MapperKind.ROOT_FIELD]: (fieldSchema, name, type) => {
198
+ const fieldAnnotations = (annotations.resolvers[name] ?? [])
199
+ .map((info) => ({
200
+ ...info,
201
+ resolver: annotationResolvers.get(info.annotation)!, // NOTE: should be validated in filter
202
+ }))
203
+ .filter((info) => {
204
+ const annotationHasResolver = info.resolver !== undefined;
205
+ if (!annotationHasResolver)
206
+ this._logger.warn(
207
+ `Found unhandled annotation "${info.annotation}", skipping linking process`,
208
+ );
209
+
210
+ return annotationHasResolver;
211
+ });
212
+
213
+ if (fieldAnnotations.length <= 0) return fieldSchema; // No transform if there's no annotation
214
+
215
+ // If there exist a resolver for an annotate parameter, remove it from schema to prevent external injection
216
+ for (const annotation of fieldAnnotations)
217
+ if (annotation.target.type === "parameter" && fieldSchema.args)
218
+ delete fieldSchema.args[annotation.target.paramName];
219
+
220
+ const defaultResolver = fieldSchema.resolve!;
221
+ fieldSchema.resolve = async function (
222
+ parent,
223
+ args: Record<string, any>,
224
+ context: GraphQLExecutionContext,
225
+ info,
226
+ ) {
227
+ const annotationCallbacks = await Promise.all(
228
+ fieldAnnotations.map(async (annotation) => ({
229
+ annotation,
230
+ return: await annotation.resolver(
231
+ ...([
232
+ info.rootValue,
233
+ annotation.data as Record<string, unknown>,
234
+ context,
235
+ info,
236
+ ] satisfies GraphQLAnnotationResolverArgs),
237
+ ),
238
+ })),
239
+ );
240
+
241
+ const resolvedParams = annotationCallbacks.reduce(
242
+ (params, call) => {
243
+ if (call.annotation.target.type !== "parameter") return params;
244
+
245
+ const returnedClassType =
246
+ typeof call.return === "object" && !isPlainObject(call.return);
247
+ const returnValue = returnedClassType
248
+ ? instanceToPlain(call.return)
249
+ : call.return;
250
+ params[call.annotation.target.paramName] = returnValue;
251
+
252
+ return params;
253
+ },
254
+ {} as Record<string, any>,
255
+ );
256
+
257
+ return defaultResolver(
258
+ parent,
259
+ { ...args, ...resolvedParams },
260
+ context,
261
+ info,
262
+ );
263
+ };
264
+
265
+ return fieldSchema;
266
+ },
267
+ });
268
+ }
269
+ }
270
+
271
+ type GraphQLAnnotationResolverArgs = [
272
+ // Root/parent (follows graphql context to match)
273
+ any,
274
+ // Data
275
+ Record<string, unknown>,
276
+ // GraphQL Context
277
+ GraphQLExecutionContext,
278
+ // GraphQL Resolver Info
279
+ GraphQLResolveInfo,
280
+ ];
281
+ class GraphQLAnnotationResolverParamsFactory implements ParamsFactory {
282
+ // This param factory extends graphql's own just with different inputs from annotations instead.
283
+ // https://github.com/nestjs/graphql/blob/master/packages/graphql/lib/factories/params.factory.ts#L9
284
+ exchangeKeyForValue(
285
+ type: number,
286
+ possibleKey: ParamData,
287
+ // NOTE: nestjs passes this in array, so we have to "get the first element"
288
+ argsContext: GraphQLAnnotationResolverArgs,
289
+ ) {
290
+ if (!argsContext) return null;
291
+
292
+ const args = {
293
+ parentValue: argsContext[0],
294
+ data: argsContext[1],
295
+ context: argsContext[2],
296
+ info: argsContext[3],
297
+ };
298
+
299
+ switch (type as GqlParamtype) {
300
+ case GqlParamtype.ROOT:
301
+ return args.parentValue;
302
+ case GqlParamtype.ARGS:
303
+ return possibleKey && args.data
304
+ ? args.data[possibleKey as string]
305
+ : args.data;
306
+ case GqlParamtype.CONTEXT:
307
+ return possibleKey && args.context
308
+ ? args.context[possibleKey as string]
309
+ : args.context;
310
+ case GqlParamtype.INFO:
311
+ return possibleKey && args.context
312
+ ? args.info[possibleKey as string]
313
+ : args.info;
314
+ default:
315
+ return null;
316
+ }
317
+ }
318
+ }