diff --git a/composition-js/src/__tests__/cachetag.test.ts b/composition-js/src/__tests__/cachetag.test.ts new file mode 100644 index 000000000..17064b22c --- /dev/null +++ b/composition-js/src/__tests__/cachetag.test.ts @@ -0,0 +1,358 @@ +import { composeServices } from "../compose"; +import { printSchema } from "@apollo/federation-internals"; +import { parse } from "graphql/index"; + +describe("cacheTag spec and join__directive", () => { + it("composes", () => { + const subgraphs = [ + { + name: "products", + typeDefs: parse(` + extend schema + @link( + url: "https://specs.apollo.dev/federation/v2.12" + import: ["@key" "@cacheTag"] + ) + + type Query { + resources: [Resource!]! @cacheTag(format: "resources") + } + + type Resource @key(fields: "id") @cacheTag(format: "resource-{$key.id}") { + id: ID! + name: String! + } + `), + }, + ]; + + const result = composeServices(subgraphs); + expect(result.errors ?? []).toEqual([]); + const printed = printSchema(result.schema!); + expect(printed).toMatchInlineSnapshot(` + "schema + @link(url: \\"https://specs.apollo.dev/link/v1.0\\") + @link(url: \\"https://specs.apollo.dev/join/v0.5\\", for: EXECUTION) + @link(url: \\"https://specs.apollo.dev/cacheTag/v0.1\\", for: EXECUTION) + { + query: Query + } + + directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA + + directive @join__graph(name: String!, url: String!) on ENUM_VALUE + + directive @join__type(graph: join__Graph!, key: join__FieldSet, extension: Boolean! = false, resolvable: Boolean! = true, isInterfaceObject: Boolean! = false) repeatable on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT | SCALAR + + directive @join__field(graph: join__Graph, requires: join__FieldSet, provides: join__FieldSet, type: String, external: Boolean, override: String, usedOverridden: Boolean, overrideLabel: String, contextArguments: [join__ContextArgument!]) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION + + directive @join__implements(graph: join__Graph!, interface: String!) repeatable on OBJECT | INTERFACE + + directive @join__unionMember(graph: join__Graph!, member: String!) repeatable on UNION + + 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 + + enum link__Purpose { + \\"\\"\\" + \`SECURITY\` features provide metadata necessary to securely resolve fields. + \\"\\"\\" + SECURITY + + \\"\\"\\" + \`EXECUTION\` features provide metadata necessary for operation execution. + \\"\\"\\" + EXECUTION + } + + scalar link__Import + + enum join__Graph { + PRODUCTS @join__graph(name: \\"products\\", url: \\"\\") + } + + scalar join__FieldSet + + scalar join__DirectiveArguments + + scalar join__FieldValue + + input join__ContextArgument { + name: String! + type: String! + context: String! + selection: join__FieldValue! + } + + type Query + @join__type(graph: PRODUCTS) + { + resources: [Resource!]! @join__directive(graphs: [PRODUCTS], name: \\"federation__cacheTag\\", args: {format: \\"resources\\"}) + } + + type Resource + @join__type(graph: PRODUCTS, key: \\"id\\") + @join__directive(graphs: [PRODUCTS], name: \\"federation__cacheTag\\", args: {format: \\"resource-{$key.id}\\"}) + { + id: ID! + name: String! + }" + `); + + if (result.schema) { + expect(printSchema(result.schema.toAPISchema())).toMatchInlineSnapshot(` + "type Query { + resources: [Resource!]! + } + + type Resource { + id: ID! + name: String! + }" + `); + } + }); + + it("composes with 2 subgraphs", () => { + const subgraphs = [ + { + name: "products", + typeDefs: parse(` + extend schema + @link( + url: "https://specs.apollo.dev/federation/v2.12" + import: ["@key" "@cacheTag"] + ) + + type Query { + resources: [Resource!]! @cacheTag(format: "resources") + } + + type Resource @key(fields: "id") @cacheTag(format: "resource-{$key.id}") { + id: ID! + name: String! + } + `), + }, + { + name: "reviews", + typeDefs: parse(` + extend schema + @link( + url: "https://specs.apollo.dev/federation/v2.12" + import: ["@key" "@cacheTag"] + ) + + type Resource @key(fields: "id") @cacheTag(format: "resource-{$key.id}") { + id: ID! + reviews: [String!]! + } + `), + }, + ]; + + const result = composeServices(subgraphs); + expect(result.errors ?? []).toEqual([]); + const printed = printSchema(result.schema!); + expect(printed).toMatchInlineSnapshot(` + "schema + @link(url: \\"https://specs.apollo.dev/link/v1.0\\") + @link(url: \\"https://specs.apollo.dev/join/v0.5\\", for: EXECUTION) + @link(url: \\"https://specs.apollo.dev/cacheTag/v0.1\\", for: EXECUTION) + { + query: Query + } + + directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA + + directive @join__graph(name: String!, url: String!) on ENUM_VALUE + + directive @join__type(graph: join__Graph!, key: join__FieldSet, extension: Boolean! = false, resolvable: Boolean! = true, isInterfaceObject: Boolean! = false) repeatable on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT | SCALAR + + directive @join__field(graph: join__Graph, requires: join__FieldSet, provides: join__FieldSet, type: String, external: Boolean, override: String, usedOverridden: Boolean, overrideLabel: String, contextArguments: [join__ContextArgument!]) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION + + directive @join__implements(graph: join__Graph!, interface: String!) repeatable on OBJECT | INTERFACE + + directive @join__unionMember(graph: join__Graph!, member: String!) repeatable on UNION + + 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 + + enum link__Purpose { + \\"\\"\\" + \`SECURITY\` features provide metadata necessary to securely resolve fields. + \\"\\"\\" + SECURITY + + \\"\\"\\" + \`EXECUTION\` features provide metadata necessary for operation execution. + \\"\\"\\" + EXECUTION + } + + scalar link__Import + + enum join__Graph { + PRODUCTS @join__graph(name: \\"products\\", url: \\"\\") + REVIEWS @join__graph(name: \\"reviews\\", url: \\"\\") + } + + scalar join__FieldSet + + scalar join__DirectiveArguments + + scalar join__FieldValue + + input join__ContextArgument { + name: String! + type: String! + context: String! + selection: join__FieldValue! + } + + type Query + @join__type(graph: PRODUCTS) + @join__type(graph: REVIEWS) + { + resources: [Resource!]! @join__field(graph: PRODUCTS) @join__directive(graphs: [PRODUCTS], name: \\"federation__cacheTag\\", args: {format: \\"resources\\"}) + } + + type Resource + @join__type(graph: PRODUCTS, key: \\"id\\") + @join__type(graph: REVIEWS, key: \\"id\\") + @join__directive(graphs: [PRODUCTS, REVIEWS], name: \\"federation__cacheTag\\", args: {format: \\"resource-{$key.id}\\"}) + { + id: ID! + name: String! @join__field(graph: PRODUCTS) + reviews: [String!]! @join__field(graph: REVIEWS) + }" + `); + + if (result.schema) { + expect(printSchema(result.schema.toAPISchema())).toMatchInlineSnapshot(` + "type Query { + resources: [Resource!]! + } + + type Resource { + id: ID! + name: String! + reviews: [String!]! + }" + `); + } + }); + + it("may be renamed", () => { + const subgraphs = [ + { + name: "products", + typeDefs: parse(` + extend schema + @link( + url: "https://specs.apollo.dev/federation/v2.12" + import: ["@key" {name: "@cacheTag" as: "@myCacheTag"}] + ) + + type Query { + resources: [Resource!]! @myCacheTag(format: "resources") + } + + type Resource @key(fields: "id") @myCacheTag(format: "resource-{$key.id}") { + id: ID! + name: String! + } + `), + }, + ]; + + const result = composeServices(subgraphs); + expect(result.errors ?? []).toEqual([]); + const printed = printSchema(result.schema!); + expect(printed).toMatchInlineSnapshot(` + "schema + @link(url: \\"https://specs.apollo.dev/link/v1.0\\") + @link(url: \\"https://specs.apollo.dev/join/v0.5\\", for: EXECUTION) + @link(url: \\"https://specs.apollo.dev/cacheTag/v0.1\\", for: EXECUTION) + { + query: Query + } + + directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA + + directive @join__graph(name: String!, url: String!) on ENUM_VALUE + + directive @join__type(graph: join__Graph!, key: join__FieldSet, extension: Boolean! = false, resolvable: Boolean! = true, isInterfaceObject: Boolean! = false) repeatable on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT | SCALAR + + directive @join__field(graph: join__Graph, requires: join__FieldSet, provides: join__FieldSet, type: String, external: Boolean, override: String, usedOverridden: Boolean, overrideLabel: String, contextArguments: [join__ContextArgument!]) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION + + directive @join__implements(graph: join__Graph!, interface: String!) repeatable on OBJECT | INTERFACE + + directive @join__unionMember(graph: join__Graph!, member: String!) repeatable on UNION + + 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 + + enum link__Purpose { + \\"\\"\\" + \`SECURITY\` features provide metadata necessary to securely resolve fields. + \\"\\"\\" + SECURITY + + \\"\\"\\" + \`EXECUTION\` features provide metadata necessary for operation execution. + \\"\\"\\" + EXECUTION + } + + scalar link__Import + + enum join__Graph { + PRODUCTS @join__graph(name: \\"products\\", url: \\"\\") + } + + scalar join__FieldSet + + scalar join__DirectiveArguments + + scalar join__FieldValue + + input join__ContextArgument { + name: String! + type: String! + context: String! + selection: join__FieldValue! + } + + type Query + @join__type(graph: PRODUCTS) + { + resources: [Resource!]! @join__directive(graphs: [PRODUCTS], name: \\"federation__cacheTag\\", args: {format: \\"resources\\"}) + } + + type Resource + @join__type(graph: PRODUCTS, key: \\"id\\") + @join__directive(graphs: [PRODUCTS], name: \\"federation__cacheTag\\", args: {format: \\"resource-{$key.id}\\"}) + { + id: ID! + name: String! + }" + `); + + if (result.schema) { + expect(printSchema(result.schema.toAPISchema())).toMatchInlineSnapshot(` + "type Query { + resources: [Resource!]! + } + + type Resource { + id: ID! + name: String! + }" + `); + } + }); +}); diff --git a/composition-js/src/merging/merge.ts b/composition-js/src/merging/merge.ts index d6f7d4915..6548b45a2 100644 --- a/composition-js/src/merging/merge.ts +++ b/composition-js/src/merging/merge.ts @@ -68,7 +68,6 @@ import { CoreSpecDefinition, FeatureVersion, FEDERATION_VERSIONS, - LinkDirectiveArgs, connectIdentity, FeatureUrl, isFederationDirectiveDefinedInSchema, @@ -379,7 +378,7 @@ class Merger { private inaccessibleDirectiveInSupergraph?: DirectiveDefinition; private latestFedVersionUsed: FeatureVersion; private joinDirectiveFeatureDefinitionsByIdentity = new Map(); - private schemaToImportNameToFeatureUrl = new Map>(); + private directivesUsingJoinDirective = new Set(); private fieldsWithFromContext: Set; private fieldsWithOverride: Set; @@ -402,14 +401,8 @@ class Merger { (hint: CompositionHint) => { this.hints.push(hint); }, ); - this.subgraphsSchema = subgraphs.values().map(({ schema }) => { - if (!this.schemaToImportNameToFeatureUrl.has(schema)) { - this.schemaToImportNameToFeatureUrl.set( - schema, - this.computeMapFromImportNameToIdentityUrl(schema), - ); - } - return schema; + this.subgraphsSchema = subgraphs.values().map((subgraph) => { + return subgraph.schema; }); this.subgraphNamesToJoinSpecName = this.prepareSupergraph(); @@ -546,12 +539,21 @@ class Merger { nameInSupergraph, compositionSpec, }); + if (compositionSpec.useJoinDirective) { + this.directivesUsingJoinDirective.add(nameInSupergraph); + } } } for (const { specInSupergraph, directives } of supergraphInfoByIdentity.values()) { const imports: CoreImport[] = []; - for (const { nameInFeature, nameInSupergraph } of directives) { + for (const { nameInFeature, nameInSupergraph, compositionSpec } of directives) { + // If this directive is using the @join__directive directive, we don't import it in the + // supergraph schemas. + if (compositionSpec.useJoinDirective) { + continue; + } + const defaultNameInSupergraph = CoreFeature.directiveNameInSchemaForCoreArguments( specInSupergraph.url, specInSupergraph.url.name, @@ -619,6 +621,11 @@ class Merger { if (this.composeDirectiveManager.shouldComposeDirective({ subgraphName, directiveName: definition.name })) { return true; } + if (this.directivesUsingJoinDirective.has(definition.name)) { + // This directive will be added as `@join__directive` by the `addJoinDirectiveDirectives` + // method. So, we skip the normal merging logic. + return false; + } if (definition instanceof Directive) { // We have special code in `Merger.prepareSupergraph` to include the _definition_ of merged federation // directives in the supergraph, so we don't have to merge those _definition_, but we *do* need to merge @@ -3108,33 +3115,6 @@ class Merger { return Boolean(url && this.joinDirectiveFeatureDefinitionsByIdentity.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, as, import: imports } = linkDirective.arguments(); - const parsedUrl = FeatureUrl.maybeParse(url); - - if (parsedUrl) { - // always add the main directive to the map, regardless of whether it is imported - map.set(`@${as ?? parsedUrl.name}`, parsedUrl); - if (imports) { - for (const i of imports) { - if (typeof i === 'string') { - map.set(i, parsedUrl); - } else { - map.set(i.as ?? i.name, parsedUrl); - } - } - } - } - } - 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. @@ -3153,17 +3133,18 @@ class Merger { 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; + const coreFeaturesInSource = source.schema().coreFeatures; for (const directive of source.appliedDirectives) { + const sourceFeature = coreFeaturesInSource?.sourceFeature(directive); let shouldIncludeAsJoinDirective = false; + // `directiveNameForJoinDirective`: The directive name to use in the extracted subgraph + // schema. For Connectors (see `shouldUseJoinDirectiveForURL`), this is an import name (the + // same name imported in the supergraph and the extracted subgraphs). For others, this is + // the fully qualified directive name in the subgraph schema (re-assigned below). + let directiveNameForJoinDirective = directive.name; - if (directive.name === 'link') { + if (sourceFeature && sourceFeature.feature.url.identity == linkIdentity) { const { url } = directive.arguments(); const parsedUrl = FeatureUrl.maybeParse(url); if (typeof url === 'string' && parsedUrl) { @@ -3179,18 +3160,32 @@ class Merger { } } 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; + // See if directives from this feature URL should use the @join__directive. shouldIncludeAsJoinDirective = this.shouldUseJoinDirectiveForURL( - linkImportIdentityURLMap.get(nameWithAtSymbol), + sourceFeature?.feature.url ); + // See if this directive is one of the directives that should use the @join__directive. + if ( + !shouldIncludeAsJoinDirective + && this.directivesUsingJoinDirective.has(directive.name) + ) { + shouldIncludeAsJoinDirective = true; + if (sourceFeature) { + // Compute the fully qualified directive name in the subgraph schema without using + // `import`, so it can be referenced in the extracted subgraph schema via + // `@join__directive`. + directiveNameForJoinDirective = CoreFeature.directiveNameInSchemaForCoreArguments( + sourceFeature.feature.url, + sourceFeature.feature.url.name, + [], + sourceFeature.nameInFeature, + ); + } + } } if (shouldIncludeAsJoinDirective) { - const existingJoins = (joinsByDirectiveName[directive.name] ??= []); + const existingJoins = (joinsByDirectiveName[directiveNameForJoinDirective] ??= []); let found = false; for (const existingJoin of existingJoins) { if (valueEquals(existingJoin.args, directive.arguments())) { diff --git a/internals-js/src/__tests__/subgraphValidation.test.ts b/internals-js/src/__tests__/subgraphValidation.test.ts index 99c5bfdee..c5029e45f 100644 --- a/internals-js/src/__tests__/subgraphValidation.test.ts +++ b/internals-js/src/__tests__/subgraphValidation.test.ts @@ -1691,3 +1691,59 @@ describe('@listSize', () => { ]); }); }); + +describe('@cacheTag', () => { + it('applies on root field', () => { + const doc = gql` + extend schema + @link( + url: "https://specs.apollo.dev/federation/v2.12" + import: ["@cacheTag"] + ) + + type Query { + f(x: Int!): String! + @cacheTag(format: "query-f-{$args.x}") + @cacheTag(format: "any-query") + } + `; + const name = 'S'; + buildSubgraph(name, `http://${name}`, doc).validate(); + }); + + it('applies on entity type', () => { + const doc = gql` + extend schema + @link( + url: "https://specs.apollo.dev/federation/v2.12" + import: ["@key", "@cacheTag"] + ) + + type P @key(fields: "id") @cacheTag(format: "p-{$.id}") { + id: ID! + a: Int + } + `; + const name = 'S'; + buildSubgraph(name, `http://${name}`, doc).validate(); + }); + + it('rejects application on non-root field', () => { + const doc = gql` + type Query { + a: A + } + + type A { + x: Int @cacheTag(format: "not-applicable") + } + `; + + expect( + buildForErrors(doc, { asFed2: true, includeAllImports: true }), + ).toStrictEqual( + // TODO + undefined, + ); + }); +}); diff --git a/internals-js/src/__tests__/testUtils.ts b/internals-js/src/__tests__/testUtils.ts index cdada3a12..5c4587c63 100644 --- a/internals-js/src/__tests__/testUtils.ts +++ b/internals-js/src/__tests__/testUtils.ts @@ -9,10 +9,11 @@ export function buildForErrors( options?: { subgraphName?: string, asFed2?: boolean, + includeAllImports?: boolean, } ): [string, string][] | undefined { try { - const doc = (options?.asFed2 ?? true) ? asFed2SubgraphDocument(subgraphDefs) : subgraphDefs; + const doc = (options?.asFed2 ?? true) ? asFed2SubgraphDocument(subgraphDefs, { includeAllImports: options?.includeAllImports }) : subgraphDefs; const name = options?.subgraphName ?? 'S'; buildSubgraph(name, `http://${name}`, doc).validate(); return undefined; diff --git a/internals-js/src/directiveAndTypeSpecification.ts b/internals-js/src/directiveAndTypeSpecification.ts index 57b728dcc..3be1c9d92 100644 --- a/internals-js/src/directiveAndTypeSpecification.ts +++ b/internals-js/src/directiveAndTypeSpecification.ts @@ -34,6 +34,7 @@ export type DirectiveSpecification = { export type DirectiveCompositionSpecification = { supergraphSpecification: (federationVersion: FeatureVersion) => FeatureDefinition, + useJoinDirective: boolean, argumentsMerger?: (schema: Schema, feature: CoreFeature) => ArgumentMerger | GraphQLError, staticArgumentTransform?: StaticArgumentsTransform, } @@ -79,6 +80,7 @@ export function createDirectiveSpecification({ args = [], composes = false, supergraphSpecification = undefined, + useJoinDirective = false, staticArgumentTransform = undefined, }: { name: string, @@ -87,6 +89,7 @@ export function createDirectiveSpecification({ args?: DirectiveArgumentSpecification[], composes?: boolean, supergraphSpecification?: (fedVersion: FeatureVersion) => FeatureDefinition, + useJoinDirective?: boolean, staticArgumentTransform?: (subgraph: Subgraph, args: {[key: string]: any}) => {[key: string]: any}, }): DirectiveSpecification { let composition: DirectiveCompositionSpecification | undefined = undefined; @@ -133,6 +136,7 @@ export function createDirectiveSpecification({ } composition = { supergraphSpecification, + useJoinDirective: useJoinDirective ?? false, argumentsMerger, staticArgumentTransform, }; diff --git a/internals-js/src/federation.ts b/internals-js/src/federation.ts index 5bf83bef5..8aaf6456f 100644 --- a/internals-js/src/federation.ts +++ b/internals-js/src/federation.ts @@ -1386,6 +1386,10 @@ export class FederationMetadata { return this.getPost20FederationDirective(FederationDirectiveName.LIST_SIZE); } + cacheTagDirective(): Post20FederationDirectiveDefinition<{format: string}> { + return this.getPost20FederationDirective(FederationDirectiveName.CACHE_TAG); + } + allFederationDirectives(): DirectiveDefinition[] { const baseDirectives: DirectiveDefinition[] = [ this.keyDirective(), @@ -1446,6 +1450,10 @@ export class FederationMetadata { baseDirectives.push(listSizeDirective); } + const cacheTagDirective = this.cacheTagDirective(); + if (isFederationDirectiveDefinedInSchema(cacheTagDirective)) { + baseDirectives.push(cacheTagDirective); + } return baseDirectives; } @@ -1954,7 +1962,7 @@ export function setSchemaAsFed2Subgraph(schema: Schema, useLatest: boolean = fal // This is the full @link declaration as added by `asFed2SubgraphDocument`. It's here primarily for uses by tests that print and match // subgraph schema to avoid having to update 20+ tests every time we use a new directive or the order of import changes ... -export const FEDERATION2_LINK_WITH_FULL_IMPORTS = '@link(url: "https://specs.apollo.dev/federation/v2.12", import: ["@key", "@requires", "@provides", "@external", "@tag", "@extends", "@shareable", "@inaccessible", "@override", "@composeDirective", "@interfaceObject", "@authenticated", "@requiresScopes", "@policy", "@context", "@fromContext", "@cost", "@listSize"])'; +export const FEDERATION2_LINK_WITH_FULL_IMPORTS = '@link(url: "https://specs.apollo.dev/federation/v2.12", import: ["@key", "@requires", "@provides", "@external", "@tag", "@extends", "@shareable", "@inaccessible", "@override", "@composeDirective", "@interfaceObject", "@authenticated", "@requiresScopes", "@policy", "@context", "@fromContext", "@cost", "@listSize", "@cacheTag"])'; // This is the full @link declaration that is added when upgrading fed v1 subgraphs to v2 version. It should only be used by tests. export const FEDERATION2_LINK_WITH_AUTO_EXPANDED_IMPORTS = '@link(url: "https://specs.apollo.dev/federation/v2.12", import: ["@key", "@requires", "@provides", "@external", "@tag", "@extends", "@shareable", "@inaccessible", "@override", "@composeDirective", "@interfaceObject"])'; diff --git a/internals-js/src/index.ts b/internals-js/src/index.ts index d0f282966..df3071624 100644 --- a/internals-js/src/index.ts +++ b/internals-js/src/index.ts @@ -26,3 +26,4 @@ export * from './specs/requiresScopesSpec'; export * from './specs/policySpec'; export * from './specs/connectSpec'; export * from './specs/costSpec'; +export * from './specs/cacheTagSpec'; diff --git a/internals-js/src/specs/cacheTagSpec.ts b/internals-js/src/specs/cacheTagSpec.ts new file mode 100644 index 000000000..7c4b39fe9 --- /dev/null +++ b/internals-js/src/specs/cacheTagSpec.ts @@ -0,0 +1,40 @@ +// This `cacheTag` spec is a supergraph-only feature spec to indicate that some of the subgraphs +// use the `@cacheTag` directive. The `@cacheTag` directive itself is not used in supergraph +// schema, since `@cacheTag` directive applications are composed using the `@join__directive` +// directive. +import { + CorePurpose, + FeatureDefinition, + FeatureDefinitions, + FeatureUrl, + FeatureVersion, +} from "./coreSpec"; + +export const CACHE_TAG = 'cacheTag'; + +export class CacheTagSpecDefinition extends FeatureDefinition { + public static readonly specName = CACHE_TAG; + public static readonly identity = `https://specs.apollo.dev/${CacheTagSpecDefinition.specName}`; + + constructor(version: FeatureVersion, minimumFederationVersion?: FeatureVersion) { + super( + new FeatureUrl( + CacheTagSpecDefinition.identity, + CacheTagSpecDefinition.specName, + version + ), + minimumFederationVersion, + ); + } + + get defaultCorePurpose(): CorePurpose { + return 'EXECUTION'; + } +} + +export const CACHE_TAG_VERSIONS = + new FeatureDefinitions( + CacheTagSpecDefinition.identity + ).add( + new CacheTagSpecDefinition(new FeatureVersion(0, 1)), + ); diff --git a/internals-js/src/specs/federationSpec.ts b/internals-js/src/specs/federationSpec.ts index 80ecc2159..6d2b73111 100644 --- a/internals-js/src/specs/federationSpec.ts +++ b/internals-js/src/specs/federationSpec.ts @@ -20,6 +20,7 @@ import { REQUIRES_SCOPES_VERSIONS } from "./requiresScopesSpec"; import { POLICY_VERSIONS } from './policySpec'; import { CONTEXT_VERSIONS } from './contextSpec'; import { COST_VERSIONS } from "./costSpec"; +import { CACHE_TAG_VERSIONS, CACHE_TAG as CACHE_TAG_DIRECTIVE_NAME } from "./cacheTagSpec"; export const federationIdentity = 'https://specs.apollo.dev/federation'; @@ -47,6 +48,7 @@ export enum FederationDirectiveName { FROM_CONTEXT = 'fromContext', COST = 'cost', LIST_SIZE = 'listSize', + CACHE_TAG = CACHE_TAG_DIRECTIVE_NAME, } const fieldSetTypeSpec = createScalarTypeSpecification({ name: FederationTypeName.FIELD_SET }); @@ -181,6 +183,18 @@ export class FederationSpecDefinition extends FeatureDefinition { if (version.gte(new FeatureVersion(2, 9))) { this.registerSubFeature(COST_VERSIONS.find(new FeatureVersion(0, 1))!); } + + if (version.gte(new FeatureVersion(2, 12))) { + this.registerDirective(createDirectiveSpecification({ + name: FederationDirectiveName.CACHE_TAG, + locations: [DirectiveLocation.OBJECT, DirectiveLocation.FIELD_DEFINITION], + repeatable: true, + args: [{ name: 'format', type: (schema) => new NonNullType(schema.stringType()) }], + composes: true, + supergraphSpecification: (fedVersion) => CACHE_TAG_VERSIONS.getMinimumRequiredVersion(fedVersion), + useJoinDirective: true, + })); + } } } diff --git a/internals-js/src/supergraphs.ts b/internals-js/src/supergraphs.ts index a4d637329..deb386caa 100644 --- a/internals-js/src/supergraphs.ts +++ b/internals-js/src/supergraphs.ts @@ -44,6 +44,7 @@ export const ROUTER_SUPPORTED_SUPERGRAPH_FEATURES = new Set([ 'https://specs.apollo.dev/context/v0.1', 'https://specs.apollo.dev/cost/v0.1', 'https://specs.apollo.dev/connect/v0.1', + 'https://specs.apollo.dev/cacheTag/v0.1', ]); const coreVersionZeroDotOneUrl = FeatureUrl.parse('https://specs.apollo.dev/core/v0.1');