diff --git a/.changeset/real-comics-stare.md b/.changeset/real-comics-stare.md new file mode 100644 index 000000000..0f5450391 --- /dev/null +++ b/.changeset/real-comics-stare.md @@ -0,0 +1,6 @@ +--- +"@apollo/composition": minor +"@apollo/federation-internals": minor +--- + +Support `@join__directive(graphs, name, args)` directives diff --git a/.cspell/cspell-dict.txt b/.cspell/cspell-dict.txt index 3de941c55..74a1a251c 100644 --- a/.cspell/cspell-dict.txt +++ b/.cspell/cspell-dict.txt @@ -220,6 +220,7 @@ subgpraph subgrahs SUBGRAPHA SUBGRAPHB +SUBGRAPHC subraph subraphs Substrat diff --git a/composition-js/src/__tests__/__snapshots__/compose.composeDirective.test.ts.snap b/composition-js/src/__tests__/__snapshots__/compose.composeDirective.test.ts.snap index 6d651f4ec..305e7fa2b 100644 --- a/composition-js/src/__tests__/__snapshots__/compose.composeDirective.test.ts.snap +++ b/composition-js/src/__tests__/__snapshots__/compose.composeDirective.test.ts.snap @@ -24,6 +24,8 @@ directive @join__unionMember(graph: join__Graph!, member: String!) repeatable on directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE +directive @join__directive(graphs: [join__Graph!], name: String!, args: join__DirectiveArguments) repeatable on SCHEMA | OBJECT | INTERFACE | FIELD_DEFINITION + directive @mytag(name: String!) repeatable on FIELD_DEFINITION | OBJECT | INTERFACE | UNION | ARGUMENT_DEFINITION | SCALAR | ENUM | ENUM_VALUE | INPUT_OBJECT | INPUT_FIELD_DEFINITION | SCHEMA directive @tag(name: String!, prop: String!) on FIELD_DEFINITION | OBJECT @@ -49,6 +51,8 @@ enum join__Graph { scalar join__FieldSet +scalar join__DirectiveArguments + type Query @join__type(graph: SUBGRAPHA) @join__type(graph: SUBGRAPHB) diff --git a/composition-js/src/__tests__/compose.test.ts b/composition-js/src/__tests__/compose.test.ts index a63609d74..d5efc9b6e 100644 --- a/composition-js/src/__tests__/compose.test.ts +++ b/composition-js/src/__tests__/compose.test.ts @@ -76,6 +76,8 @@ describe('composition', () => { query: Query } + directive @join__directive(graphs: [join__Graph!], name: String!, args: join__DirectiveArguments) repeatable on SCHEMA | OBJECT | INTERFACE | FIELD_DEFINITION + directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE directive @join__field(graph: join__Graph, requires: join__FieldSet, provides: join__FieldSet, type: String, external: Boolean, override: String, usedOverridden: Boolean) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION @@ -97,6 +99,8 @@ describe('composition', () => { V2 @join__enumValue(graph: SUBGRAPH2) } + scalar join__DirectiveArguments + scalar join__FieldSet enum join__Graph { @@ -224,6 +228,8 @@ describe('composition', () => { query: Query } + directive @join__directive(graphs: [join__Graph!], name: String!, args: join__DirectiveArguments) repeatable on SCHEMA | OBJECT | INTERFACE | FIELD_DEFINITION + directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE directive @join__field(graph: join__Graph, requires: join__FieldSet, provides: join__FieldSet, type: String, external: Boolean, override: String, usedOverridden: Boolean) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION @@ -245,6 +251,8 @@ describe('composition', () => { V2 @join__enumValue(graph: SUBGRAPH2) } + scalar join__DirectiveArguments + scalar join__FieldSet enum join__Graph { @@ -2489,6 +2497,8 @@ describe('composition', () => { query: Query } + directive @join__directive(graphs: [join__Graph!], name: String!, args: join__DirectiveArguments) repeatable on SCHEMA | OBJECT | INTERFACE | FIELD_DEFINITION + directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE directive @join__field(graph: join__Graph, requires: join__FieldSet, provides: join__FieldSet, type: String, external: Boolean, override: String, usedOverridden: Boolean) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION @@ -2503,6 +2513,8 @@ describe('composition', () => { directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA + scalar join__DirectiveArguments + scalar join__FieldSet enum join__Graph { @@ -4631,3 +4643,326 @@ describe('composition', () => { expect(authenticatedDirectiveExists).toBeUndefined(); }); }); + +describe('@source* directives', () => { + const schemaA = gql` + extend schema + @link(url: "https://specs.apollo.dev/federation/v2.1", import: [ + "@key" + "@shareable" + ]) + @link(url: "https://specs.apollo.dev/source/v0.1", import: [ + "@sourceAPI" + "@sourceType" + "@sourceField" + ]) + @sourceAPI( + name: "A" + http: { baseURL: "https://api.a.com/v1" } + ) + + type Query { + resources: [Resource!]! @sourceField( + api: "A" + http: { GET: "/resources" } + ) @shareable + } + + type Resource @key(fields: "id") @sourceType( + api: "A" + http: { GET: "/resources/{id}" } + selection: "id description" + ) { + id: ID! + description: String! + } + `; + + const schemaB = gql` + extend schema + @link(url: "https://specs.apollo.dev/federation/v2.1", import: [ + "@key" + "@shareable" + ]) + @link(url: "https://specs.apollo.dev/source/v0.1", import: [ + "@sourceAPI" + "@sourceType" + "@sourceField" + ]) + @sourceAPI( + name: "A" + http: { baseURL: "https://api.a.com/v1" } + ) + + type Query { + resources: [Resource!]! @sourceField( + api: "A" + http: { GET: "/resources" } + ) @shareable + } + + type Resource @key(fields: "id") { + id: ID! + } + `; + + const schemaC = gql` + extend schema + @link(url: "https://specs.apollo.dev/federation/v2.1", import: [ + "@key" + "@shareable" + ]) + @link(url: "https://specs.apollo.dev/source/v0.1", import: [ + "@sourceAPI" + "@sourceType" + "@sourceField" + ]) + @sourceAPI( + name: "A" + http: { baseURL: "https://api.a.com/v1" } + ) + + type Resource @key(fields: "id") @sourceType( + api: "A" + http: { GET: "/resources/{id}" } + selection: "id creationDate" + ) { + id: ID! + creationDate: String! + } + `; + + it('single subgraph composition', () => { + const subgraphA = { + name: 'subgraphA', + typeDefs: schemaA, + }; + const result = composeServices([subgraphA]); + expect(result.errors ?? []).toEqual([]); + const printed = printSchema(result.schema!); + expect(printed).toContain( +`schema + @link(url: "https://specs.apollo.dev/link/v1.0") + @link(url: "https://specs.apollo.dev/join/v0.3", for: EXECUTION) + @join__directive(graphs: [SUBGRAPHA], name: "link", args: {url: "https://specs.apollo.dev/source/v0.1", import: ["@sourceAPI", "@sourceType", "@sourceField"]}) + @join__directive(graphs: [SUBGRAPHA], name: "sourceAPI", args: {name: "A", http: {baseURL: "https://api.a.com/v1"}}) +{ + query: Query +}`); + + expect(printed).toContain( + `directive @join__directive(graphs: [join__Graph!], name: String!, args: join__DirectiveArguments) repeatable on SCHEMA | OBJECT | INTERFACE | FIELD_DEFINITION` + ); + + expect(printed).toContain( +`type Query + @join__type(graph: SUBGRAPHA) +{ + resources: [Resource!]! @join__directive(graphs: [SUBGRAPHA], name: "sourceField", args: {api: "A", http: {GET: "/resources"}}) +}` + ); + + expect(printed).toContain( +`type Resource + @join__type(graph: SUBGRAPHA, key: "id") + @join__directive(graphs: [SUBGRAPHA], name: "sourceType", args: {api: "A", http: {GET: "/resources/{id}"}, selection: "id description"}) +{ + id: ID! + description: String! +}` + ); + }); + + it('subgraphA and subgraphB composition', () => { + const result = composeServices([ + { + name: 'subgraphA', + typeDefs: schemaA, + }, + { + name: 'subgraphB', + typeDefs: schemaB, + }, + ]); + expect(result.errors ?? []).toEqual([]); + const printed = printSchema(result.schema!); + + expect(printed).toContain( +`schema + @link(url: \"https://specs.apollo.dev/link/v1.0\") + @link(url: \"https://specs.apollo.dev/join/v0.3\", for: EXECUTION) + @join__directive(graphs: [SUBGRAPHA, SUBGRAPHB], name: \"link\", args: {url: \"https://specs.apollo.dev/source/v0.1\", import: [\"@sourceAPI\", \"@sourceType\", \"@sourceField\"]}) + @join__directive(graphs: [SUBGRAPHA, SUBGRAPHB], name: \"sourceAPI\", args: {name: \"A\", http: {baseURL: \"https://api.a.com/v1\"}}) +{ + query: Query +}` + ); + + expect(printed).toContain( +`type Query + @join__type(graph: SUBGRAPHA) + @join__type(graph: SUBGRAPHB) +{ + resources: [Resource!]! @join__directive(graphs: [SUBGRAPHA, SUBGRAPHB], name: \"sourceField\", args: {api: \"A\", http: {GET: \"/resources\"}}) +}` + ); + + expect(printed).toContain( +`type Resource + @join__type(graph: SUBGRAPHA, key: \"id\") + @join__type(graph: SUBGRAPHB, key: \"id\") + @join__directive(graphs: [SUBGRAPHA], name: \"sourceType\", args: {api: \"A\", http: {GET: \"/resources/{id}\"}, selection: \"id description\"}) +{ + id: ID! + description: String! @join__field(graph: SUBGRAPHA) +}` + ); + }); + + it('subgraphA and subgraphC composition', () => { + const result = composeServices([ + { + name: 'subgraphA', + typeDefs: schemaA, + }, + { + name: 'subgraphC', + typeDefs: schemaC, + }, + ]); + expect(result.errors ?? []).toEqual([]); + const printed = printSchema(result.schema!); + + expect(printed).toContain( +`schema + @link(url: \"https://specs.apollo.dev/link/v1.0\") + @link(url: \"https://specs.apollo.dev/join/v0.3\", for: EXECUTION) + @join__directive(graphs: [SUBGRAPHA, SUBGRAPHC], name: \"link\", args: {url: \"https://specs.apollo.dev/source/v0.1\", import: [\"@sourceAPI\", \"@sourceType\", \"@sourceField\"]}) + @join__directive(graphs: [SUBGRAPHA, SUBGRAPHC], name: \"sourceAPI\", args: {name: \"A\", http: {baseURL: \"https://api.a.com/v1\"}}) +{ + query: Query +}` + ); + + expect(printed).toContain( +`type Query + @join__type(graph: SUBGRAPHA) + @join__type(graph: SUBGRAPHC) +{ + resources: [Resource!]! @join__field(graph: SUBGRAPHA) @join__directive(graphs: [SUBGRAPHA], name: \"sourceField\", args: {api: \"A\", http: {GET: \"/resources\"}}) +}` + ); + + expect(printed).toContain( +`type Resource + @join__type(graph: SUBGRAPHA, key: \"id\") + @join__type(graph: SUBGRAPHC, key: \"id\") + @join__directive(graphs: [SUBGRAPHA], name: \"sourceType\", args: {api: \"A\", http: {GET: \"/resources/{id}\"}, selection: \"id description\"}) + @join__directive(graphs: [SUBGRAPHC], name: \"sourceType\", args: {api: \"A\", http: {GET: \"/resources/{id}\"}, selection: \"id creationDate\"}) +{ + id: ID! + description: String! @join__field(graph: SUBGRAPHA) + creationDate: String! @join__field(graph: SUBGRAPHC) +}` + ); + }); + + it('subgraphB and subgraphC composition', () => { + const result = composeServices([ + { + name: 'subgraphB', + typeDefs: schemaB, + }, + { + name: 'subgraphC', + typeDefs: schemaC, + }, + ]); + expect(result.errors ?? []).toEqual([]); + const printed = printSchema(result.schema!); + + expect(printed).toContain( +`schema + @link(url: \"https://specs.apollo.dev/link/v1.0\") + @link(url: \"https://specs.apollo.dev/join/v0.3\", for: EXECUTION) + @join__directive(graphs: [SUBGRAPHB, SUBGRAPHC], name: \"link\", args: {url: \"https://specs.apollo.dev/source/v0.1\", import: [\"@sourceAPI\", \"@sourceType\", \"@sourceField\"]}) + @join__directive(graphs: [SUBGRAPHB, SUBGRAPHC], name: \"sourceAPI\", args: {name: \"A\", http: {baseURL: \"https://api.a.com/v1\"}}) +{ + query: Query +}`); + + expect(printed).toContain( +`type Query + @join__type(graph: SUBGRAPHB) + @join__type(graph: SUBGRAPHC) +{ + resources: [Resource!]! @join__field(graph: SUBGRAPHB) @join__directive(graphs: [SUBGRAPHB], name: \"sourceField\", args: {api: \"A\", http: {GET: \"/resources\"}}) +}` + ); + + expect(printed).toContain( +`type Resource + @join__type(graph: SUBGRAPHB, key: \"id\") + @join__type(graph: SUBGRAPHC, key: \"id\") + @join__directive(graphs: [SUBGRAPHC], name: \"sourceType\", args: {api: \"A\", http: {GET: \"/resources/{id}\"}, selection: \"id creationDate\"}) +{ + id: ID! + creationDate: String! @join__field(graph: SUBGRAPHC) +}` + ); + }); + + it('subgraphA, subgraphB, and subgraphC composition', () => { + const result = composeServices([ + { + name: 'subgraphA', + typeDefs: schemaA, + }, + { + name: 'subgraphB', + typeDefs: schemaB, + }, + { + name: 'subgraphC', + typeDefs: schemaC, + }, + ]); + expect(result.errors ?? []).toEqual([]); + const printed = printSchema(result.schema!); + + expect(printed).toContain( +`schema + @link(url: \"https://specs.apollo.dev/link/v1.0\") + @link(url: \"https://specs.apollo.dev/join/v0.3\", for: EXECUTION) + @join__directive(graphs: [SUBGRAPHA, SUBGRAPHB, SUBGRAPHC], name: \"link\", args: {url: \"https://specs.apollo.dev/source/v0.1\", import: [\"@sourceAPI\", \"@sourceType\", \"@sourceField\"]}) + @join__directive(graphs: [SUBGRAPHA, SUBGRAPHB, SUBGRAPHC], name: \"sourceAPI\", args: {name: \"A\", http: {baseURL: \"https://api.a.com/v1\"}}) +{ + query: Query +}` + ); + + expect(printed).toContain( +`type Query + @join__type(graph: SUBGRAPHA) + @join__type(graph: SUBGRAPHB) + @join__type(graph: SUBGRAPHC) +{ + resources: [Resource!]! @join__field(graph: SUBGRAPHA) @join__field(graph: SUBGRAPHB) @join__directive(graphs: [SUBGRAPHA, SUBGRAPHB], name: \"sourceField\", args: {api: \"A\", http: {GET: \"/resources\"}}) +}` + ); + + expect(printed).toContain( +`type Resource + @join__type(graph: SUBGRAPHA, key: \"id\") + @join__type(graph: SUBGRAPHB, key: \"id\") + @join__type(graph: SUBGRAPHC, key: \"id\") + @join__directive(graphs: [SUBGRAPHA], name: \"sourceType\", args: {api: \"A\", http: {GET: \"/resources/{id}\"}, selection: \"id description\"}) + @join__directive(graphs: [SUBGRAPHC], name: \"sourceType\", args: {api: \"A\", http: {GET: \"/resources/{id}\"}, selection: \"id creationDate\"}) +{ + id: ID! + description: String! @join__field(graph: SUBGRAPHA) + creationDate: String! @join__field(graph: SUBGRAPHC) +}` + ) + }); +}); diff --git a/composition-js/src/composeDirectiveManager.ts b/composition-js/src/composeDirectiveManager.ts index df65bb8f1..ba48a80ae 100644 --- a/composition-js/src/composeDirectiveManager.ts +++ b/composition-js/src/composeDirectiveManager.ts @@ -64,6 +64,7 @@ const DISALLOWED_IDENTITIES = [ 'https://specs.apollo.dev/federation', 'https://specs.apollo.dev/authenticated', 'https://specs.apollo.dev/requiresScopes', + 'https://specs.apollo.dev/source', ]; export class ComposeDirectiveManager { diff --git a/composition-js/src/merging/merge.ts b/composition-js/src/merging/merge.ts index b9614cfdb..6c367187c 100644 --- a/composition-js/src/merging/merge.ts +++ b/composition-js/src/merging/merge.ts @@ -71,6 +71,9 @@ import { FeatureVersion, FEDERATION_VERSIONS, InaccessibleSpecDefinition, + LinkDirectiveArgs, + sourceIdentity, + FeatureUrl, } from "@apollo/federation-internals"; import { ASTNode, GraphQLError, DirectiveLocation } from "graphql"; import { @@ -279,6 +282,8 @@ class Merger { private linkSpec: CoreSpecDefinition; private inaccessibleSpec: InaccessibleSpecDefinition; private latestFedVersionUsed: FeatureVersion; + private joinDirectiveIdentityURLs = new Set(); + private schemaToImportNameToFeatureUrl = new Map>(); constructor(readonly subgraphs: Subgraphs, readonly options: CompositionOptions) { this.latestFedVersionUsed = this.getLatestFederationVersionUsed(); @@ -297,9 +302,24 @@ class Merger { (error: GraphQLError) => { this.errors.push(error); }, (hint: CompositionHint) => { this.hints.push(hint); }, ); - this.subgraphsSchema = subgraphs.values().map(subgraph => subgraph.schema); + + this.subgraphsSchema = subgraphs.values().map(({ schema }) => { + if (!this.schemaToImportNameToFeatureUrl.has(schema)) { + this.schemaToImportNameToFeatureUrl.set( + schema, + this.computeMapFromImportNameToIdentityUrl(schema), + ); + } + return schema; + }); + this.subgraphNamesToJoinSpecName = this.prepareSupergraph(); this.appliedDirectivesToMerge = []; + + [ // Represent any applications of directives imported from these spec URLs + // using @join__directive in the merged supergraph. + sourceIdentity, + ].forEach(url => this.joinDirectiveIdentityURLs.add(url)); } private getLatestFederationVersionUsed(): FeatureVersion { @@ -723,6 +743,7 @@ class Merger { this.mergeDescription(sources, dest); this.addJoinType(sources, dest); this.recordAppliedDirectivesToMerge(sources, dest); + this.addJoinDirectiveDirectives(sources, dest); switch (dest.kind) { case 'ScalarType': // Since we don't handle applied directives yet, we have nothing specific to do for scalars. @@ -1360,6 +1381,7 @@ class Merger { this.validateExternalFields(sources, dest, allTypesEqual); } this.addJoinField({ sources, dest, allTypesEqual, mergeContext }); + this.addJoinDirectiveDirectives(sources, dest); } private validateFieldSharing(sources: FieldOrUndefinedArray, dest: FieldDefinition, mergeContext: FieldMergeContext) { @@ -2572,6 +2594,111 @@ class Merger { // Because we rename all root type in subgraphs to their default names, we shouldn't ever have incompatibilities here. assert(!isIncompatible, () => `Should not have incompatible root type for ${rootKind}`); } + this.addJoinDirectiveDirectives(sources, dest); + } + + private shouldUseJoinDirectiveForURL(url: FeatureUrl | undefined): boolean { + return Boolean( + url && + this.joinDirectiveIdentityURLs.has(url.identity) + ); + } + + private computeMapFromImportNameToIdentityUrl( + schema: Schema, + ): Map { + // For each @link directive on the schema definition, store its normalized + // identity url in a Map, reachable from all its imported names. + const map = new Map(); + for (const linkDirective of schema.schemaDefinition.appliedDirectivesOf('link')) { + const { url, import: imports } = linkDirective.arguments(); + if (imports) { + for (const i of imports) { + if (typeof i === 'string') { + map.set(i, FeatureUrl.parse(url)); + } else { + map.set(i.as ?? i.name, FeatureUrl.parse(url)); + } + } + } + } + return map; + } + + // This method gets called at various points during the merge to allow + // subgraph directive applications to be reflected (unapplied) in the + // supergraph, using the @join__directive(graphs,name,args) directive. + private addJoinDirectiveDirectives( + sources: (SchemaElement | undefined)[], + dest: SchemaElement, + ) { + const joinsByDirectiveName: { + [directiveName: string]: Array<{ + graphs: string[]; + args: Record; + }> + } = Object.create(null); + + for (const [idx, source] of sources.entries()) { + if (!source) continue; + const graph = this.joinSpecName(idx); + + // We compute this map only once per subgraph, as it takes time + // proportional to the size of the schema. + const linkImportIdentityURLMap = + this.schemaToImportNameToFeatureUrl.get(source.schema()); + if (!linkImportIdentityURLMap) continue; + + for (const directive of source.appliedDirectives) { + let shouldIncludeAsJoinDirective = false; + + if (directive.name === 'link') { + const { url } = directive.arguments(); + if (typeof url === 'string') { + shouldIncludeAsJoinDirective = + this.shouldUseJoinDirectiveForURL(FeatureUrl.parse(url)); + } + } else { + // To be consistent with other code accessing + // linkImportIdentityURLMap, we ensure directive names start with a + // leading @. + const nameWithAtSymbol = + directive.name.startsWith('@') ? directive.name : '@' + directive.name; + shouldIncludeAsJoinDirective = this.shouldUseJoinDirectiveForURL( + linkImportIdentityURLMap.get(nameWithAtSymbol), + ); + } + + if (shouldIncludeAsJoinDirective) { + const existingJoins = (joinsByDirectiveName[directive.name] ??= []); + let found = false; + for (const existingJoin of existingJoins) { + if (valueEquals(existingJoin.args, directive.arguments())) { + existingJoin.graphs.push(graph); + found = true; + break; + } + } + if (!found) { + existingJoins.push({ + graphs: [graph], + args: directive.arguments(), + }); + } + } + } + } + + const joinDirective = this.joinSpec.directiveDirective(this.merged); + Object.keys(joinsByDirectiveName).forEach(directiveName => { + joinsByDirectiveName[directiveName].forEach(join => { + dest.applyDirective(joinDirective, { + graphs: join.graphs, + name: directiveName, + args: join.args, + }); + }); + }); } private filterSubgraphs(predicate: (schema: Schema) => boolean): string[] { diff --git a/gateway-js/src/__tests__/gateway/lifecycle-hooks.test.ts b/gateway-js/src/__tests__/gateway/lifecycle-hooks.test.ts index 9ffa0790f..d6b9787ed 100644 --- a/gateway-js/src/__tests__/gateway/lifecycle-hooks.test.ts +++ b/gateway-js/src/__tests__/gateway/lifecycle-hooks.test.ts @@ -149,7 +149,7 @@ describe('lifecycle hooks', () => { // the supergraph (even just formatting differences), this ID will change // and this test will have to updated. expect(secondCall[0]!.compositionId).toEqual( - '4644102bb30ec7e254fac2577f78137bbab156cb965973ae481f309120738ff6', + '4657c2b2d643e1269c49c3f661f5c1e174cef413065c7ab79b28e16ea5f64479', ); // second call should have previous info in the second arg expect(secondCall[1]!.compositionId).toEqual(expectedFirstId); diff --git a/internals-js/src/federation.ts b/internals-js/src/federation.ts index d7e8ca5d5..2248803a1 100644 --- a/internals-js/src/federation.ts +++ b/internals-js/src/federation.ts @@ -85,6 +85,11 @@ import { createObjectTypeSpecification, createScalarTypeSpecification, createUni import { didYouMean, suggestionList } from "./suggestions"; import { coreFeatureDefinitionIfKnown } from "./knownCoreFeatures"; import { joinIdentity } from "./specs/joinSpec"; +import { + SourceAPIDirectiveArgs, + SourceFieldDirectiveArgs, + SourceTypeDirectiveArgs, +} from "./specs/sourceSpec"; const linkSpec = LINK_VERSIONS.latest(); const tagSpec = TAG_VERSIONS.latest(); @@ -774,6 +779,18 @@ export class FederationMetadata { return this.getPost20FederationDirective(FederationDirectiveName.POLICY); } + sourceAPIDirective(): Post20FederationDirectiveDefinition { + return this.getPost20FederationDirective(FederationDirectiveName.SOURCE_API); + } + + sourceTypeDirective(): Post20FederationDirectiveDefinition { + return this.getPost20FederationDirective(FederationDirectiveName.SOURCE_TYPE); + } + + sourceFieldDirective(): Post20FederationDirectiveDefinition { + return this.getPost20FederationDirective(FederationDirectiveName.SOURCE_FIELD); + } + allFederationDirectives(): DirectiveDefinition[] { const baseDirectives: DirectiveDefinition[] = [ this.keyDirective(), @@ -814,6 +831,19 @@ export class FederationMetadata { baseDirectives.push(policyDirective); } + const sourceAPIDirective = this.sourceAPIDirective(); + if (isFederationDirectiveDefinedInSchema(sourceAPIDirective)) { + baseDirectives.push(sourceAPIDirective); + } + const sourceTypeDirective = this.sourceTypeDirective(); + if (isFederationDirectiveDefinedInSchema(sourceTypeDirective)) { + baseDirectives.push(sourceTypeDirective); + } + const sourceFieldDirective = this.sourceFieldDirective(); + if (isFederationDirectiveDefinedInSchema(sourceFieldDirective)) { + baseDirectives.push(sourceFieldDirective); + } + return baseDirectives; } diff --git a/internals-js/src/index.ts b/internals-js/src/index.ts index 60cfe8c2f..78d0d9950 100644 --- a/internals-js/src/index.ts +++ b/internals-js/src/index.ts @@ -23,3 +23,4 @@ export * from './argumentCompositionStrategies'; export * from './specs/authenticatedSpec'; export * from './specs/requiresScopesSpec'; export * from './specs/policySpec'; +export * from './specs/sourceSpec'; diff --git a/internals-js/src/specs/federationSpec.ts b/internals-js/src/specs/federationSpec.ts index 2bb1909cf..b4df403c0 100644 --- a/internals-js/src/specs/federationSpec.ts +++ b/internals-js/src/specs/federationSpec.ts @@ -40,6 +40,9 @@ export enum FederationDirectiveName { AUTHENTICATED = 'authenticated', REQUIRES_SCOPES = 'requiresScopes', POLICY = 'policy', + SOURCE_API = 'sourceAPI', + SOURCE_TYPE = 'sourceType', + SOURCE_FIELD = 'sourceField', } const fieldSetTypeSpec = createScalarTypeSpecification({ name: FederationTypeName.FIELD_SET }); diff --git a/internals-js/src/specs/joinSpec.ts b/internals-js/src/specs/joinSpec.ts index a7afa6c7a..cea9cf64b 100644 --- a/internals-js/src/specs/joinSpec.ts +++ b/internals-js/src/specs/joinSpec.ts @@ -6,6 +6,7 @@ import { ScalarType, Schema, NonNullType, + ListType, } from "../definitions"; import { Subgraph, Subgraphs } from "../federation"; import { registerKnownFeature } from '../knownCoreFeatures'; @@ -48,6 +49,12 @@ export type JoinFieldDirectiveArguments = { usedOverridden?: boolean, } +export type JoinDirectiveArguments = { + graphs: string[], + name: string, + args?: Record, +}; + export class JoinSpecDefinition extends FeatureDefinition { constructor(version: FeatureVersion, minimumFederationVersion?: FeatureVersion) { super(new FeatureUrl(joinIdentity, 'join', version), minimumFederationVersion); @@ -126,6 +133,22 @@ export class JoinSpecDefinition extends FeatureDefinition { joinEnumValue.addArgument('graph', new NonNullType(graphEnum)); } + const joinDirective = this.addDirective(schema, 'directive').addLocations( + DirectiveLocation.SCHEMA, + DirectiveLocation.OBJECT, + DirectiveLocation.INTERFACE, + DirectiveLocation.FIELD_DEFINITION, + ); + joinDirective.repeatable = true; + // Note this 'graphs' argument is plural, since the same directive + // application can appear on the same schema element in multiple subgraphs. + // Repetition of a graph in this 'graphs' list is allowed, and corresponds + // to repeated application of the same directive in the same subgraph, which + // is allowed. + joinDirective.addArgument('graphs', new ListType(new NonNullType(graphEnum))); + joinDirective.addArgument('name', new NonNullType(schema.stringType())); + joinDirective.addArgument('args', this.addScalarType(schema, 'DirectiveArguments')); + if (this.isV01()) { const joinOwner = this.addDirective(schema, 'owner').addLocations(DirectiveLocation.OBJECT); joinOwner.addArgument('graph', new NonNullType(graphEnum)); @@ -192,6 +215,10 @@ export class JoinSpecDefinition extends FeatureDefinition { return this.directive(schema, 'graph')!; } + directiveDirective(schema: Schema): DirectiveDefinition { + return this.directive(schema, 'directive')!; + } + typeDirective(schema: Schema): DirectiveDefinition { return this.directive(schema, 'type')!; } diff --git a/internals-js/src/specs/sourceSpec.ts b/internals-js/src/specs/sourceSpec.ts new file mode 100644 index 000000000..b071aba39 --- /dev/null +++ b/internals-js/src/specs/sourceSpec.ts @@ -0,0 +1,208 @@ +import { DirectiveLocation, GraphQLError } from 'graphql'; +import { FeatureDefinition, FeatureDefinitions, FeatureUrl, FeatureVersion } from "./coreSpec"; +import { + Schema, + NonNullType, + InputObjectType, + InputFieldDefinition, + ListType, +} from '../definitions'; +import { registerKnownFeature } from '../knownCoreFeatures'; +import { createDirectiveSpecification } from '../directiveAndTypeSpecification'; + +export const sourceIdentity = 'https://specs.apollo.dev/source'; + +export class SourceSpecDefinition extends FeatureDefinition { + constructor(version: FeatureVersion, minimumFederationVersion?: FeatureVersion) { + super(new FeatureUrl(sourceIdentity, 'source', version), minimumFederationVersion); + + this.registerDirective(createDirectiveSpecification({ + name: 'sourceAPI', + locations: [DirectiveLocation.SCHEMA], + repeatable: true, + // We "compose" these `@source{API,Type,Field}` directives using the + // `@join__directive` mechanism, so they do not need to be composed in the + // way passing `composes: true` here implies. + composes: false, + })); + + this.registerDirective(createDirectiveSpecification({ + name: 'sourceType', + locations: [DirectiveLocation.OBJECT, DirectiveLocation.INTERFACE], + repeatable: true, + composes: false, + })); + + this.registerDirective(createDirectiveSpecification({ + name: 'sourceField', + locations: [DirectiveLocation.FIELD_DEFINITION], + repeatable: true, + composes: false, + })); + } + + addElementsToSchema(schema: Schema): GraphQLError[] { + const sourceAPI = this.addDirective(schema, 'sourceAPI').addLocations(DirectiveLocation.SCHEMA); + sourceAPI.repeatable = true; + + sourceAPI.addArgument('name', new NonNullType(schema.stringType())); + + const HTTPHeaderMapping = schema.addType(new InputObjectType('HTTPHeaderMapping')); + HTTPHeaderMapping.addField(new InputFieldDefinition('name')).type = + new NonNullType(schema.stringType()); + HTTPHeaderMapping.addField(new InputFieldDefinition('as')).type = + schema.stringType(); + HTTPHeaderMapping.addField(new InputFieldDefinition('value')).type = + schema.stringType(); + + const HTTPSourceAPI = schema.addType(new InputObjectType('HTTPSourceAPI')); + HTTPSourceAPI.addField(new InputFieldDefinition('baseURL')).type = + new NonNullType(schema.stringType()); + HTTPSourceAPI.addField(new InputFieldDefinition('headers')).type = + new ListType(new NonNullType(HTTPHeaderMapping)); + sourceAPI.addArgument('http', HTTPSourceAPI); + + const sourceType = this.addDirective(schema, 'sourceType').addLocations( + DirectiveLocation.OBJECT, + DirectiveLocation.INTERFACE, + // TODO Allow @sourceType on unions, similar to interfaces? + // DirectiveLocation.UNION, + ); + sourceType.repeatable = true; + sourceType.addArgument('api', new NonNullType(schema.stringType())); + + const URLPathTemplate = this.addScalarType(schema, 'URLPathTemplate'); + const JSONSelection = this.addScalarType(schema, 'JSONSelection'); + + const HTTPSourceType = schema.addType(new InputObjectType('HTTPSourceType')); + HTTPSourceType.addField(new InputFieldDefinition('GET')).type = URLPathTemplate; + HTTPSourceType.addField(new InputFieldDefinition('POST')).type = URLPathTemplate; + HTTPSourceType.addField(new InputFieldDefinition('headers')).type = + new ListType(new NonNullType(HTTPHeaderMapping)); + HTTPSourceType.addField(new InputFieldDefinition('body')).type = JSONSelection; + sourceType.addArgument('http', HTTPSourceType); + + sourceType.addArgument('selection', new NonNullType(JSONSelection)); + + const KeyTypeMap = schema.addType(new InputObjectType('KeyTypeMap')); + KeyTypeMap.addField(new InputFieldDefinition('key')).type = new NonNullType(schema.stringType()); + KeyTypeMap.addField(new InputFieldDefinition('typeMap')).type = + // TypenameKeyMap is a scalar type similar to a JSON dictionary, where the + // keys are __typename strings and the values are values of the key field. + this.addScalarType(schema, 'TypenameKeyMap'); + sourceType.addArgument('keyTypeMap', KeyTypeMap); + + const sourceField = this.addDirective(schema, 'sourceField').addLocations( + DirectiveLocation.FIELD_DEFINITION, + ); + sourceField.repeatable = true; + sourceField.addArgument('api', new NonNullType(schema.stringType())); + sourceField.addArgument('selection', JSONSelection); + + const HTTPSourceField = schema.addType(new InputObjectType('HTTPSourceField')); + HTTPSourceField.addField(new InputFieldDefinition('GET')).type = URLPathTemplate; + HTTPSourceField.addField(new InputFieldDefinition('POST')).type = URLPathTemplate; + HTTPSourceField.addField(new InputFieldDefinition('PUT')).type = URLPathTemplate; + HTTPSourceField.addField(new InputFieldDefinition('PATCH')).type = URLPathTemplate; + HTTPSourceField.addField(new InputFieldDefinition('DELETE')).type = URLPathTemplate; + HTTPSourceField.addField(new InputFieldDefinition('body')).type = JSONSelection; + HTTPSourceField.addField(new InputFieldDefinition('headers')).type = + new ListType(new NonNullType(HTTPHeaderMapping)); + sourceField.addArgument('http', HTTPSourceField); + + return []; + } + + allElementNames(): string[] { + return [ + '@sourceAPI', + '@sourceType', + '@sourceField', + // 'JSONSelection', + // 'URLPathTemplate', + // 'JSON', + // 'HTTPHeaderMapping', + // 'HTTPSourceAPI', + // 'HTTPSourceType', + // 'HTTPSourceField', + // 'KeyTypeMap', + ]; + } + + sourceAPIDirective(schema: Schema) { + return this.directive(schema, 'sourceAPI')!; + } + + sourceTypeDirective(schema: Schema) { + return this.directive(schema, 'sourceType')!; + } + + sourceFieldDirective(schema: Schema) { + return this.directive(schema, 'sourceField')!; + } +} + +export type SourceAPIDirectiveArgs = { + name: string; + http?: HTTPSourceAPI; +}; + +export type HTTPSourceAPI = { + baseURL: string; + headers?: HTTPHeaderMapping[]; +}; + +export type HTTPHeaderMapping = { + name: string; + as?: string; + value?: string; +}; + +export type SourceTypeDirectiveArgs = { + api: string; + http?: HTTPSourceType; + selection: JSONSelection; + keyTypeMap?: KeyTypeMap; +}; + +export type HTTPSourceType = { + GET?: URLPathTemplate; + POST?: URLPathTemplate; + headers?: HTTPHeaderMapping[]; + body?: JSONSelection; +}; + +type URLPathTemplate = string; +type JSONSelection = string; + +type KeyTypeMap = { + key: string; + typeMap: { + [__typename: string]: string; + }; +}; + +export type SourceFieldDirectiveArgs = { + api: string; + http?: HTTPSourceField; + selection?: JSONSelection; +}; + +export type HTTPSourceField = { + GET?: URLPathTemplate; + POST?: URLPathTemplate; + PUT?: URLPathTemplate; + PATCH?: URLPathTemplate; + DELETE?: URLPathTemplate; + body?: JSONSelection; + headers?: HTTPHeaderMapping[]; +}; + +export const SOURCE_VERSIONS = new FeatureDefinitions(sourceIdentity) + .add(new SourceSpecDefinition( + new FeatureVersion(0, 1), + // TODO Expecting this to be bumped to 2.7, but there's no 2.7 version yet. + new FeatureVersion(2, 6), + )); + +registerKnownFeature(SOURCE_VERSIONS);