diff --git a/.changeset/popular-mirrors-move.md b/.changeset/popular-mirrors-move.md new file mode 100644 index 000000000..84207ecb4 --- /dev/null +++ b/.changeset/popular-mirrors-move.md @@ -0,0 +1,6 @@ +--- +"@apollo/federation-internals": minor +"@apollo/composition": patch +--- + +Allow known `FeatureDefinition` subclasses to define custom subgraph schema validation rules diff --git a/composition-js/src/__tests__/compose.test.ts b/composition-js/src/__tests__/compose.test.ts index 61c6f53b4..94477630c 100644 --- a/composition-js/src/__tests__/compose.test.ts +++ b/composition-js/src/__tests__/compose.test.ts @@ -4961,4 +4961,147 @@ describe('@source* directives', () => { }` ) }); + + describe('validation errors', () => { + const goodSchema = gql` + extend schema + @link(url: "https://specs.apollo.dev/federation/v2.7", import: ["@key"]) + @link(url: "https://specs.apollo.dev/source/v0.1", import: [ + "@sourceAPI" + "@sourceType" + "@sourceField" + ]) + @sourceAPI( + name: "A" + http: { baseURL: "https://api.a.com/v1" } + ) + { + query: Query + } + + type Query { + resources: [Resource!]! @sourceField( + api: "A" + http: { GET: "/resources" } + ) + } + + type Resource @key(fields: "id") @sourceType( + api: "A" + http: { GET: "/resources/{id}" } + selection: "id description" + ) { + id: ID! + description: String! + } + `; + + const badSchema = gql` + extend schema + @link(url: "https://specs.apollo.dev/federation/v2.7", import: ["@key"]) + @link(url: "https://specs.apollo.dev/source/v0.1", import: [ + "@sourceAPI" + "@sourceType" + "@sourceField" + ]) + @sourceAPI( + name: "A?!" # Should be valid GraphQL identifier + http: { baseURL: "https://api.a.com/v1" } + ) + { + query: Query + } + + type Query { + resources: [Resource!]! @sourceField( + api: "A" + http: { GET: "/resources" } + ) + } + + type Resource @key(fields: "id") @sourceType( + api: "A" + http: { GET: "/resources/{id}" } + selection: "id description" + ) { + id: ID! + description: String! + } + `; + + it('good schema composes without validation errors', () => { + const result = composeServices([{ + name: 'good', + typeDefs: goodSchema, + }]); + expect(result.errors ?? []).toEqual([]); + }); + + it('bad schema composes with validation errors', () => { + const result = composeServices([{ + name: 'bad', + typeDefs: badSchema, + }]); + + const messages = result.errors!.map(e => e.message); + + expect(messages).toContain( + '[bad] @sourceAPI(name: "A?!") must specify valid GraphQL name' + ); + + expect(messages).toContain( + '[bad] @sourceType specifies unknown api A' + ); + + expect(messages).toContain( + '[bad] @sourceField specifies unknown api A' + ); + }); + + const renamedSchema = gql` + extend schema + @link(url: "https://specs.apollo.dev/federation/v2.7", import: ["@key"]) + @link(url: "https://specs.apollo.dev/source/v0.1", import: [ + { name: "@sourceAPI", as: "@api" } + { name: "@sourceType", as: "@type" } + { name: "@sourceField", as: "@field" } + ]) + @api( + name: "not an identifier" + http: { baseURL: "https://api.a.com/v1" } + ) + { + query: Query + } + + type Query { + resources: [Resource!]! @field( + api: "not an identifier" + http: { GET: "/resources" } + ) + } + + type Resource @key(fields: "id") @type( + api: "not an identifier" + http: { GET: "/resources/{id}" } + selection: "id description" + ) { + id: ID! + description: String! + } + `; + + it('can handle the @source* directives being renamed', () => { + const result = composeServices([{ + name: 'renamed', + typeDefs: renamedSchema, + }]); + + const messages = result.errors!.map(e => e.message); + + expect(messages).toContain( + '[renamed] @api(name: "not an identifier") must specify valid GraphQL name' + ); + }); + }); }); diff --git a/docs/source/errors.md b/docs/source/errors.md index 65af3446d..87b1e8ad5 100644 --- a/docs/source/errors.md +++ b/docs/source/errors.md @@ -89,6 +89,24 @@ The following errors might be raised during composition: | `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 | | | `SATISFIABILITY_ERROR` | Subgraphs can be merged, but the resulting supergraph API would have queries that cannot be satisfied by those subgraphs. | 2.0.0 | | | `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 | | +| `SOURCE_API_HTTP_BASE_URL_INVALID` | The `@sourceAPI` directive must specify a valid http.baseURL | 2.7.0 | | +| `SOURCE_API_NAME_INVALID` | Each `@sourceAPI` directive must take a unique and valid name as an argument | 2.7.0 | | +| `SOURCE_API_PROTOCOL_INVALID` | Each `@sourceAPI` directive must specify exactly one of the known protocols | 2.7.0 | | +| `SOURCE_FIELD_API_ERROR` | The `api` argument of the `@sourceField` directive must match a valid `@sourceAPI` name | 2.7.0 | | +| `SOURCE_FIELD_HTTP_BODY_INVALID` | If `@sourceField` specifies http.body, it must be a valid `JSONSelection` matching available arguments and fields | 2.7.0 | | +| `SOURCE_FIELD_HTTP_METHOD_INVALID` | The `@sourceField` directive must specify at most one of `http.{GET,POST,PUT,PATCH,DELETE}` | 2.7.0 | | +| `SOURCE_FIELD_HTTP_PATH_INVALID` | The `@sourceField` directive must specify a valid URL template for `http.{GET,POST,PUT,PATCH,DELETE}` | 2.7.0 | | +| `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 | | +| `SOURCE_FIELD_PROTOCOL_INVALID` | If `@sourceField` specifies a protocol, it must match the corresponding `@sourceAPI` protocol | 2.7.0 | | +| `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 | | +| `SOURCE_HTTP_HEADERS_INVALID` | The `http.headers` argument of `@source*` directives must specify valid HTTP headers | 2.7.0 | | +| `SOURCE_TYPE_API_ERROR` | The `api` argument of the `@sourceType` directive must match a valid `@sourceAPI` name | 2.7.0 | | +| `SOURCE_TYPE_HTTP_BODY_INVALID` | If the `@sourceType` specifies `http.body`, it must be a valid `JSONSelection` | 2.7.0 | | +| `SOURCE_TYPE_HTTP_METHOD_INVALID` | The `@sourceType` directive must specify exactly one of `http.GET` or `http.POST` | 2.7.0 | | +| `SOURCE_TYPE_HTTP_PATH_INVALID` | The `@sourceType` directive must specify a valid URL template for `http.GET` or `http.POST` | 2.7.0 | | +| `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 | | +| `SOURCE_TYPE_PROTOCOL_INVALID` | The `@sourceType` directive must specify the same protocol as its corresponding `@sourceAPI` | 2.7.0 | | +| `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 | | | `TYPE_DEFINITION_INVALID` | A built-in or federation type has an invalid definition in the schema. | 2.0.0 | | | `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` | | `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 | | diff --git a/internals-js/src/error.ts b/internals-js/src/error.ts index 43e9be7c1..25897c14b 100644 --- a/internals-js/src/error.ts +++ b/internals-js/src/error.ts @@ -555,6 +555,112 @@ const INTERFACE_KEY_MISSING_IMPLEMENTATION_TYPE = makeCodeDefinition( { addedIn: '2.3.0' }, ) +const SOURCE_API_NAME_INVALID = makeCodeDefinition( + 'SOURCE_API_NAME_INVALID', + 'Each `@sourceAPI` directive must take a unique and valid name as an argument', + { addedIn: '2.7.0' }, +); + +const SOURCE_API_PROTOCOL_INVALID = makeCodeDefinition( + 'SOURCE_API_PROTOCOL_INVALID', + 'Each `@sourceAPI` directive must specify exactly one of the known protocols', + { addedIn: '2.7.0' }, +); + +const SOURCE_API_HTTP_BASE_URL_INVALID = makeCodeDefinition( + 'SOURCE_API_HTTP_BASE_URL_INVALID', + 'The `@sourceAPI` directive must specify a valid http.baseURL', + { addedIn: '2.7.0' }, +); + +const SOURCE_HTTP_HEADERS_INVALID = makeCodeDefinition( + 'SOURCE_HTTP_HEADERS_INVALID', + 'The `http.headers` argument of `@source*` directives must specify valid HTTP headers', + { addedIn: '2.7.0' }, +); + +const SOURCE_TYPE_API_ERROR = makeCodeDefinition( + 'SOURCE_TYPE_API_ERROR', + 'The `api` argument of the `@sourceType` directive must match a valid `@sourceAPI` name', + { addedIn: '2.7.0' }, +); + +const SOURCE_TYPE_PROTOCOL_INVALID = makeCodeDefinition( + 'SOURCE_TYPE_PROTOCOL_INVALID', + 'The `@sourceType` directive must specify the same protocol as its corresponding `@sourceAPI`', + { addedIn: '2.7.0' }, +); + +const SOURCE_TYPE_HTTP_METHOD_INVALID = makeCodeDefinition( + 'SOURCE_TYPE_HTTP_METHOD_INVALID', + 'The `@sourceType` directive must specify exactly one of `http.GET` or `http.POST`', + { addedIn: '2.7.0' }, +); + +const SOURCE_TYPE_HTTP_PATH_INVALID = makeCodeDefinition( + 'SOURCE_TYPE_HTTP_PATH_INVALID', + 'The `@sourceType` directive must specify a valid URL template for `http.GET` or `http.POST`', + { addedIn: '2.7.0' }, +); + +const SOURCE_TYPE_HTTP_BODY_INVALID = makeCodeDefinition( + 'SOURCE_TYPE_HTTP_BODY_INVALID', + 'If the `@sourceType` specifies `http.body`, it must be a valid `JSONSelection`', + { addedIn: '2.7.0' }, +); + +const SOURCE_TYPE_ON_NON_OBJECT_OR_NON_ENTITY = makeCodeDefinition( + 'SOURCE_TYPE_ON_NON_OBJECT_OR_NON_ENTITY', + 'The `@sourceType` directive must be applied to an object or interface type that also has `@key`', + { addedIn: '2.7.0' }, +); + +const SOURCE_TYPE_SELECTION_INVALID = makeCodeDefinition( + 'SOURCE_TYPE_SELECTION_INVALID', + 'The `selection` argument of the `@sourceType` directive must be a valid `JSONSelection` that outputs fields of the GraphQL type', +); + +const SOURCE_FIELD_API_ERROR = makeCodeDefinition( + 'SOURCE_FIELD_API_ERROR', + 'The `api` argument of the `@sourceField` directive must match a valid `@sourceAPI` name', + { addedIn: '2.7.0' }, +); + +const SOURCE_FIELD_PROTOCOL_INVALID = makeCodeDefinition( + 'SOURCE_FIELD_PROTOCOL_INVALID', + 'If `@sourceField` specifies a protocol, it must match the corresponding `@sourceAPI` protocol', + { addedIn: '2.7.0' }, +); + +const SOURCE_FIELD_HTTP_METHOD_INVALID = makeCodeDefinition( + 'SOURCE_FIELD_HTTP_METHOD_INVALID', + 'The `@sourceField` directive must specify at most one of `http.{GET,POST,PUT,PATCH,DELETE}`', + { addedIn: '2.7.0' }, +); + +const SOURCE_FIELD_HTTP_PATH_INVALID = makeCodeDefinition( + 'SOURCE_FIELD_HTTP_PATH_INVALID', + 'The `@sourceField` directive must specify a valid URL template for `http.{GET,POST,PUT,PATCH,DELETE}`', + { addedIn: '2.7.0' }, +); + +const SOURCE_FIELD_HTTP_BODY_INVALID = makeCodeDefinition( + 'SOURCE_FIELD_HTTP_BODY_INVALID', + 'If `@sourceField` specifies http.body, it must be a valid `JSONSelection` matching available arguments and fields', + { addedIn: '2.7.0' }, +); + +const SOURCE_FIELD_SELECTION_INVALID = makeCodeDefinition( + 'SOURCE_FIELD_SELECTION_INVALID', + 'The `selection` argument of the `@sourceField` directive must be a valid `JSONSelection` that outputs fields of the GraphQL type', + { addedIn: '2.7.0' }, +); + +const SOURCE_FIELD_NOT_ON_ROOT_OR_ENTITY_FIELD = makeCodeDefinition( + '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', + { addedIn: '2.7.0' }, +); export const ERROR_CATEGORIES = { DIRECTIVE_FIELDS_MISSING_EXTERNAL, @@ -643,6 +749,25 @@ export const ERRORS = { INTERFACE_OBJECT_USAGE_ERROR, INTERFACE_KEY_NOT_ON_IMPLEMENTATION, INTERFACE_KEY_MISSING_IMPLEMENTATION_TYPE, + // Errors related to @sourceAPI, @sourceType, and/or @sourceField + SOURCE_API_NAME_INVALID, + SOURCE_API_PROTOCOL_INVALID, + SOURCE_API_HTTP_BASE_URL_INVALID, + SOURCE_HTTP_HEADERS_INVALID, + SOURCE_TYPE_API_ERROR, + SOURCE_TYPE_PROTOCOL_INVALID, + SOURCE_TYPE_HTTP_METHOD_INVALID, + SOURCE_TYPE_HTTP_PATH_INVALID, + SOURCE_TYPE_HTTP_BODY_INVALID, + SOURCE_TYPE_ON_NON_OBJECT_OR_NON_ENTITY, + SOURCE_TYPE_SELECTION_INVALID, + SOURCE_FIELD_API_ERROR, + SOURCE_FIELD_PROTOCOL_INVALID, + SOURCE_FIELD_HTTP_METHOD_INVALID, + SOURCE_FIELD_HTTP_PATH_INVALID, + SOURCE_FIELD_HTTP_BODY_INVALID, + SOURCE_FIELD_SELECTION_INVALID, + SOURCE_FIELD_NOT_ON_ROOT_OR_ENTITY_FIELD, }; const codeDefByCode = Object.values(ERRORS).reduce((obj: {[code: string]: ErrorCodeDefinition}, codeDef: ErrorCodeDefinition) => { obj[codeDef.code] = codeDef; return obj; }, {}); diff --git a/internals-js/src/federation.ts b/internals-js/src/federation.ts index d5af52634..1ab5b0d11 100644 --- a/internals-js/src/federation.ts +++ b/internals-js/src/federation.ts @@ -83,7 +83,7 @@ import { import { defaultPrintOptions, PrintOptions as PrintOptions, printSchema } from "./print"; import { createObjectTypeSpecification, createScalarTypeSpecification, createUnionTypeSpecification } from "./directiveAndTypeSpecification"; import { didYouMean, suggestionList } from "./suggestions"; -import { coreFeatureDefinitionIfKnown } from "./knownCoreFeatures"; +import { coreFeatureDefinitionIfKnown, validateKnownFeatures } from "./knownCoreFeatures"; import { joinIdentity } from "./specs/joinSpec"; import { SourceAPIDirectiveArgs, @@ -583,8 +583,7 @@ export class FederationMetadata { private _fieldUsedPredicate?: (field: FieldDefinition) => boolean; private _isFed2Schema?: boolean; - constructor(readonly schema: Schema) { - } + constructor(readonly schema: Schema) {} private onInvalidate() { this._externalTester = undefined; @@ -1081,6 +1080,11 @@ export class FederationBlueprint extends SchemaBlueprint { validateKeyOnInterfacesAreAlsoOnAllImplementations(metadata, errorCollector); validateInterfaceObjectsAreOnEntities(metadata, errorCollector); + // FeatureDefinition objects passed to registerKnownFeature can register + // validation functions for subgraph schemas by overriding the + // validateSubgraphSchema method. + validateKnownFeatures(schema, errorCollector); + // If tag is redefined by the user, make sure the definition is compatible with what we expect const tagDirective = metadata.tagDirective(); if (tagDirective) { diff --git a/internals-js/src/knownCoreFeatures.ts b/internals-js/src/knownCoreFeatures.ts index 55faef616..83bb3ceaf 100644 --- a/internals-js/src/knownCoreFeatures.ts +++ b/internals-js/src/knownCoreFeatures.ts @@ -1,6 +1,8 @@ +import { GraphQLError } from "graphql"; +import { Schema } from "./definitions"; import { FeatureDefinition, FeatureDefinitions, FeatureUrl } from "./specs/coreSpec"; -const registeredFeatures: Map = new Map(); +const registeredFeatures = new Map(); export function registerKnownFeature(definitions: FeatureDefinitions) { if (!registeredFeatures.has(definitions.identity)) { @@ -12,6 +14,19 @@ export function coreFeatureDefinitionIfKnown(url: FeatureUrl): FeatureDefinition return registeredFeatures.get(url.identity)?.find(url.version); } +export function validateKnownFeatures( + schema: Schema, + errorCollector: GraphQLError[] = [], +): GraphQLError[] { + registeredFeatures.forEach(definitions => { + const feature = definitions.latest(); + if (feature.validateSubgraphSchema !== FeatureDefinition.prototype.validateSubgraphSchema) { + errorCollector.push(...feature.validateSubgraphSchema(schema)); + } + }); + return errorCollector; +} + /** * Removes a feature from the set of known features. * diff --git a/internals-js/src/specs/coreSpec.ts b/internals-js/src/specs/coreSpec.ts index 95ac988da..3c2efef96 100644 --- a/internals-js/src/specs/coreSpec.ts +++ b/internals-js/src/specs/coreSpec.ts @@ -117,6 +117,11 @@ export abstract class FeatureDefinition { .concat(this.typeSpecs().map((spec) => spec.name)); } + // No-op implementation that can be overridden by subclasses. + validateSubgraphSchema(_schema: Schema): GraphQLError[] { + return []; + } + protected nameInSchema(schema: Schema): string | undefined { const feature = this.featureInSchema(schema); return feature?.nameInSchema; diff --git a/internals-js/src/specs/sourceSpec.ts b/internals-js/src/specs/sourceSpec.ts index 78c807318..31765a5fd 100644 --- a/internals-js/src/specs/sourceSpec.ts +++ b/internals-js/src/specs/sourceSpec.ts @@ -1,14 +1,17 @@ -import { DirectiveLocation, GraphQLError } from 'graphql'; -import { FeatureDefinition, FeatureDefinitions, FeatureUrl, FeatureVersion } from "./coreSpec"; +import { DirectiveLocation, GraphQLError, assertName } from 'graphql'; +import { FeatureDefinition, FeatureDefinitions, FeatureUrl, FeatureVersion, LinkDirectiveArgs } from "./coreSpec"; import { Schema, NonNullType, InputObjectType, InputFieldDefinition, ListType, + DirectiveDefinition, + SchemaElement, } from '../definitions'; import { registerKnownFeature } from '../knownCoreFeatures'; import { createDirectiveSpecification } from '../directiveAndTypeSpecification'; +import { ERRORS } from '../error'; export const sourceIdentity = 'https://specs.apollo.dev/source'; @@ -79,6 +82,8 @@ export class SourceSpecDefinition extends FeatureDefinition { HTTPSourceType.addField(new InputFieldDefinition('POST')).type = URLPathTemplate; HTTPSourceType.addField(new InputFieldDefinition('headers')).type = new ListType(new NonNullType(HTTPHeaderMapping)); + // Note that this body selection can only use @key fields of the type, + // because there are no field arguments to consume with @sourceType. HTTPSourceType.addField(new InputFieldDefinition('body')).type = JSONSelection; sourceType.addArgument('http', HTTPSourceType); @@ -98,6 +103,7 @@ export class SourceSpecDefinition extends FeatureDefinition { sourceField.repeatable = true; sourceField.addArgument('api', new NonNullType(schema.stringType())); sourceField.addArgument('selection', JSONSelection); + sourceField.addArgument('keyTypeMap', KeyTypeMap); const HTTPSourceField = schema.addType(new InputObjectType('HTTPSourceField')); HTTPSourceField.addField(new InputFieldDefinition('GET')).type = URLPathTemplate; @@ -140,8 +146,395 @@ export class SourceSpecDefinition extends FeatureDefinition { sourceFieldDirective(schema: Schema) { return this.directive(schema, 'sourceField')!; } + + private getSourceDirectives(schema: Schema) { + const result: { + sourceAPI?: DirectiveDefinition; + sourceType?: DirectiveDefinition; + sourceField?: DirectiveDefinition; + } = {}; + + schema.schemaDefinition.appliedDirectivesOf('link') + .forEach(linkDirective => { + const { url, import: imports } = linkDirective.arguments(); + if (imports && FeatureUrl.maybeParse(url)?.identity === sourceIdentity) { + imports.forEach(nameOrRename => { + const originalName = typeof nameOrRename === 'string' ? nameOrRename : nameOrRename.name; + const importedName = typeof nameOrRename === 'string' ? nameOrRename : nameOrRename.as || originalName; + const importedNameWithoutAt = importedName.replace(/^@/, ''); + + if (originalName === '@sourceAPI') { + result.sourceAPI = schema.directive(importedNameWithoutAt) as DirectiveDefinition; + } else if (originalName === '@sourceType') { + result.sourceType = schema.directive(importedNameWithoutAt) as DirectiveDefinition; + } else if (originalName === '@sourceField') { + result.sourceField = schema.directive(importedNameWithoutAt) as DirectiveDefinition; + } + }); + } + }); + + return result; + } + + override validateSubgraphSchema(schema: Schema): GraphQLError[] { + const { + sourceAPI, + sourceType, + sourceField, + } = this.getSourceDirectives(schema); + + if (!(sourceAPI || sourceType || sourceField)) { + // If none of the @source* directives are present, nothing needs + // validating. + return []; + } + + const apiNameToProtocol = new Map(); + const errors: GraphQLError[] = []; + + if (sourceAPI) { + this.validateSourceAPI(sourceAPI, apiNameToProtocol, errors); + } + + if (sourceType) { + this.validateSourceType(sourceType, apiNameToProtocol, errors); + } + + if (sourceField) { + this.validateSourceField(sourceField, apiNameToProtocol, errors); + } + + return errors; + } + + private validateSourceAPI( + sourceAPI: DirectiveDefinition, + apiNameToProtocol: Map, + errors: GraphQLError[], + ) { + sourceAPI.applications().forEach(application => { + const { name, ...rest } = application.arguments(); + + if (apiNameToProtocol.has(name)) { + errors.push(ERRORS.SOURCE_API_NAME_INVALID.err( + `${sourceAPI} must specify unique name`, + { nodes: application.sourceAST }, + )); + } + + try { + assertName(name); + } catch (e) { + errors.push(ERRORS.SOURCE_API_NAME_INVALID.err( + `${sourceAPI}(name: ${ + JSON.stringify(name) + }) must specify valid GraphQL name`, + { nodes: application.sourceAST }, + )); + } + + let protocol: ProtocolName | undefined; + KNOWN_SOURCE_PROTOCOLS.forEach(knownProtocol => { + if (rest[knownProtocol]) { + if (protocol) { + errors.push(ERRORS.SOURCE_API_PROTOCOL_INVALID.err( + `${sourceAPI} must specify only one of ${KNOWN_SOURCE_PROTOCOLS.join(', ')}`, + { nodes: application.sourceAST }, + )); + } + protocol = knownProtocol; + } + }); + + if (protocol) { + apiNameToProtocol.set(name, protocol); + + const protocolValue = rest[protocol]; + if (protocolValue && protocol === HTTP_PROTOCOL) { + const { baseURL, headers } = protocolValue as HTTPSourceAPI; + + try { + new URL(baseURL); + } catch (e) { + errors.push(ERRORS.SOURCE_API_HTTP_BASE_URL_INVALID.err( + `${sourceAPI} http.baseURL ${JSON.stringify(baseURL)} must be valid URL`, + { nodes: application.sourceAST }, + )); + } + + validateHTTPHeaders(headers, errors, sourceAPI.name); + } + } else { + errors.push(ERRORS.SOURCE_API_PROTOCOL_INVALID.err( + `${sourceAPI} must specify one of ${KNOWN_SOURCE_PROTOCOLS.join(', ')}`, + { nodes: application.sourceAST }, + )); + } + }); + } + + private validateSourceType( + sourceType: DirectiveDefinition, + apiNameToProtocol: Map, + errors: GraphQLError[], + ) { + sourceType.applications().forEach(application => { + const { api, selection, ...rest } = application.arguments(); + if (!api || !apiNameToProtocol.has(api)) { + errors.push(ERRORS.SOURCE_TYPE_API_ERROR.err( + `${sourceType} specifies unknown api ${api}`, + { nodes: application.sourceAST }, + )); + } else { + const expectedProtocol = apiNameToProtocol.get(api); + const protocolValue = expectedProtocol && rest[expectedProtocol]; + if (expectedProtocol && !protocolValue) { + errors.push(ERRORS.SOURCE_TYPE_API_ERROR.err( + `${sourceType} must specify same ${ + expectedProtocol + } argument as corresponding @sourceAPI for api ${api}`, + { nodes: application.sourceAST }, + )); + } + + if (protocolValue && expectedProtocol === HTTP_PROTOCOL) { + const { GET, POST, headers, body } = protocolValue as HTTPSourceType; + + if ([GET, POST].filter(Boolean).length !== 1) { + errors.push(ERRORS.SOURCE_TYPE_HTTP_METHOD_INVALID.err( + `${sourceType} must specify exactly one of http.GET or http.POST`, + { nodes: application.sourceAST }, + )); + } else { + const urlPathTemplate = (GET || POST)!; + try { + // TODO Validate URL path template uses only available @key fields + // of the type. + parseURLPathTemplate(urlPathTemplate); + } catch (e) { + errors.push(ERRORS.SOURCE_TYPE_HTTP_PATH_INVALID.err( + `${sourceType} http.GET or http.POST must be valid URL path template` + )); + } + } + + validateHTTPHeaders(headers, errors, sourceType.name); + + if (body) { + if (GET) { + errors.push(ERRORS.SOURCE_TYPE_HTTP_BODY_INVALID.err( + `${sourceType} http.GET cannot specify http.body`, + { nodes: application.sourceAST }, + )); + } + + try { + parseJSONSelection(body); + // TODO Validate body selection matches the available fields. + } catch (e) { + errors.push(ERRORS.SOURCE_TYPE_HTTP_BODY_INVALID.err( + `${sourceType} http.body not valid JSONSelection: ${e.message}`, + { nodes: application.sourceAST }, + )); + } + } + } + } + + const ast = application.parent.sourceAST; + switch (ast?.kind) { + case "ObjectTypeDefinition": + case "InterfaceTypeDefinition": + if (!ast.directives?.some(directive => directive.name.value === "key")) { + errors.push(ERRORS.SOURCE_TYPE_ON_NON_OBJECT_OR_NON_ENTITY.err( + `${sourceType} must be applied to an entity type that also has a @key directive`, + { nodes: application.sourceAST }, + )); + } + try { + parseJSONSelection(selection); + // TODO Validate selection is valid JSONSelection for type. + } catch (e) { + errors.push(ERRORS.SOURCE_TYPE_SELECTION_INVALID.err( + `${sourceType} selection not valid JSONSelection: ${e.message}`, + { nodes: application.sourceAST }, + )); + } + break; + default: + errors.push(ERRORS.SOURCE_TYPE_ON_NON_OBJECT_OR_NON_ENTITY.err( + `${sourceType} must be applied to object or interface type`, + { nodes: application.sourceAST }, + )); + } + }); + } + + private validateSourceField( + sourceField: DirectiveDefinition, + apiNameToProtocol: Map, + errors: GraphQLError[], + ) { + sourceField.applications().forEach(application => { + const { api, selection, ...rest } = application.arguments(); + if (!api || !apiNameToProtocol.has(api)) { + errors.push(ERRORS.SOURCE_FIELD_API_ERROR.err( + `${sourceField} specifies unknown api ${api}`, + { nodes: application.sourceAST }, + )); + } else { + const expectedProtocol = apiNameToProtocol.get(api); + const protocolValue = expectedProtocol && rest[expectedProtocol]; + if (protocolValue && expectedProtocol === HTTP_PROTOCOL) { + const { + GET, POST, PUT, PATCH, DELETE, + headers, + body, + } = protocolValue as HTTPSourceField; + + const usedMethods = [GET, POST, PUT, PATCH, DELETE].filter(Boolean); + if (usedMethods.length > 1) { + errors.push(ERRORS.SOURCE_FIELD_HTTP_METHOD_INVALID.err( + `${sourceField} allows at most one of http.{GET,POST,PUT,PATCH,DELETE}`, + )); + } else if (usedMethods.length === 1) { + const urlPathTemplate = usedMethods[0]!; + try { + // TODO Validate URL path template uses only available fields of + // the type and/or argument names of the field. + parseURLPathTemplate(urlPathTemplate); + } catch (e) { + errors.push(ERRORS.SOURCE_FIELD_HTTP_PATH_INVALID.err( + `${sourceField} http.{GET,POST,PUT,PATCH,DELETE} must be valid URL path template` + )); + } + } + + validateHTTPHeaders(headers, errors, sourceField.name); + + if (body) { + if (GET) { + errors.push(ERRORS.SOURCE_FIELD_HTTP_BODY_INVALID.err( + `${sourceField} http.GET cannot specify http.body`, + { nodes: application.sourceAST }, + )); + } else if (DELETE) { + errors.push(ERRORS.SOURCE_FIELD_HTTP_BODY_INVALID.err( + `${sourceField} http.DELETE cannot specify http.body`, + { nodes: application.sourceAST }, + )); + } + + try { + parseJSONSelection(body); + // TODO Validate body string matches the available fields of the + // parent type and/or argument names of the field. + } catch (e) { + errors.push(ERRORS.SOURCE_FIELD_HTTP_BODY_INVALID.err( + `${sourceField} http.body not valid JSONSelection: ${e.message}`, + { nodes: application.sourceAST }, + )); + } + } + } + } + + if (selection) { + try { + parseJSONSelection(selection); + // TODO Validate selection string matches the available fields of + // the parent type and/or argument names of the field. + } catch (e) { + errors.push(ERRORS.SOURCE_FIELD_SELECTION_INVALID.err( + `${sourceField} selection not valid JSONSelection: ${e.message}`, + { nodes: application.sourceAST }, + )); + } + } + + // @sourceField is allowed only on root Query and Mutation fields or + // fields of entity object types. + const fieldParent = application.parent; + if (fieldParent.sourceAST?.kind !== "FieldDefinition") { + errors.push(ERRORS.SOURCE_FIELD_NOT_ON_ROOT_OR_ENTITY_FIELD.err( + `${sourceField} must be applied to field`, + { nodes: application.sourceAST }, + )); + } else { + const typeGrandparent = fieldParent.parent as SchemaElement; + if (typeGrandparent.sourceAST?.kind !== "ObjectTypeDefinition") { + errors.push(ERRORS.SOURCE_FIELD_NOT_ON_ROOT_OR_ENTITY_FIELD.err( + `${sourceField} must be applied to field of object type`, + { nodes: application.sourceAST }, + )); + } else { + const typeGrandparentName = typeGrandparent.sourceAST?.name.value; + if ( + typeGrandparentName !== "Query" && + typeGrandparentName !== "Mutation" && + typeGrandparent.appliedDirectivesOf("key").length === 0 + ) { + errors.push(ERRORS.SOURCE_FIELD_NOT_ON_ROOT_OR_ENTITY_FIELD.err( + `${sourceField} must be applied to root Query or Mutation field or field of entity type`, + { nodes: application.sourceAST }, + )); + } + } + } + }); + } +} + +function isValidHTTPHeaderName(name: string): boolean { + // https://developers.cloudflare.com/rules/transform/request-header-modification/reference/header-format/ + return /^[a-zA-Z0-9-_]+$/.test(name); +} + +function validateHTTPHeaders( + headers: HTTPHeaderMapping[] | undefined, + errors: GraphQLError[], + directiveName: string, +) { + if (!directiveName.startsWith('@')) { + directiveName = '@' + directiveName; + } + if (headers) { + headers.forEach(({ name, as, value }, i) => { + // Ensure name is a valid HTTP header name. + if (!isValidHTTPHeaderName(name)) { + errors.push(ERRORS.SOURCE_HTTP_HEADERS_INVALID.err( + `${directiveName} headers[${i}].name == ${ + JSON.stringify(name) + } is not valid HTTP header name`, + )); + } + + if (!as === !value) { + errors.push(ERRORS.SOURCE_HTTP_HEADERS_INVALID.err( + `${directiveName} headers[${i}] must specify exactly one of as or value`, + )); + } + + // TODO Validate value is valid HTTP header value? + }); + } +} + +function parseJSONSelection(_selection: string): any { + // TODO } +function parseURLPathTemplate(_template: string): any { + // TODO +} + +const HTTP_PROTOCOL = "http"; +const KNOWN_SOURCE_PROTOCOLS = [ + HTTP_PROTOCOL, +] as const; +type ProtocolName = (typeof KNOWN_SOURCE_PROTOCOLS)[number]; + export type SourceAPIDirectiveArgs = { name: string; http?: HTTPSourceAPI; @@ -186,6 +579,7 @@ export type SourceFieldDirectiveArgs = { api: string; http?: HTTPSourceField; selection?: JSONSelection; + keyTypeMap?: KeyTypeMap; }; export type HTTPSourceField = {