Skip to content

Commit 66833fb

Browse files
authored
Let known FeatureDefinition subclasses implement validateSubgraphSchema method (#2910)
1 parent 56a7816 commit 66833fb

File tree

8 files changed

+716
-6
lines changed

8 files changed

+716
-6
lines changed

.changeset/popular-mirrors-move.md

+6
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@apollo/federation-internals": minor
3+
"@apollo/composition": patch
4+
---
5+
6+
Allow known `FeatureDefinition` subclasses to define custom subgraph schema validation rules

composition-js/src/__tests__/compose.test.ts

+143
Original file line numberDiff line numberDiff line change
@@ -4961,4 +4961,147 @@ describe('@source* directives', () => {
49614961
}`
49624962
)
49634963
});
4964+
4965+
describe('validation errors', () => {
4966+
const goodSchema = gql`
4967+
extend schema
4968+
@link(url: "https://specs.apollo.dev/federation/v2.7", import: ["@key"])
4969+
@link(url: "https://specs.apollo.dev/source/v0.1", import: [
4970+
"@sourceAPI"
4971+
"@sourceType"
4972+
"@sourceField"
4973+
])
4974+
@sourceAPI(
4975+
name: "A"
4976+
http: { baseURL: "https://api.a.com/v1" }
4977+
)
4978+
{
4979+
query: Query
4980+
}
4981+
4982+
type Query {
4983+
resources: [Resource!]! @sourceField(
4984+
api: "A"
4985+
http: { GET: "/resources" }
4986+
)
4987+
}
4988+
4989+
type Resource @key(fields: "id") @sourceType(
4990+
api: "A"
4991+
http: { GET: "/resources/{id}" }
4992+
selection: "id description"
4993+
) {
4994+
id: ID!
4995+
description: String!
4996+
}
4997+
`;
4998+
4999+
const badSchema = gql`
5000+
extend schema
5001+
@link(url: "https://specs.apollo.dev/federation/v2.7", import: ["@key"])
5002+
@link(url: "https://specs.apollo.dev/source/v0.1", import: [
5003+
"@sourceAPI"
5004+
"@sourceType"
5005+
"@sourceField"
5006+
])
5007+
@sourceAPI(
5008+
name: "A?!" # Should be valid GraphQL identifier
5009+
http: { baseURL: "https://api.a.com/v1" }
5010+
)
5011+
{
5012+
query: Query
5013+
}
5014+
5015+
type Query {
5016+
resources: [Resource!]! @sourceField(
5017+
api: "A"
5018+
http: { GET: "/resources" }
5019+
)
5020+
}
5021+
5022+
type Resource @key(fields: "id") @sourceType(
5023+
api: "A"
5024+
http: { GET: "/resources/{id}" }
5025+
selection: "id description"
5026+
) {
5027+
id: ID!
5028+
description: String!
5029+
}
5030+
`;
5031+
5032+
it('good schema composes without validation errors', () => {
5033+
const result = composeServices([{
5034+
name: 'good',
5035+
typeDefs: goodSchema,
5036+
}]);
5037+
expect(result.errors ?? []).toEqual([]);
5038+
});
5039+
5040+
it('bad schema composes with validation errors', () => {
5041+
const result = composeServices([{
5042+
name: 'bad',
5043+
typeDefs: badSchema,
5044+
}]);
5045+
5046+
const messages = result.errors!.map(e => e.message);
5047+
5048+
expect(messages).toContain(
5049+
'[bad] @sourceAPI(name: "A?!") must specify valid GraphQL name'
5050+
);
5051+
5052+
expect(messages).toContain(
5053+
'[bad] @sourceType specifies unknown api A'
5054+
);
5055+
5056+
expect(messages).toContain(
5057+
'[bad] @sourceField specifies unknown api A'
5058+
);
5059+
});
5060+
5061+
const renamedSchema = gql`
5062+
extend schema
5063+
@link(url: "https://specs.apollo.dev/federation/v2.7", import: ["@key"])
5064+
@link(url: "https://specs.apollo.dev/source/v0.1", import: [
5065+
{ name: "@sourceAPI", as: "@api" }
5066+
{ name: "@sourceType", as: "@type" }
5067+
{ name: "@sourceField", as: "@field" }
5068+
])
5069+
@api(
5070+
name: "not an identifier"
5071+
http: { baseURL: "https://api.a.com/v1" }
5072+
)
5073+
{
5074+
query: Query
5075+
}
5076+
5077+
type Query {
5078+
resources: [Resource!]! @field(
5079+
api: "not an identifier"
5080+
http: { GET: "/resources" }
5081+
)
5082+
}
5083+
5084+
type Resource @key(fields: "id") @type(
5085+
api: "not an identifier"
5086+
http: { GET: "/resources/{id}" }
5087+
selection: "id description"
5088+
) {
5089+
id: ID!
5090+
description: String!
5091+
}
5092+
`;
5093+
5094+
it('can handle the @source* directives being renamed', () => {
5095+
const result = composeServices([{
5096+
name: 'renamed',
5097+
typeDefs: renamedSchema,
5098+
}]);
5099+
5100+
const messages = result.errors!.map(e => e.message);
5101+
5102+
expect(messages).toContain(
5103+
'[renamed] @api(name: "not an identifier") must specify valid GraphQL name'
5104+
);
5105+
});
5106+
});
49645107
});

docs/source/errors.md

+18
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,24 @@ The following errors might be raised during composition:
8989
| `ROOT_SUBSCRIPTION_USED` | A subgraph's schema defines a type with the name `subscription`, while also specifying a _different_ type name as the root query object. This is not allowed. | 0.x | |
9090
| `SATISFIABILITY_ERROR` | Subgraphs can be merged, but the resulting supergraph API would have queries that cannot be satisfied by those subgraphs. | 2.0.0 | |
9191
| `SHAREABLE_HAS_MISMATCHED_RUNTIME_TYPES` | A shareable field return type has mismatched possible runtime types in the subgraphs in which the field is declared. As shared fields must resolve the same way in all subgraphs, this is almost surely a mistake. | 2.0.0 | |
92+
| `SOURCE_API_HTTP_BASE_URL_INVALID` | The `@sourceAPI` directive must specify a valid http.baseURL | 2.7.0 | |
93+
| `SOURCE_API_NAME_INVALID` | Each `@sourceAPI` directive must take a unique and valid name as an argument | 2.7.0 | |
94+
| `SOURCE_API_PROTOCOL_INVALID` | Each `@sourceAPI` directive must specify exactly one of the known protocols | 2.7.0 | |
95+
| `SOURCE_FIELD_API_ERROR` | The `api` argument of the `@sourceField` directive must match a valid `@sourceAPI` name | 2.7.0 | |
96+
| `SOURCE_FIELD_HTTP_BODY_INVALID` | If `@sourceField` specifies http.body, it must be a valid `JSONSelection` matching available arguments and fields | 2.7.0 | |
97+
| `SOURCE_FIELD_HTTP_METHOD_INVALID` | The `@sourceField` directive must specify at most one of `http.{GET,POST,PUT,PATCH,DELETE}` | 2.7.0 | |
98+
| `SOURCE_FIELD_HTTP_PATH_INVALID` | The `@sourceField` directive must specify a valid URL template for `http.{GET,POST,PUT,PATCH,DELETE}` | 2.7.0 | |
99+
| `SOURCE_FIELD_NOT_ON_ROOT_OR_ENTITY_FIELD` | The `@sourceField` directive must be applied to a field of the `Query` or `Mutation` types, or of an entity type | 2.7.0 | |
100+
| `SOURCE_FIELD_PROTOCOL_INVALID` | If `@sourceField` specifies a protocol, it must match the corresponding `@sourceAPI` protocol | 2.7.0 | |
101+
| `SOURCE_FIELD_SELECTION_INVALID` | The `selection` argument of the `@sourceField` directive must be a valid `JSONSelection` that outputs fields of the GraphQL type | 2.7.0 | |
102+
| `SOURCE_HTTP_HEADERS_INVALID` | The `http.headers` argument of `@source*` directives must specify valid HTTP headers | 2.7.0 | |
103+
| `SOURCE_TYPE_API_ERROR` | The `api` argument of the `@sourceType` directive must match a valid `@sourceAPI` name | 2.7.0 | |
104+
| `SOURCE_TYPE_HTTP_BODY_INVALID` | If the `@sourceType` specifies `http.body`, it must be a valid `JSONSelection` | 2.7.0 | |
105+
| `SOURCE_TYPE_HTTP_METHOD_INVALID` | The `@sourceType` directive must specify exactly one of `http.GET` or `http.POST` | 2.7.0 | |
106+
| `SOURCE_TYPE_HTTP_PATH_INVALID` | The `@sourceType` directive must specify a valid URL template for `http.GET` or `http.POST` | 2.7.0 | |
107+
| `SOURCE_TYPE_ON_NON_OBJECT_OR_NON_ENTITY` | The `@sourceType` directive must be applied to an object or interface type that also has `@key` | 2.7.0 | |
108+
| `SOURCE_TYPE_PROTOCOL_INVALID` | The `@sourceType` directive must specify the same protocol as its corresponding `@sourceAPI` | 2.7.0 | |
109+
| `SOURCE_TYPE_SELECTION_INVALID` | The `selection` argument of the `@sourceType` directive must be a valid `JSONSelection` that outputs fields of the GraphQL type | 2.0.0 | |
92110
| `TYPE_DEFINITION_INVALID` | A built-in or federation type has an invalid definition in the schema. | 2.0.0 | |
93111
| `TYPE_KIND_MISMATCH` | A type has the same name in different subgraphs, but a different kind. For instance, one definition is an object type but another is an interface. | 2.0.0 | Replaces: `VALUE_TYPE_KIND_MISMATCH`, `EXTENSION_OF_WRONG_KIND`, `ENUM_MISMATCH_TYPE` |
94112
| `TYPE_WITH_ONLY_UNUSED_EXTERNAL` | A federation 1 schema has a composite type comprised only of unused external fields. Note that this error can _only_ be raised for federation 1 schema as federation 2 schema do not allow unused external fields (and errors with code EXTERNAL_UNUSED will be raised in that case). But when federation 1 schema are automatically migrated to federation 2 ones, unused external fields are automatically removed, and in rare case this can leave a type empty. If that happens, an error with this code will be raised | 2.0.0 | |

internals-js/src/error.ts

+125
Original file line numberDiff line numberDiff line change
@@ -555,6 +555,112 @@ const INTERFACE_KEY_MISSING_IMPLEMENTATION_TYPE = makeCodeDefinition(
555555
{ addedIn: '2.3.0' },
556556
)
557557

558+
const SOURCE_API_NAME_INVALID = makeCodeDefinition(
559+
'SOURCE_API_NAME_INVALID',
560+
'Each `@sourceAPI` directive must take a unique and valid name as an argument',
561+
{ addedIn: '2.7.0' },
562+
);
563+
564+
const SOURCE_API_PROTOCOL_INVALID = makeCodeDefinition(
565+
'SOURCE_API_PROTOCOL_INVALID',
566+
'Each `@sourceAPI` directive must specify exactly one of the known protocols',
567+
{ addedIn: '2.7.0' },
568+
);
569+
570+
const SOURCE_API_HTTP_BASE_URL_INVALID = makeCodeDefinition(
571+
'SOURCE_API_HTTP_BASE_URL_INVALID',
572+
'The `@sourceAPI` directive must specify a valid http.baseURL',
573+
{ addedIn: '2.7.0' },
574+
);
575+
576+
const SOURCE_HTTP_HEADERS_INVALID = makeCodeDefinition(
577+
'SOURCE_HTTP_HEADERS_INVALID',
578+
'The `http.headers` argument of `@source*` directives must specify valid HTTP headers',
579+
{ addedIn: '2.7.0' },
580+
);
581+
582+
const SOURCE_TYPE_API_ERROR = makeCodeDefinition(
583+
'SOURCE_TYPE_API_ERROR',
584+
'The `api` argument of the `@sourceType` directive must match a valid `@sourceAPI` name',
585+
{ addedIn: '2.7.0' },
586+
);
587+
588+
const SOURCE_TYPE_PROTOCOL_INVALID = makeCodeDefinition(
589+
'SOURCE_TYPE_PROTOCOL_INVALID',
590+
'The `@sourceType` directive must specify the same protocol as its corresponding `@sourceAPI`',
591+
{ addedIn: '2.7.0' },
592+
);
593+
594+
const SOURCE_TYPE_HTTP_METHOD_INVALID = makeCodeDefinition(
595+
'SOURCE_TYPE_HTTP_METHOD_INVALID',
596+
'The `@sourceType` directive must specify exactly one of `http.GET` or `http.POST`',
597+
{ addedIn: '2.7.0' },
598+
);
599+
600+
const SOURCE_TYPE_HTTP_PATH_INVALID = makeCodeDefinition(
601+
'SOURCE_TYPE_HTTP_PATH_INVALID',
602+
'The `@sourceType` directive must specify a valid URL template for `http.GET` or `http.POST`',
603+
{ addedIn: '2.7.0' },
604+
);
605+
606+
const SOURCE_TYPE_HTTP_BODY_INVALID = makeCodeDefinition(
607+
'SOURCE_TYPE_HTTP_BODY_INVALID',
608+
'If the `@sourceType` specifies `http.body`, it must be a valid `JSONSelection`',
609+
{ addedIn: '2.7.0' },
610+
);
611+
612+
const SOURCE_TYPE_ON_NON_OBJECT_OR_NON_ENTITY = makeCodeDefinition(
613+
'SOURCE_TYPE_ON_NON_OBJECT_OR_NON_ENTITY',
614+
'The `@sourceType` directive must be applied to an object or interface type that also has `@key`',
615+
{ addedIn: '2.7.0' },
616+
);
617+
618+
const SOURCE_TYPE_SELECTION_INVALID = makeCodeDefinition(
619+
'SOURCE_TYPE_SELECTION_INVALID',
620+
'The `selection` argument of the `@sourceType` directive must be a valid `JSONSelection` that outputs fields of the GraphQL type',
621+
);
622+
623+
const SOURCE_FIELD_API_ERROR = makeCodeDefinition(
624+
'SOURCE_FIELD_API_ERROR',
625+
'The `api` argument of the `@sourceField` directive must match a valid `@sourceAPI` name',
626+
{ addedIn: '2.7.0' },
627+
);
628+
629+
const SOURCE_FIELD_PROTOCOL_INVALID = makeCodeDefinition(
630+
'SOURCE_FIELD_PROTOCOL_INVALID',
631+
'If `@sourceField` specifies a protocol, it must match the corresponding `@sourceAPI` protocol',
632+
{ addedIn: '2.7.0' },
633+
);
634+
635+
const SOURCE_FIELD_HTTP_METHOD_INVALID = makeCodeDefinition(
636+
'SOURCE_FIELD_HTTP_METHOD_INVALID',
637+
'The `@sourceField` directive must specify at most one of `http.{GET,POST,PUT,PATCH,DELETE}`',
638+
{ addedIn: '2.7.0' },
639+
);
640+
641+
const SOURCE_FIELD_HTTP_PATH_INVALID = makeCodeDefinition(
642+
'SOURCE_FIELD_HTTP_PATH_INVALID',
643+
'The `@sourceField` directive must specify a valid URL template for `http.{GET,POST,PUT,PATCH,DELETE}`',
644+
{ addedIn: '2.7.0' },
645+
);
646+
647+
const SOURCE_FIELD_HTTP_BODY_INVALID = makeCodeDefinition(
648+
'SOURCE_FIELD_HTTP_BODY_INVALID',
649+
'If `@sourceField` specifies http.body, it must be a valid `JSONSelection` matching available arguments and fields',
650+
{ addedIn: '2.7.0' },
651+
);
652+
653+
const SOURCE_FIELD_SELECTION_INVALID = makeCodeDefinition(
654+
'SOURCE_FIELD_SELECTION_INVALID',
655+
'The `selection` argument of the `@sourceField` directive must be a valid `JSONSelection` that outputs fields of the GraphQL type',
656+
{ addedIn: '2.7.0' },
657+
);
658+
659+
const SOURCE_FIELD_NOT_ON_ROOT_OR_ENTITY_FIELD = makeCodeDefinition(
660+
'SOURCE_FIELD_NOT_ON_ROOT_OR_ENTITY_FIELD',
661+
'The `@sourceField` directive must be applied to a field of the `Query` or `Mutation` types, or of an entity type',
662+
{ addedIn: '2.7.0' },
663+
);
558664

559665
export const ERROR_CATEGORIES = {
560666
DIRECTIVE_FIELDS_MISSING_EXTERNAL,
@@ -643,6 +749,25 @@ export const ERRORS = {
643749
INTERFACE_OBJECT_USAGE_ERROR,
644750
INTERFACE_KEY_NOT_ON_IMPLEMENTATION,
645751
INTERFACE_KEY_MISSING_IMPLEMENTATION_TYPE,
752+
// Errors related to @sourceAPI, @sourceType, and/or @sourceField
753+
SOURCE_API_NAME_INVALID,
754+
SOURCE_API_PROTOCOL_INVALID,
755+
SOURCE_API_HTTP_BASE_URL_INVALID,
756+
SOURCE_HTTP_HEADERS_INVALID,
757+
SOURCE_TYPE_API_ERROR,
758+
SOURCE_TYPE_PROTOCOL_INVALID,
759+
SOURCE_TYPE_HTTP_METHOD_INVALID,
760+
SOURCE_TYPE_HTTP_PATH_INVALID,
761+
SOURCE_TYPE_HTTP_BODY_INVALID,
762+
SOURCE_TYPE_ON_NON_OBJECT_OR_NON_ENTITY,
763+
SOURCE_TYPE_SELECTION_INVALID,
764+
SOURCE_FIELD_API_ERROR,
765+
SOURCE_FIELD_PROTOCOL_INVALID,
766+
SOURCE_FIELD_HTTP_METHOD_INVALID,
767+
SOURCE_FIELD_HTTP_PATH_INVALID,
768+
SOURCE_FIELD_HTTP_BODY_INVALID,
769+
SOURCE_FIELD_SELECTION_INVALID,
770+
SOURCE_FIELD_NOT_ON_ROOT_OR_ENTITY_FIELD,
646771
};
647772

648773
const codeDefByCode = Object.values(ERRORS).reduce((obj: {[code: string]: ErrorCodeDefinition}, codeDef: ErrorCodeDefinition) => { obj[codeDef.code] = codeDef; return obj; }, {});

internals-js/src/federation.ts

+7-3
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ import {
8383
import { defaultPrintOptions, PrintOptions as PrintOptions, printSchema } from "./print";
8484
import { createObjectTypeSpecification, createScalarTypeSpecification, createUnionTypeSpecification } from "./directiveAndTypeSpecification";
8585
import { didYouMean, suggestionList } from "./suggestions";
86-
import { coreFeatureDefinitionIfKnown } from "./knownCoreFeatures";
86+
import { coreFeatureDefinitionIfKnown, validateKnownFeatures } from "./knownCoreFeatures";
8787
import { joinIdentity } from "./specs/joinSpec";
8888
import {
8989
SourceAPIDirectiveArgs,
@@ -583,8 +583,7 @@ export class FederationMetadata {
583583
private _fieldUsedPredicate?: (field: FieldDefinition<CompositeType>) => boolean;
584584
private _isFed2Schema?: boolean;
585585

586-
constructor(readonly schema: Schema) {
587-
}
586+
constructor(readonly schema: Schema) {}
588587

589588
private onInvalidate() {
590589
this._externalTester = undefined;
@@ -1081,6 +1080,11 @@ export class FederationBlueprint extends SchemaBlueprint {
10811080
validateKeyOnInterfacesAreAlsoOnAllImplementations(metadata, errorCollector);
10821081
validateInterfaceObjectsAreOnEntities(metadata, errorCollector);
10831082

1083+
// FeatureDefinition objects passed to registerKnownFeature can register
1084+
// validation functions for subgraph schemas by overriding the
1085+
// validateSubgraphSchema method.
1086+
validateKnownFeatures(schema, errorCollector);
1087+
10841088
// If tag is redefined by the user, make sure the definition is compatible with what we expect
10851089
const tagDirective = metadata.tagDirective();
10861090
if (tagDirective) {

internals-js/src/knownCoreFeatures.ts

+16-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
1+
import { GraphQLError } from "graphql";
2+
import { Schema } from "./definitions";
13
import { FeatureDefinition, FeatureDefinitions, FeatureUrl } from "./specs/coreSpec";
24

3-
const registeredFeatures: Map<string, FeatureDefinitions> = new Map();
5+
const registeredFeatures = new Map<string, FeatureDefinitions>();
46

57
export function registerKnownFeature(definitions: FeatureDefinitions) {
68
if (!registeredFeatures.has(definitions.identity)) {
@@ -12,6 +14,19 @@ export function coreFeatureDefinitionIfKnown(url: FeatureUrl): FeatureDefinition
1214
return registeredFeatures.get(url.identity)?.find(url.version);
1315
}
1416

17+
export function validateKnownFeatures(
18+
schema: Schema,
19+
errorCollector: GraphQLError[] = [],
20+
): GraphQLError[] {
21+
registeredFeatures.forEach(definitions => {
22+
const feature = definitions.latest();
23+
if (feature.validateSubgraphSchema !== FeatureDefinition.prototype.validateSubgraphSchema) {
24+
errorCollector.push(...feature.validateSubgraphSchema(schema));
25+
}
26+
});
27+
return errorCollector;
28+
}
29+
1530
/**
1631
* Removes a feature from the set of known features.
1732
*

internals-js/src/specs/coreSpec.ts

+5
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,11 @@ export abstract class FeatureDefinition {
117117
.concat(this.typeSpecs().map((spec) => spec.name));
118118
}
119119

120+
// No-op implementation that can be overridden by subclasses.
121+
validateSubgraphSchema(_schema: Schema): GraphQLError[] {
122+
return [];
123+
}
124+
120125
protected nameInSchema(schema: Schema): string | undefined {
121126
const feature = this.featureInSchema(schema);
122127
return feature?.nameInSchema;

0 commit comments

Comments
 (0)