@apollo/federation-internals 2.11.2 → 2.11.3

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,4 +1,4 @@
1
- import { DirectiveLocation, GraphQLError } from 'graphql';
1
+ import { DirectiveLocation } from 'graphql';
2
2
  import {
3
3
  CorePurpose,
4
4
  FeatureDefinition,
@@ -7,17 +7,28 @@ import {
7
7
  FeatureVersion,
8
8
  } from './coreSpec';
9
9
  import {
10
- Schema,
11
- NonNullType,
10
+ CoreFeature,
12
11
  InputObjectType,
13
- InputFieldDefinition,
12
+ isInputObjectType,
13
+ isNonNullType,
14
14
  ListType,
15
+ NamedType,
16
+ NonNullType,
17
+ ScalarType,
18
+ Schema,
15
19
  } from '../definitions';
16
20
  import { registerKnownFeature } from '../knownCoreFeatures';
17
21
  import {
18
22
  createDirectiveSpecification,
19
23
  createScalarTypeSpecification,
24
+ ensureSameTypeKind,
25
+ InputFieldSpecification,
26
+ TypeSpecification,
20
27
  } from '../directiveAndTypeSpecification';
28
+ import { ERRORS } from '../error';
29
+ import { sameType } from '../types';
30
+ import { assert } from '../utils';
31
+ import { valueEquals, valueToString } from '../values';
21
32
 
22
33
  export const connectIdentity = 'https://specs.apollo.dev/connect';
23
34
 
@@ -41,60 +52,46 @@ export class ConnectSpecDefinition extends FeatureDefinition {
41
52
  minimumFederationVersion,
42
53
  );
43
54
 
44
- this.registerDirective(
45
- createDirectiveSpecification({
46
- name: CONNECT,
47
- locations: [DirectiveLocation.FIELD_DEFINITION],
48
- repeatable: true,
49
- // We "compose" these directives using the `@join__directive` mechanism,
50
- // so they do not need to be composed in the way passing `composes: true`
51
- // here implies.
52
- composes: false,
53
- }),
54
- );
55
-
56
- this.registerDirective(
57
- createDirectiveSpecification({
58
- name: SOURCE,
59
- locations: [DirectiveLocation.SCHEMA],
60
- repeatable: true,
61
- composes: false,
62
- }),
63
- );
55
+ function lookupFeatureTypeInSchema<T extends NamedType>(name: string, kind: T['kind'], schema: Schema, feature?: CoreFeature): T {
56
+ assert(feature, `Shouldn't be added without being attached to a @connect spec`);
57
+ const typeName = feature.typeNameInSchema(name);
58
+ const type = schema.typeOfKind<T>(typeName, kind);
59
+ assert(type, () => `Expected "${typeName}" to be defined`);
60
+ return type;
61
+ }
64
62
 
63
+ /* scalar URLPathTemplate */
65
64
  this.registerType(
66
- createScalarTypeSpecification({ name: URL_PATH_TEMPLATE }),
65
+ createScalarTypeSpecification({ name: URL_PATH_TEMPLATE }),
67
66
  );
68
- this.registerType(createScalarTypeSpecification({ name: JSON_SELECTION }));
69
- this.registerType({ name: CONNECT_HTTP, checkOrAdd: () => [] });
70
- this.registerType({ name: SOURCE_HTTP, checkOrAdd: () => [] });
71
- this.registerType({ name: HTTP_HEADER_MAPPING, checkOrAdd: () => [] });
72
- }
73
-
74
- addElementsToSchema(schema: Schema): GraphQLError[] {
75
- /* scalar URLPathTemplate */
76
- const URLPathTemplate = this.addScalarType(schema, URL_PATH_TEMPLATE);
77
-
78
67
  /* scalar JSONSelection */
79
- const JSONSelection = this.addScalarType(schema, JSON_SELECTION);
68
+ this.registerType(createScalarTypeSpecification({ name: JSON_SELECTION }));
80
69
 
81
70
  /*
82
- directive @connect(
83
- source: String
84
- http: ConnectHTTP
85
- selection: JSONSelection!
86
- entity: Boolean = false
87
- errors: ConnectorErrors
88
- ) repeatable on FIELD_DEFINITION
89
- | OBJECT # added in v0.2, validation enforced in rust
71
+ input ConnectorErrors {
72
+ message: JSONSelection
73
+ extensions: JSONSelection
74
+ }
90
75
  */
91
- const connect = this.addDirective(schema, CONNECT).addLocations(
92
- DirectiveLocation.FIELD_DEFINITION,
93
- DirectiveLocation.OBJECT,
76
+ this.registerType(
77
+ createInputObjectTypeSpecification({
78
+ name: CONNECTOR_ERRORS,
79
+ inputFieldsFct: (schema, feature) => {
80
+ const jsonSelectionType =
81
+ lookupFeatureTypeInSchema<ScalarType>(JSON_SELECTION, 'ScalarType', schema, feature);
82
+ return [
83
+ {
84
+ name: 'message',
85
+ type: jsonSelectionType
86
+ },
87
+ {
88
+ name: 'extensions',
89
+ type: jsonSelectionType
90
+ },
91
+ ]
92
+ }
93
+ })
94
94
  );
95
- connect.repeatable = true;
96
-
97
- connect.addArgument(SOURCE, schema.stringType());
98
95
 
99
96
  /*
100
97
  input HTTPHeaderMapping {
@@ -103,15 +100,82 @@ export class ConnectSpecDefinition extends FeatureDefinition {
103
100
  value: String
104
101
  }
105
102
  */
106
- const HTTPHeaderMapping = schema.addType(
107
- new InputObjectType(this.typeNameInSchema(schema, HTTP_HEADER_MAPPING)!),
103
+ this.registerType(
104
+ createInputObjectTypeSpecification({
105
+ name: HTTP_HEADER_MAPPING,
106
+ inputFieldsFct: (schema) => [
107
+ {
108
+ name: 'name',
109
+ type: new NonNullType(schema.stringType())
110
+ },
111
+ {
112
+ name: 'from',
113
+ type: schema.stringType()
114
+ },
115
+ {
116
+ name: 'value',
117
+ type: schema.stringType()
118
+ },
119
+ ]
120
+ })
121
+ );
122
+
123
+ /*
124
+ input ConnectBatch {
125
+ maxSize: Int
126
+ }
127
+ */
128
+ this.registerType(
129
+ createInputObjectTypeSpecification({
130
+ name: CONNECT_BATCH,
131
+ inputFieldsFct: (schema) => [
132
+ {
133
+ name: 'maxSize',
134
+ type: schema.intType()
135
+ }
136
+ ]
137
+ })
138
+ )
139
+
140
+ /*
141
+ input SourceHTTP {
142
+ baseURL: String!
143
+ headers: [HTTPHeaderMapping!]
144
+
145
+ # added in v0.2
146
+ path: JSONSelection
147
+ queryParams: JSONSelection
148
+ }
149
+ */
150
+ this.registerType(
151
+ createInputObjectTypeSpecification({
152
+ name: SOURCE_HTTP,
153
+ inputFieldsFct: (schema, feature) => {
154
+ const jsonSelectionType =
155
+ lookupFeatureTypeInSchema<ScalarType>(JSON_SELECTION, 'ScalarType', schema, feature);
156
+ const httpHeaderMappingType =
157
+ lookupFeatureTypeInSchema<InputObjectType>(HTTP_HEADER_MAPPING, 'InputObjectType', schema, feature);
158
+ return [
159
+ {
160
+ name: 'baseURL',
161
+ type: new NonNullType(schema.stringType())
162
+ },
163
+ {
164
+ name: 'headers',
165
+ type: new ListType(new NonNullType(httpHeaderMappingType))
166
+ },
167
+ {
168
+ name: 'path',
169
+ type: jsonSelectionType
170
+ },
171
+ {
172
+ name: 'queryParams',
173
+ type: jsonSelectionType
174
+ }
175
+ ];
176
+ }
177
+ })
108
178
  );
109
- HTTPHeaderMapping.addField(new InputFieldDefinition('name')).type =
110
- new NonNullType(schema.stringType());
111
- HTTPHeaderMapping.addField(new InputFieldDefinition('from')).type =
112
- schema.stringType();
113
- HTTPHeaderMapping.addField(new InputFieldDefinition('value')).type =
114
- schema.stringType();
115
179
 
116
180
  /*
117
181
  input ConnectHTTP {
@@ -125,82 +189,155 @@ export class ConnectSpecDefinition extends FeatureDefinition {
125
189
 
126
190
  # added in v0.2
127
191
  path: JSONSelection
128
- query: JSONSelection
192
+ queryParams: JSONSelection
129
193
  }
130
194
  */
131
- const ConnectHTTP = schema.addType(
132
- new InputObjectType(this.typeNameInSchema(schema, CONNECT_HTTP)!),
195
+ this.registerType(
196
+ createInputObjectTypeSpecification({
197
+ name: CONNECT_HTTP,
198
+ inputFieldsFct: (schema, feature) => {
199
+ const urlPathTemplateType =
200
+ lookupFeatureTypeInSchema<ScalarType>(URL_PATH_TEMPLATE, 'ScalarType', schema, feature);
201
+ const jsonSelectionType =
202
+ lookupFeatureTypeInSchema<ScalarType>(JSON_SELECTION, 'ScalarType', schema, feature);
203
+ const httpHeaderMappingType =
204
+ lookupFeatureTypeInSchema<InputObjectType>(HTTP_HEADER_MAPPING, 'InputObjectType', schema, feature);
205
+ return [
206
+ {
207
+ name: 'GET',
208
+ type: urlPathTemplateType
209
+ },
210
+ {
211
+ name: 'POST',
212
+ type: urlPathTemplateType
213
+ },
214
+ {
215
+ name: 'PUT',
216
+ type: urlPathTemplateType
217
+ },
218
+ {
219
+ name: 'PATCH',
220
+ type: urlPathTemplateType
221
+ },
222
+ {
223
+ name: 'DELETE',
224
+ type: urlPathTemplateType
225
+ },
226
+ {
227
+ name: 'body',
228
+ type: jsonSelectionType
229
+ },
230
+ {
231
+ name: 'headers',
232
+ type: new ListType(new NonNullType(httpHeaderMappingType))
233
+ },
234
+ {
235
+ name: 'path',
236
+ type: jsonSelectionType
237
+ },
238
+ {
239
+ name: 'queryParams',
240
+ type: jsonSelectionType
241
+ },
242
+ ];
243
+ }
244
+ })
133
245
  );
134
- ConnectHTTP.addField(new InputFieldDefinition('GET')).type =
135
- URLPathTemplate;
136
- ConnectHTTP.addField(new InputFieldDefinition('POST')).type =
137
- URLPathTemplate;
138
- ConnectHTTP.addField(new InputFieldDefinition('PUT')).type =
139
- URLPathTemplate;
140
- ConnectHTTP.addField(new InputFieldDefinition('PATCH')).type =
141
- URLPathTemplate;
142
- ConnectHTTP.addField(new InputFieldDefinition('DELETE')).type =
143
- URLPathTemplate;
144
- ConnectHTTP.addField(new InputFieldDefinition('body')).type = JSONSelection;
145
- ConnectHTTP.addField(new InputFieldDefinition('headers')).type =
146
- new ListType(new NonNullType(HTTPHeaderMapping));
147
-
148
- ConnectHTTP.addField(new InputFieldDefinition('path')).type = JSONSelection;
149
- ConnectHTTP.addField(new InputFieldDefinition('queryParams')).type =
150
- JSONSelection;
151
-
152
- connect.addArgument('http', new NonNullType(ConnectHTTP));
153
-
154
- const ConnectBatch = schema.addType(new InputObjectType(this.typeNameInSchema(schema, CONNECT_BATCH)!));
155
- ConnectBatch.addField(new InputFieldDefinition('maxSize')).type = schema.intType();
156
- connect.addArgument('batch', ConnectBatch);
157
-
158
- const ConnectorErrors = schema.addType(new InputObjectType(this.typeNameInSchema(schema, CONNECTOR_ERRORS)!));
159
- ConnectorErrors.addField(new InputFieldDefinition('message')).type = JSONSelection;
160
- ConnectorErrors.addField(new InputFieldDefinition('extensions')).type = JSONSelection;
161
- connect.addArgument('errors', ConnectorErrors);
162
-
163
- connect.addArgument('selection', new NonNullType(JSONSelection));
164
- connect.addArgument('entity', schema.booleanType(), false);
165
246
 
166
247
  /*
167
- directive @source(
168
- name: String!
169
- http: ConnectHTTP
248
+ directive @connect(
249
+ source: String
250
+ http: ConnectHTTP!
251
+ batch: ConnectBatch
170
252
  errors: ConnectorErrors
171
- ) repeatable on SCHEMA
253
+ selection: JSONSelection!
254
+ entity: Boolean = false
255
+ ) repeatable on FIELD_DEFINITION
256
+ | OBJECT # added in v0.2, validation enforced in rust
172
257
  */
173
- const source = this.addDirective(schema, SOURCE).addLocations(
174
- DirectiveLocation.SCHEMA,
258
+ this.registerDirective(
259
+ createDirectiveSpecification({
260
+ name: CONNECT,
261
+ locations: [DirectiveLocation.FIELD_DEFINITION, DirectiveLocation.OBJECT],
262
+ repeatable: true,
263
+ args: [
264
+ {
265
+ name: 'source',
266
+ type: (schema) => schema.stringType()
267
+ },
268
+ {
269
+ name: 'http',
270
+ type: (schema, feature) => {
271
+ const connectHttpType =
272
+ lookupFeatureTypeInSchema<InputObjectType>(CONNECT_HTTP, 'InputObjectType', schema, feature);
273
+ return new NonNullType(connectHttpType);
274
+ }
275
+ },
276
+ {
277
+ name: 'batch',
278
+ type: (schema, feature) =>
279
+ lookupFeatureTypeInSchema<InputObjectType>(CONNECT_BATCH, 'InputObjectType', schema, feature)
280
+ },
281
+ {
282
+ name: 'errors',
283
+ type: (schema, feature) =>
284
+ lookupFeatureTypeInSchema<InputObjectType>(CONNECTOR_ERRORS, 'InputObjectType', schema, feature)
285
+ },
286
+ {
287
+ name: 'selection',
288
+ type: (schema, feature) => {
289
+ const jsonSelectionType =
290
+ lookupFeatureTypeInSchema<ScalarType>(JSON_SELECTION, 'ScalarType', schema, feature);
291
+ return new NonNullType(jsonSelectionType);
292
+ }
293
+ },
294
+ {
295
+ name: 'entity',
296
+ type: (schema) => schema.booleanType(),
297
+ defaultValue: false
298
+ }
299
+ ],
300
+ // We "compose" these directives using the `@join__directive` mechanism,
301
+ // so they do not need to be composed in the way passing `composes: true`
302
+ // here implies.
303
+ composes: false,
304
+ }),
175
305
  );
176
- source.repeatable = true;
177
- source.addArgument('name', new NonNullType(schema.stringType()));
178
306
 
179
307
  /*
180
- input SourceHTTP {
181
- baseURL: String!
182
- headers: [HTTPHeaderMapping!]
183
-
184
- # added in v0.2
185
- path: JSONSelection
186
- query: JSONSelection
187
- }
308
+ directive @source(
309
+ name: String!
310
+ http: SourceHTTP!
311
+ errors: ConnectorErrors
312
+ ) repeatable on SCHEMA
188
313
  */
189
- const SourceHTTP = schema.addType(
190
- new InputObjectType(this.typeNameInSchema(schema, SOURCE_HTTP)!),
314
+ this.registerDirective(
315
+ createDirectiveSpecification({
316
+ name: SOURCE,
317
+ locations: [DirectiveLocation.SCHEMA],
318
+ repeatable: true,
319
+ composes: false,
320
+ args: [
321
+ {
322
+ name: 'name',
323
+ type: (schema) => new NonNullType(schema.stringType())
324
+ },
325
+ {
326
+ name: 'http',
327
+ type: (schema, feature) => {
328
+ const sourceHttpType =
329
+ lookupFeatureTypeInSchema<InputObjectType>(SOURCE_HTTP, 'InputObjectType', schema, feature);
330
+ return new NonNullType(sourceHttpType);
331
+ }
332
+ },
333
+ {
334
+ name: 'errors',
335
+ type: (schema, feature) =>
336
+ lookupFeatureTypeInSchema<InputObjectType>(CONNECTOR_ERRORS, 'InputObjectType', schema, feature)
337
+ }
338
+ ]
339
+ }),
191
340
  );
192
- SourceHTTP.addField(new InputFieldDefinition('baseURL')).type =
193
- new NonNullType(schema.stringType());
194
- SourceHTTP.addField(new InputFieldDefinition('headers')).type =
195
- new ListType(new NonNullType(HTTPHeaderMapping));
196
-
197
- SourceHTTP.addField(new InputFieldDefinition('path')).type = JSONSelection;
198
- SourceHTTP.addField(new InputFieldDefinition('queryParams')).type = JSONSelection;
199
-
200
- source.addArgument('http', new NonNullType(SourceHTTP));
201
- source.addArgument('errors', ConnectorErrors);
202
-
203
- return [];
204
341
  }
205
342
 
206
343
  get defaultCorePurpose(): CorePurpose {
@@ -225,3 +362,108 @@ export const CONNECT_VERSIONS = new FeatureDefinitions<ConnectSpecDefinition>(
225
362
  );
226
363
 
227
364
  registerKnownFeature(CONNECT_VERSIONS);
365
+
366
+ // This function is purposefully declared only in this file and without export.
367
+ //
368
+ // Do NOT add this to "internals-js/src/directiveAndTypeSpecification.ts", and
369
+ // do NOT export this function.
370
+ //
371
+ // Subgraph schema building, at this time of writing, does not really support
372
+ // input objects in specs. We did a number of one-off things to support them in
373
+ // the connect spec's case, and it will be non-maintainable/bug-prone to do them
374
+ // again.
375
+ //
376
+ // There's work to be done to support input objects more generally; please see
377
+ // https://github.com/apollographql/federation/pull/3311 for more information.
378
+ function createInputObjectTypeSpecification({
379
+ name,
380
+ inputFieldsFct,
381
+ }: {
382
+ name: string,
383
+ inputFieldsFct: (schema: Schema, feature?: CoreFeature) => InputFieldSpecification[],
384
+ }): TypeSpecification {
385
+ return {
386
+ name,
387
+ checkOrAdd: (schema: Schema, feature?: CoreFeature, asBuiltIn?: boolean) => {
388
+ const actualName = feature?.typeNameInSchema(name) ?? name;
389
+ const expectedFields = inputFieldsFct(schema, feature);
390
+ const existing = schema.type(actualName);
391
+ if (existing) {
392
+ let errors = ensureSameTypeKind('InputObjectType', existing);
393
+ if (errors.length > 0) {
394
+ return errors;
395
+ }
396
+ assert(isInputObjectType(existing), 'Should be an input object type');
397
+ // The following mimics `ensureSameArguments()`, but with some changes.
398
+ for (const { name: fieldName, type, defaultValue } of expectedFields) {
399
+ const existingField = existing.field(fieldName);
400
+ if (!existingField) {
401
+ // Not declaring an optional input field is ok: that means you won't
402
+ // be able to pass a non-default value in your schema, but we allow
403
+ // you that. But missing a required input field it not ok.
404
+ if (isNonNullType(type) && defaultValue === undefined) {
405
+ errors.push(ERRORS.TYPE_DEFINITION_INVALID.err(
406
+ `Invalid definition for type ${name}: missing required input field "${fieldName}"`,
407
+ { nodes: existing.sourceAST },
408
+ ));
409
+ }
410
+ continue;
411
+ }
412
+
413
+ let existingType = existingField.type!;
414
+ if (isNonNullType(existingType) && !isNonNullType(type)) {
415
+ // It's ok to redefine an optional input field as mandatory. For
416
+ // instance, if you want to force people on your team to provide a
417
+ // "maxSize", you can redefine ConnectBatch as
418
+ // `input ConnectBatch { maxSize: Int! }` to get validation. In
419
+ // other words, you are allowed to always pass an input field that
420
+ // is optional if you so wish.
421
+ existingType = existingType.ofType;
422
+ }
423
+ // Note that while `ensureSameArguments()` allows input type
424
+ // redefinitions (e.g. allowing users to declare `String` instead of a
425
+ // custom scalar), this behavior can be confusing/error-prone more
426
+ // generally, so we forbid this for now. We can relax this later on a
427
+ // case-by-case basis if needed.
428
+ //
429
+ // Further, `ensureSameArguments()` would skip default value checking
430
+ // if the input type was non-nullable. It's unclear why this is there;
431
+ // it may have been a mistake due to the impression that non-nullable
432
+ // inputs can't have default values (they can), or this may have been
433
+ // to avoid some breaking change, but there's no such limitation in
434
+ // the case of input objects, so we always validate default values
435
+ // here.
436
+ if (!sameType(type, existingType)) {
437
+ errors.push(ERRORS.TYPE_DEFINITION_INVALID.err(
438
+ `Invalid definition for type ${name}: input field "${fieldName}" should have type "${type}" but found type "${existingField.type!}"`,
439
+ { nodes: existingField.sourceAST },
440
+ ));
441
+ } else if (!valueEquals(defaultValue, existingField.defaultValue)) {
442
+ errors.push(ERRORS.TYPE_DEFINITION_INVALID.err(
443
+ `Invalid definition type ${name}: input field "${fieldName}" should have default value ${valueToString(defaultValue)} but found default value ${valueToString(existingField.defaultValue)}`,
444
+ { nodes: existingField.sourceAST },
445
+ ));
446
+ }
447
+ }
448
+ for (const existingField of existing.fields()) {
449
+ // If it's an expected input field, we already validated it. But we
450
+ // still need to reject unknown input fields.
451
+ if (!expectedFields.some((field) => field.name === existingField.name)) {
452
+ errors.push(ERRORS.TYPE_DEFINITION_INVALID.err(
453
+ `Invalid definition for type ${name}: unknown/unsupported input field "${existingField.name}"`,
454
+ { nodes: existingField.sourceAST },
455
+ ));
456
+ }
457
+ }
458
+ return errors;
459
+ } else {
460
+ const createdType = schema.addType(new InputObjectType(actualName, asBuiltIn));
461
+ for (const { name, type, defaultValue } of expectedFields) {
462
+ const newField = createdType.addField(name, type);
463
+ newField.defaultValue = defaultValue;
464
+ }
465
+ return [];
466
+ }
467
+ },
468
+ }
469
+ }