From 8d177514782bc9addc57b9fb36e4d8d9fe08acbb Mon Sep 17 00:00:00 2001 From: Benjamin <5719034+bnjjj@users.noreply.github.com> Date: Wed, 11 Jun 2025 15:26:40 +0200 Subject: [PATCH 01/13] add cacheKey specs Signed-off-by: Benjamin <5719034+bnjjj@users.noreply.github.com> --- composition-js/src/__tests__/cachekey.test.ts | 759 ++++++++++++++++++ composition-js/src/merging/merge.ts | 2 + internals-js/src/index.ts | 1 + internals-js/src/specs/cacheKeySpec.ts | 78 ++ 4 files changed, 840 insertions(+) create mode 100644 composition-js/src/__tests__/cachekey.test.ts create mode 100644 internals-js/src/specs/cacheKeySpec.ts diff --git a/composition-js/src/__tests__/cachekey.test.ts b/composition-js/src/__tests__/cachekey.test.ts new file mode 100644 index 000000000..21b444e0a --- /dev/null +++ b/composition-js/src/__tests__/cachekey.test.ts @@ -0,0 +1,759 @@ +import { composeServices } from "../compose"; +import { printSchema } from "@apollo/federation-internals"; +import { parse } from "graphql/index"; + +describe("cacheKey spec and join__directive", () => { + it("composes", () => { + const subgraphs = [ + { + name: "products", + typeDefs: parse(` + extend schema + @link( + url: "https://specs.apollo.dev/federation/v2.11" + import: ["@key"] + ) + @link( + url: "https://specs.apollo.dev/cacheKey/v0.1" + import: ["@cacheKey"] + ) + + type Query { + resources: [Resource!]! @cacheKey(format: "resources", cascade: true) + } + + type Resource @key(fields: "id") @cacheKey(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/cacheKey/v0.1\\", for: EXECUTION) + @join__directive(graphs: [PRODUCTS], name: \\"link\\", args: {url: \\"https://specs.apollo.dev/cacheKey/v0.1\\", import: [\\"@cacheKey\\"]}) + { + 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: \\"cacheKey\\", args: {format: \\"resources\\", cascade: true}) + } + + type Resource + @join__type(graph: PRODUCTS, key: \\"id\\") + @join__directive(graphs: [PRODUCTS], name: \\"cacheKey\\", 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.11" + import: ["@key"] + ) + @link( + url: "https://specs.apollo.dev/cacheKey/v0.1" + import: ["@cacheKey"] + ) + + type Query { + resources: [Resource!]! @cacheKey(format: "resources", cascade: true) + } + + type Resource @key(fields: "id") @cacheKey(format: "resource-{$key.id}") { + id: ID! + name: String! + } + `), + }, + { + name: "reviews", + typeDefs: parse(` + extend schema + @link( + url: "https://specs.apollo.dev/federation/v2.11" + import: ["@key"] + ) + @link( + url: "https://specs.apollo.dev/cacheKey/v0.1" + import: ["@cacheKey"] + ) + + type Resource @key(fields: "id") @cacheKey(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/cacheKey/v0.1\\", for: EXECUTION) + @join__directive(graphs: [PRODUCTS, REVIEWS], name: \\"link\\", args: {url: \\"https://specs.apollo.dev/cacheKey/v0.1\\", import: [\\"@cacheKey\\"]}) + { + 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: \\"cacheKey\\", args: {format: \\"resources\\", cascade: true}) + } + + type Resource + @join__type(graph: PRODUCTS, key: \\"id\\") + @join__type(graph: REVIEWS, key: \\"id\\") + @join__directive(graphs: [PRODUCTS, REVIEWS], name: \\"cacheKey\\", 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("using as:", () => { + // const subgraphs = [ + // { + // name: "with-connectors", + // typeDefs: parse(` + // extend schema + // @link( + // url: "https://specs.apollo.dev/federation/v2.10" + // import: ["@key"] + // ) + // @link( + // url: "https://specs.apollo.dev/connect/v0.1" + // as: "http" + // import: ["@source"] + // ) + // @source(name: "v1", http: { baseURL: "http://v1" }) + + // type Query { + // resources: [Resource!]! + // @http(source: "v1", http: { GET: "/resources" }, selection: "") + // } + + // type Resource @key(fields: "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/connect/v0.2\\", for: EXECUTION) + // @join__directive(graphs: [WITH_CONNECTORS], name: \\"link\\", args: {url: \\"https://specs.apollo.dev/connect/v0.1\\", as: \\"http\\", import: [\\"@source\\"]}) + // @join__directive(graphs: [WITH_CONNECTORS], name: \\"source\\", args: {name: \\"v1\\", http: {baseURL: \\"http://v1\\"}}) + // { + // 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 { + // WITH_CONNECTORS @join__graph(name: \\"with-connectors\\", 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: WITH_CONNECTORS) + // { + // resources: [Resource!]! @join__directive(graphs: [WITH_CONNECTORS], name: \\"http\\", args: {source: \\"v1\\", http: {GET: \\"/resources\\"}, selection: \\"\\"}) + // } + + // type Resource + // @join__type(graph: WITH_CONNECTORS, 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 v0.2", () => { + // const subgraphs = [ + // { + // name: "with-connectors-v0_2", + // typeDefs: parse(` + // extend schema + // @link( + // url: "https://specs.apollo.dev/federation/v2.11" + // import: ["@key"] + // ) + // @link( + // url: "https://specs.apollo.dev/connect/v0.2" + // import: ["@connect", "@source"] + // ) + // @source( + // name: "v1" + // http: { + // baseURL: "http://v1" + // path: "" + // queryParams: "" + // } + // errors: { message: "" extensions: "" } + // ) + + // type Query { + // resources: [Resource!]! + // @connect(source: "v1", http: { GET: "/resources" }, selection: "") + // } + + // type Resource @key(fields: "id") + // @connect( + // source: "v1" + // http: { + // GET: "/resources" + // path: "" + // queryParams: "" + // } + // batch: { maxSize: 5 } + // errors: { message: "" extensions: "" } + // selection: "" + // ) { + // id: ID! + // name: String! + // } + // `), + // }, + // { + // name: "with-connectors-v0_1", + // typeDefs: parse(` + // extend schema + // @link( + // url: "https://specs.apollo.dev/federation/v2.10" + // import: ["@key"] + // ) + // @link( + // url: "https://specs.apollo.dev/connect/v0.1" + // import: ["@connect", "@source"] + // ) + // @source(name: "v1", http: { baseURL: "http://v1" }) + + // type Query { + // widgets: [Widget!]! + // @connect(source: "v1", http: { GET: "/widgets" }, selection: "") + // } + + // type Widget @key(fields: "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/connect/v0.2\\", for: EXECUTION) + // @join__directive(graphs: [WITH_CONNECTORS_V0_1_], name: \\"link\\", args: {url: \\"https://specs.apollo.dev/connect/v0.1\\", import: [\\"@connect\\", \\"@source\\"]}) + // @join__directive(graphs: [WITH_CONNECTORS_V0_2_], name: \\"link\\", args: {url: \\"https://specs.apollo.dev/connect/v0.2\\", import: [\\"@connect\\", \\"@source\\"]}) + // @join__directive(graphs: [WITH_CONNECTORS_V0_1_], name: \\"source\\", args: {name: \\"v1\\", http: {baseURL: \\"http://v1\\"}}) + // @join__directive(graphs: [WITH_CONNECTORS_V0_2_], name: \\"source\\", args: {name: \\"v1\\", http: {baseURL: \\"http://v1\\", path: \\"\\", queryParams: \\"\\"}, errors: {message: \\"\\", extensions: \\"\\"}}) + // { + // 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 { + // WITH_CONNECTORS_V0_1_ @join__graph(name: \\"with-connectors-v0_1\\", url: \\"\\") + // WITH_CONNECTORS_V0_2_ @join__graph(name: \\"with-connectors-v0_2\\", 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: WITH_CONNECTORS_V0_1_) + // @join__type(graph: WITH_CONNECTORS_V0_2_) + // { + // widgets: [Widget!]! @join__field(graph: WITH_CONNECTORS_V0_1_) @join__directive(graphs: [WITH_CONNECTORS_V0_1_], name: \\"connect\\", args: {source: \\"v1\\", http: {GET: \\"/widgets\\"}, selection: \\"\\"}) + // resources: [Resource!]! @join__field(graph: WITH_CONNECTORS_V0_2_) @join__directive(graphs: [WITH_CONNECTORS_V0_2_], name: \\"connect\\", args: {source: \\"v1\\", http: {GET: \\"/resources\\"}, selection: \\"\\"}) + // } + + // type Widget + // @join__type(graph: WITH_CONNECTORS_V0_1_, key: \\"id\\") + // { + // id: ID! + // name: String! + // } + + // type Resource + // @join__type(graph: WITH_CONNECTORS_V0_2_, key: \\"id\\") + // @join__directive(graphs: [WITH_CONNECTORS_V0_2_], name: \\"connect\\", args: {source: \\"v1\\", http: {GET: \\"/resources\\", path: \\"\\", queryParams: \\"\\"}, batch: {maxSize: 5}, errors: {message: \\"\\", extensions: \\"\\"}, selection: \\"\\"}) + // { + // id: ID! + // name: String! + // }" + // `); + + // if (result.schema) { + // expect(printSchema(result.schema.toAPISchema())).toMatchInlineSnapshot(` + // "type Query { + // widgets: [Widget!]! + // resources: [Resource!]! + // } + + // type Widget { + // id: ID! + // name: String! + // } + + // type Resource { + // id: ID! + // name: String! + // }" + // `); + // } + // }); + + // it("composes with renames", () => { + // const subgraphs = [ + // { + // name: "with-connectors", + // typeDefs: parse(` + // extend schema + // @link( + // url: "https://specs.apollo.dev/federation/v2.10" + // import: ["@key"] + // ) + // @link( + // url: "https://specs.apollo.dev/connect/v0.1" + // as: "http" + // import: [ + // { name: "@connect", as: "@http" } + // { name: "@source", as: "@api" } + // ] + // ) + // @api(name: "v1", http: { baseURL: "http://v1" }) + + // type Query { + // resources: [Resource!]! + // @http(source: "v1", http: { GET: "/resources" }, selection: "") + // } + + // type Resource @key(fields: "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/connect/v0.2\\", for: EXECUTION) + // @join__directive(graphs: [WITH_CONNECTORS], name: \\"link\\", args: {url: \\"https://specs.apollo.dev/connect/v0.1\\", as: \\"http\\", import: [{name: \\"@connect\\", as: \\"@http\\"}, {name: \\"@source\\", as: \\"@api\\"}]}) + // @join__directive(graphs: [WITH_CONNECTORS], name: \\"api\\", args: {name: \\"v1\\", http: {baseURL: \\"http://v1\\"}}) + // { + // 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 { + // WITH_CONNECTORS @join__graph(name: \\"with-connectors\\", 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: WITH_CONNECTORS) + // { + // resources: [Resource!]! @join__directive(graphs: [WITH_CONNECTORS], name: \\"http\\", args: {source: \\"v1\\", http: {GET: \\"/resources\\"}, selection: \\"\\"}) + // } + + // type Resource + // @join__type(graph: WITH_CONNECTORS, 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("requires the http arg for @source", () => { + // const subgraphs = [ + // { + // name: "with-connectors", + // typeDefs: parse(` + // extend schema + // @link( + // url: "https://specs.apollo.dev/federation/v2.10" + // import: ["@key"] + // ) + // @link( + // url: "https://specs.apollo.dev/connect/v0.1" + // import: ["@connect", "@source"] + // ) + // @source(name: "v1") + + // type Query { + // resources: [Resource!]! + // @connect(source: "v1", http: { GET: "/resources" }, selection: "") + // } + + // type Resource { + // id: ID! + // name: String! + // } + // `), + // }, + // ]; + + // const result = composeServices(subgraphs); + // expect(result.errors?.length).toBe(1); + // const error = result.errors![0]; + // expect(error.message).toEqual( + // '[with-connectors] Directive "@source" argument "http" of type "connect__SourceHTTP!" is required, but it was not provided.' + // ); + // expect(error.extensions.code).toEqual("INVALID_GRAPHQL"); + // }); + + // it("requires the http arg for @connect", () => { + // const subgraphs = [ + // { + // name: "with-connectors", + // typeDefs: parse(` + // extend schema + // @link( + // url: "https://specs.apollo.dev/federation/v2.10" + // import: ["@key"] + // ) + // @link( + // url: "https://specs.apollo.dev/connect/v0.1" + // import: ["@connect", "@source"] + // ) + // @source(name: "v1", http: {baseURL: "http://127.0.0.1"}) + + // type Query { + // resources: [Resource!]! + // @connect(source: "v1", selection: "") + // } + + // type Resource { + // id: ID! + // name: String! + // } + // `), + // }, + // ]; + + // const result = composeServices(subgraphs); + // expect(result.errors?.length).toBe(1); + // const error = result.errors![0]; + // expect(error.message).toEqual( + // '[with-connectors] Directive "@connect" argument "http" of type "connect__ConnectHTTP!" is required, but it was not provided.' + // ); + // expect(error.extensions.code).toEqual("INVALID_GRAPHQL"); + // }); +}); diff --git a/composition-js/src/merging/merge.ts b/composition-js/src/merging/merge.ts index d6f7d4915..18a72cb16 100644 --- a/composition-js/src/merging/merge.ts +++ b/composition-js/src/merging/merge.ts @@ -86,6 +86,7 @@ import { inaccessibleIdentity, FeatureDefinitions, CONNECT_VERSIONS, + CACHE_KEY_VERSIONS, } from "@apollo/federation-internals"; import { ASTNode, GraphQLError, DirectiveLocation } from "graphql"; import { @@ -418,6 +419,7 @@ class Merger { // Represent any applications of directives imported from these spec URLs // using @join__directive in the merged supergraph. this.joinDirectiveFeatureDefinitionsByIdentity.set(CONNECT_VERSIONS.identity, CONNECT_VERSIONS); + this.joinDirectiveFeatureDefinitionsByIdentity.set(CACHE_KEY_VERSIONS.identity, CACHE_KEY_VERSIONS); } private getLatestFederationVersionUsed(): FeatureVersion { diff --git a/internals-js/src/index.ts b/internals-js/src/index.ts index d0f282966..3cd25ef27 100644 --- a/internals-js/src/index.ts +++ b/internals-js/src/index.ts @@ -25,4 +25,5 @@ export * from './specs/authenticatedSpec'; export * from './specs/requiresScopesSpec'; export * from './specs/policySpec'; export * from './specs/connectSpec'; +export * from './specs/cacheKeySpec'; export * from './specs/costSpec'; diff --git a/internals-js/src/specs/cacheKeySpec.ts b/internals-js/src/specs/cacheKeySpec.ts new file mode 100644 index 000000000..79ab3e83d --- /dev/null +++ b/internals-js/src/specs/cacheKeySpec.ts @@ -0,0 +1,78 @@ +import { DirectiveLocation, GraphQLError } from 'graphql'; +import { + CorePurpose, + FeatureDefinition, + FeatureDefinitions, + FeatureUrl, + FeatureVersion, +} from './coreSpec'; +import { Schema } from '../definitions'; +import { registerKnownFeature } from '../knownCoreFeatures'; +import { createDirectiveSpecification } from '../directiveAndTypeSpecification'; + +export const cacheKeyIdentity = 'https://specs.apollo.dev/cacheKey'; + +const CACHE_KEY = 'cacheKey'; +const FORMAT = 'format'; +const CASCADE = 'cascade'; + +export class CacheKeySpecDefinition extends FeatureDefinition { + constructor( + version: FeatureVersion, + readonly minimumFederationVersion: FeatureVersion, + ) { + super( + new FeatureUrl(cacheKeyIdentity, CACHE_KEY, version), + minimumFederationVersion, + ); + + this.registerDirective( + createDirectiveSpecification({ + name: CACHE_KEY, + locations: [ + DirectiveLocation.FIELD_DEFINITION, + DirectiveLocation.OBJECT, + ], + repeatable: true, + // We "compose" these directives using the `@join__directive` mechanism, + // so they do not need to be composed in the way passing `composes: true` + // here implies. + composes: false, + }), + ); + } + + addElementsToSchema(schema: Schema): GraphQLError[] { + /* + directive @cacheKey( + format: String + cascade: Boolean = false + ) repeatable on FIELD_DEFINITION + | OBJECT + */ + const cacheKey = this.addDirective(schema, CACHE_KEY).addLocations( + DirectiveLocation.FIELD_DEFINITION, + DirectiveLocation.OBJECT, + ); + cacheKey.repeatable = true; + + cacheKey.addArgument(FORMAT, schema.stringType()); + cacheKey.addArgument(CASCADE, schema.booleanType(), false); + + return []; + } + + get defaultCorePurpose(): CorePurpose { + return 'EXECUTION'; + } +} + +export const CACHE_KEY_VERSIONS = + new FeatureDefinitions(cacheKeyIdentity).add( + new CacheKeySpecDefinition( + new FeatureVersion(0, 1), + new FeatureVersion(2, 12), + ), + ); + +registerKnownFeature(CACHE_KEY_VERSIONS); From 24d1c8e95029e339f49e08aa727fcfadc6a199b2 Mon Sep 17 00:00:00 2001 From: Benjamin <5719034+bnjjj@users.noreply.github.com> Date: Mon, 23 Jun 2025 14:28:36 +0200 Subject: [PATCH 02/13] add support of cacheTag directive Signed-off-by: Benjamin <5719034+bnjjj@users.noreply.github.com> --- composition-js/src/__tests__/cachekey.test.ts | 40 +++++++++---------- internals-js/src/index.ts | 2 +- .../{cacheKeySpec.ts => cacheTagSpec.ts} | 23 +++++------ 3 files changed, 31 insertions(+), 34 deletions(-) rename internals-js/src/specs/{cacheKeySpec.ts => cacheTagSpec.ts} (70%) diff --git a/composition-js/src/__tests__/cachekey.test.ts b/composition-js/src/__tests__/cachekey.test.ts index 21b444e0a..d84e07817 100644 --- a/composition-js/src/__tests__/cachekey.test.ts +++ b/composition-js/src/__tests__/cachekey.test.ts @@ -2,7 +2,7 @@ import { composeServices } from "../compose"; import { printSchema } from "@apollo/federation-internals"; import { parse } from "graphql/index"; -describe("cacheKey spec and join__directive", () => { +describe("cacheTag spec and join__directive", () => { it("composes", () => { const subgraphs = [ { @@ -14,15 +14,15 @@ describe("cacheKey spec and join__directive", () => { import: ["@key"] ) @link( - url: "https://specs.apollo.dev/cacheKey/v0.1" - import: ["@cacheKey"] + url: "https://specs.apollo.dev/cacheTag/v0.1" + import: ["@cacheTag"] ) type Query { - resources: [Resource!]! @cacheKey(format: "resources", cascade: true) + resources: [Resource!]! @cacheTag(format: "resources") } - type Resource @key(fields: "id") @cacheKey(format: "resource-{$key.id}") { + type Resource @key(fields: "id") @cacheTag(format: "resource-{$key.id}") { id: ID! name: String! } @@ -37,8 +37,8 @@ describe("cacheKey spec and join__directive", () => { "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/cacheKey/v0.1\\", for: EXECUTION) - @join__directive(graphs: [PRODUCTS], name: \\"link\\", args: {url: \\"https://specs.apollo.dev/cacheKey/v0.1\\", import: [\\"@cacheKey\\"]}) + @link(url: \\"https://specs.apollo.dev/cacheTag/v0.1\\", for: EXECUTION) + @join__directive(graphs: [PRODUCTS], name: \\"link\\", args: {url: \\"https://specs.apollo.dev/cacheTag/v0.1\\", import: [\\"@cacheTag\\"]}) { query: Query } @@ -93,12 +93,12 @@ describe("cacheKey spec and join__directive", () => { type Query @join__type(graph: PRODUCTS) { - resources: [Resource!]! @join__directive(graphs: [PRODUCTS], name: \\"cacheKey\\", args: {format: \\"resources\\", cascade: true}) + resources: [Resource!]! @join__directive(graphs: [PRODUCTS], name: \\"cacheTag\\", args: {format: \\"resources\\"}) } type Resource @join__type(graph: PRODUCTS, key: \\"id\\") - @join__directive(graphs: [PRODUCTS], name: \\"cacheKey\\", args: {format: \\"resource-{$key.id}\\"}) + @join__directive(graphs: [PRODUCTS], name: \\"cacheTag\\", args: {format: \\"resource-{$key.id}\\"}) { id: ID! name: String! @@ -130,15 +130,15 @@ describe("cacheKey spec and join__directive", () => { import: ["@key"] ) @link( - url: "https://specs.apollo.dev/cacheKey/v0.1" - import: ["@cacheKey"] + url: "https://specs.apollo.dev/cacheTag/v0.1" + import: ["@cacheTag"] ) type Query { - resources: [Resource!]! @cacheKey(format: "resources", cascade: true) + resources: [Resource!]! @cacheTag(format: "resources") } - type Resource @key(fields: "id") @cacheKey(format: "resource-{$key.id}") { + type Resource @key(fields: "id") @cacheTag(format: "resource-{$key.id}") { id: ID! name: String! } @@ -153,11 +153,11 @@ describe("cacheKey spec and join__directive", () => { import: ["@key"] ) @link( - url: "https://specs.apollo.dev/cacheKey/v0.1" - import: ["@cacheKey"] + url: "https://specs.apollo.dev/cacheTag/v0.1" + import: ["@cacheTag"] ) - type Resource @key(fields: "id") @cacheKey(format: "resource-{$key.id}") { + type Resource @key(fields: "id") @cacheTag(format: "resource-{$key.id}") { id: ID! reviews: [String!]! } @@ -172,8 +172,8 @@ describe("cacheKey spec and join__directive", () => { "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/cacheKey/v0.1\\", for: EXECUTION) - @join__directive(graphs: [PRODUCTS, REVIEWS], name: \\"link\\", args: {url: \\"https://specs.apollo.dev/cacheKey/v0.1\\", import: [\\"@cacheKey\\"]}) + @link(url: \\"https://specs.apollo.dev/cacheTag/v0.1\\", for: EXECUTION) + @join__directive(graphs: [PRODUCTS, REVIEWS], name: \\"link\\", args: {url: \\"https://specs.apollo.dev/cacheTag/v0.1\\", import: [\\"@cacheTag\\"]}) { query: Query } @@ -230,13 +230,13 @@ describe("cacheKey spec and join__directive", () => { @join__type(graph: PRODUCTS) @join__type(graph: REVIEWS) { - resources: [Resource!]! @join__field(graph: PRODUCTS) @join__directive(graphs: [PRODUCTS], name: \\"cacheKey\\", args: {format: \\"resources\\", cascade: true}) + resources: [Resource!]! @join__field(graph: PRODUCTS) @join__directive(graphs: [PRODUCTS], name: \\"cacheTag\\", args: {format: \\"resources\\"}) } type Resource @join__type(graph: PRODUCTS, key: \\"id\\") @join__type(graph: REVIEWS, key: \\"id\\") - @join__directive(graphs: [PRODUCTS, REVIEWS], name: \\"cacheKey\\", args: {format: \\"resource-{$key.id}\\"}) + @join__directive(graphs: [PRODUCTS, REVIEWS], name: \\"cacheTag\\", args: {format: \\"resource-{$key.id}\\"}) { id: ID! name: String! @join__field(graph: PRODUCTS) diff --git a/internals-js/src/index.ts b/internals-js/src/index.ts index 3cd25ef27..52083c670 100644 --- a/internals-js/src/index.ts +++ b/internals-js/src/index.ts @@ -25,5 +25,5 @@ export * from './specs/authenticatedSpec'; export * from './specs/requiresScopesSpec'; export * from './specs/policySpec'; export * from './specs/connectSpec'; -export * from './specs/cacheKeySpec'; +export * from './specs/cacheTagSpec'; export * from './specs/costSpec'; diff --git a/internals-js/src/specs/cacheKeySpec.ts b/internals-js/src/specs/cacheTagSpec.ts similarity index 70% rename from internals-js/src/specs/cacheKeySpec.ts rename to internals-js/src/specs/cacheTagSpec.ts index 79ab3e83d..103f6504f 100644 --- a/internals-js/src/specs/cacheKeySpec.ts +++ b/internals-js/src/specs/cacheTagSpec.ts @@ -10,19 +10,18 @@ import { Schema } from '../definitions'; import { registerKnownFeature } from '../knownCoreFeatures'; import { createDirectiveSpecification } from '../directiveAndTypeSpecification'; -export const cacheKeyIdentity = 'https://specs.apollo.dev/cacheKey'; +export const cacheTagIdentity = 'https://specs.apollo.dev/cacheTag'; -const CACHE_KEY = 'cacheKey'; +const CACHE_KEY = 'cacheTag'; const FORMAT = 'format'; -const CASCADE = 'cascade'; -export class CacheKeySpecDefinition extends FeatureDefinition { +export class CacheTagSpecDefinition extends FeatureDefinition { constructor( version: FeatureVersion, readonly minimumFederationVersion: FeatureVersion, ) { super( - new FeatureUrl(cacheKeyIdentity, CACHE_KEY, version), + new FeatureUrl(cacheTagIdentity, CACHE_KEY, version), minimumFederationVersion, ); @@ -44,20 +43,18 @@ export class CacheKeySpecDefinition extends FeatureDefinition { addElementsToSchema(schema: Schema): GraphQLError[] { /* - directive @cacheKey( + directive @cacheTag( format: String - cascade: Boolean = false ) repeatable on FIELD_DEFINITION | OBJECT */ - const cacheKey = this.addDirective(schema, CACHE_KEY).addLocations( + const cacheTag = this.addDirective(schema, CACHE_KEY).addLocations( DirectiveLocation.FIELD_DEFINITION, DirectiveLocation.OBJECT, ); - cacheKey.repeatable = true; + cacheTag.repeatable = true; - cacheKey.addArgument(FORMAT, schema.stringType()); - cacheKey.addArgument(CASCADE, schema.booleanType(), false); + cacheTag.addArgument(FORMAT, schema.stringType()); return []; } @@ -68,8 +65,8 @@ export class CacheKeySpecDefinition extends FeatureDefinition { } export const CACHE_KEY_VERSIONS = - new FeatureDefinitions(cacheKeyIdentity).add( - new CacheKeySpecDefinition( + new FeatureDefinitions(cacheTagIdentity).add( + new CacheTagSpecDefinition( new FeatureVersion(0, 1), new FeatureVersion(2, 12), ), From 1959454d4fdd08d386f9b1200f8e430fa7dced4a Mon Sep 17 00:00:00 2001 From: Duckki Oe Date: Mon, 23 Jun 2025 20:43:18 -0700 Subject: [PATCH 03/13] revised `@cacheTag` directive to be a federation directive - bumped up the federation version to 2.12 - added some subgraph validation tests - paved a path to merge any federation directives using `@join__directive` in supergraph --- composition-js/src/__tests__/cachekey.test.ts | 28 ++----- composition-js/src/merging/merge.ts | 19 ++++- .../src/__tests__/subgraphValidation.test.ts | 37 +++++++++ internals-js/src/__tests__/testUtils.ts | 3 +- internals-js/src/federation.ts | 10 ++- internals-js/src/index.ts | 1 - internals-js/src/specs/cacheTagSpec.ts | 75 ------------------- internals-js/src/specs/federationSpec.ts | 9 +++ 8 files changed, 79 insertions(+), 103 deletions(-) delete mode 100644 internals-js/src/specs/cacheTagSpec.ts diff --git a/composition-js/src/__tests__/cachekey.test.ts b/composition-js/src/__tests__/cachekey.test.ts index d84e07817..af0641c4c 100644 --- a/composition-js/src/__tests__/cachekey.test.ts +++ b/composition-js/src/__tests__/cachekey.test.ts @@ -10,12 +10,8 @@ describe("cacheTag spec and join__directive", () => { typeDefs: parse(` extend schema @link( - url: "https://specs.apollo.dev/federation/v2.11" - import: ["@key"] - ) - @link( - url: "https://specs.apollo.dev/cacheTag/v0.1" - import: ["@cacheTag"] + url: "https://specs.apollo.dev/federation/v2.12" + import: ["@key" "@cacheTag"] ) type Query { @@ -37,8 +33,6 @@ describe("cacheTag spec and join__directive", () => { "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) - @join__directive(graphs: [PRODUCTS], name: \\"link\\", args: {url: \\"https://specs.apollo.dev/cacheTag/v0.1\\", import: [\\"@cacheTag\\"]}) { query: Query } @@ -126,12 +120,8 @@ describe("cacheTag spec and join__directive", () => { typeDefs: parse(` extend schema @link( - url: "https://specs.apollo.dev/federation/v2.11" - import: ["@key"] - ) - @link( - url: "https://specs.apollo.dev/cacheTag/v0.1" - import: ["@cacheTag"] + url: "https://specs.apollo.dev/federation/v2.12" + import: ["@key" "@cacheTag"] ) type Query { @@ -149,12 +139,8 @@ describe("cacheTag spec and join__directive", () => { typeDefs: parse(` extend schema @link( - url: "https://specs.apollo.dev/federation/v2.11" - import: ["@key"] - ) - @link( - url: "https://specs.apollo.dev/cacheTag/v0.1" - import: ["@cacheTag"] + url: "https://specs.apollo.dev/federation/v2.12" + import: ["@key" "@cacheTag"] ) type Resource @key(fields: "id") @cacheTag(format: "resource-{$key.id}") { @@ -172,8 +158,6 @@ describe("cacheTag spec and join__directive", () => { "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) - @join__directive(graphs: [PRODUCTS, REVIEWS], name: \\"link\\", args: {url: \\"https://specs.apollo.dev/cacheTag/v0.1\\", import: [\\"@cacheTag\\"]}) { query: Query } diff --git a/composition-js/src/merging/merge.ts b/composition-js/src/merging/merge.ts index 18a72cb16..04733037c 100644 --- a/composition-js/src/merging/merge.ts +++ b/composition-js/src/merging/merge.ts @@ -86,7 +86,7 @@ import { inaccessibleIdentity, FeatureDefinitions, CONNECT_VERSIONS, - CACHE_KEY_VERSIONS, + FederationDirectiveName, } from "@apollo/federation-internals"; import { ASTNode, GraphQLError, DirectiveLocation } from "graphql"; import { @@ -380,6 +380,7 @@ class Merger { private inaccessibleDirectiveInSupergraph?: DirectiveDefinition; private latestFedVersionUsed: FeatureVersion; private joinDirectiveFeatureDefinitionsByIdentity = new Map(); + private federationDirectiveUsingJoinDirective = new Set(); private schemaToImportNameToFeatureUrl = new Map>(); private fieldsWithFromContext: Set; private fieldsWithOverride: Set; @@ -419,7 +420,8 @@ class Merger { // Represent any applications of directives imported from these spec URLs // using @join__directive in the merged supergraph. this.joinDirectiveFeatureDefinitionsByIdentity.set(CONNECT_VERSIONS.identity, CONNECT_VERSIONS); - this.joinDirectiveFeatureDefinitionsByIdentity.set(CACHE_KEY_VERSIONS.identity, CACHE_KEY_VERSIONS); + // Some federation directives are translated to @join__directive in the supergraph. + this.federationDirectiveUsingJoinDirective.add(FederationDirectiveName.CACHE_TAG); } private getLatestFederationVersionUsed(): FeatureVersion { @@ -3186,9 +3188,20 @@ class Merger { // leading @. const nameWithAtSymbol = directive.name.startsWith('@') ? directive.name : '@' + directive.name; + const featureUrl = linkImportIdentityURLMap.get(nameWithAtSymbol); + // See if directives from this feature URL should use the @join__directive. shouldIncludeAsJoinDirective = this.shouldUseJoinDirectiveForURL( - linkImportIdentityURLMap.get(nameWithAtSymbol), + featureUrl ); + // See if this directive is one of the federation directives that + // should use the @join__directive. + if ( + !shouldIncludeAsJoinDirective + && featureUrl?.identity == federationIdentity + && this.federationDirectiveUsingJoinDirective.has(directive.name) + ) { + shouldIncludeAsJoinDirective = true; + } } if (shouldIncludeAsJoinDirective) { diff --git a/internals-js/src/__tests__/subgraphValidation.test.ts b/internals-js/src/__tests__/subgraphValidation.test.ts index 99c5bfdee..21566860e 100644 --- a/internals-js/src/__tests__/subgraphValidation.test.ts +++ b/internals-js/src/__tests__/subgraphValidation.test.ts @@ -1691,3 +1691,40 @@ describe('@listSize', () => { ]); }); }); + +describe('@cacheTag', () => { + it('works 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-{$arg.x}") + } + `; + 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/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 52083c670..d0f282966 100644 --- a/internals-js/src/index.ts +++ b/internals-js/src/index.ts @@ -25,5 +25,4 @@ export * from './specs/authenticatedSpec'; export * from './specs/requiresScopesSpec'; export * from './specs/policySpec'; export * from './specs/connectSpec'; -export * from './specs/cacheTagSpec'; export * from './specs/costSpec'; diff --git a/internals-js/src/specs/cacheTagSpec.ts b/internals-js/src/specs/cacheTagSpec.ts deleted file mode 100644 index 103f6504f..000000000 --- a/internals-js/src/specs/cacheTagSpec.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { DirectiveLocation, GraphQLError } from 'graphql'; -import { - CorePurpose, - FeatureDefinition, - FeatureDefinitions, - FeatureUrl, - FeatureVersion, -} from './coreSpec'; -import { Schema } from '../definitions'; -import { registerKnownFeature } from '../knownCoreFeatures'; -import { createDirectiveSpecification } from '../directiveAndTypeSpecification'; - -export const cacheTagIdentity = 'https://specs.apollo.dev/cacheTag'; - -const CACHE_KEY = 'cacheTag'; -const FORMAT = 'format'; - -export class CacheTagSpecDefinition extends FeatureDefinition { - constructor( - version: FeatureVersion, - readonly minimumFederationVersion: FeatureVersion, - ) { - super( - new FeatureUrl(cacheTagIdentity, CACHE_KEY, version), - minimumFederationVersion, - ); - - this.registerDirective( - createDirectiveSpecification({ - name: CACHE_KEY, - locations: [ - DirectiveLocation.FIELD_DEFINITION, - DirectiveLocation.OBJECT, - ], - repeatable: true, - // We "compose" these directives using the `@join__directive` mechanism, - // so they do not need to be composed in the way passing `composes: true` - // here implies. - composes: false, - }), - ); - } - - addElementsToSchema(schema: Schema): GraphQLError[] { - /* - directive @cacheTag( - format: String - ) repeatable on FIELD_DEFINITION - | OBJECT - */ - const cacheTag = this.addDirective(schema, CACHE_KEY).addLocations( - DirectiveLocation.FIELD_DEFINITION, - DirectiveLocation.OBJECT, - ); - cacheTag.repeatable = true; - - cacheTag.addArgument(FORMAT, schema.stringType()); - - return []; - } - - get defaultCorePurpose(): CorePurpose { - return 'EXECUTION'; - } -} - -export const CACHE_KEY_VERSIONS = - new FeatureDefinitions(cacheTagIdentity).add( - new CacheTagSpecDefinition( - new FeatureVersion(0, 1), - new FeatureVersion(2, 12), - ), - ); - -registerKnownFeature(CACHE_KEY_VERSIONS); diff --git a/internals-js/src/specs/federationSpec.ts b/internals-js/src/specs/federationSpec.ts index 80ecc2159..73ef672b1 100644 --- a/internals-js/src/specs/federationSpec.ts +++ b/internals-js/src/specs/federationSpec.ts @@ -47,6 +47,7 @@ export enum FederationDirectiveName { FROM_CONTEXT = 'fromContext', COST = 'cost', LIST_SIZE = 'listSize', + CACHE_TAG = 'cacheTag', } const fieldSetTypeSpec = createScalarTypeSpecification({ name: FederationTypeName.FIELD_SET }); @@ -181,6 +182,14 @@ 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.INTERFACE, DirectiveLocation.FIELD_DEFINITION], + args: [{ name: 'format', type: (schema) => new NonNullType(schema.stringType()) }], + })); + } } } From 17af9b049501e4630465f1b8d4632f05c627b50c Mon Sep 17 00:00:00 2001 From: Benjamin <5719034+bnjjj@users.noreply.github.com> Date: Thu, 26 Jun 2025 10:10:45 +0200 Subject: [PATCH 04/13] rename test file Signed-off-by: Benjamin <5719034+bnjjj@users.noreply.github.com> --- composition-js/src/__tests__/cachekey.test.ts | 743 ------------------ composition-js/src/__tests__/cachetag.test.ts | 245 ++++++ 2 files changed, 245 insertions(+), 743 deletions(-) delete mode 100644 composition-js/src/__tests__/cachekey.test.ts create mode 100644 composition-js/src/__tests__/cachetag.test.ts diff --git a/composition-js/src/__tests__/cachekey.test.ts b/composition-js/src/__tests__/cachekey.test.ts deleted file mode 100644 index af0641c4c..000000000 --- a/composition-js/src/__tests__/cachekey.test.ts +++ /dev/null @@ -1,743 +0,0 @@ -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) - { - 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: \\"cacheTag\\", args: {format: \\"resources\\"}) - } - - type Resource - @join__type(graph: PRODUCTS, key: \\"id\\") - @join__directive(graphs: [PRODUCTS], name: \\"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) - { - 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: \\"cacheTag\\", args: {format: \\"resources\\"}) - } - - type Resource - @join__type(graph: PRODUCTS, key: \\"id\\") - @join__type(graph: REVIEWS, key: \\"id\\") - @join__directive(graphs: [PRODUCTS, REVIEWS], name: \\"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("using as:", () => { - // const subgraphs = [ - // { - // name: "with-connectors", - // typeDefs: parse(` - // extend schema - // @link( - // url: "https://specs.apollo.dev/federation/v2.10" - // import: ["@key"] - // ) - // @link( - // url: "https://specs.apollo.dev/connect/v0.1" - // as: "http" - // import: ["@source"] - // ) - // @source(name: "v1", http: { baseURL: "http://v1" }) - - // type Query { - // resources: [Resource!]! - // @http(source: "v1", http: { GET: "/resources" }, selection: "") - // } - - // type Resource @key(fields: "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/connect/v0.2\\", for: EXECUTION) - // @join__directive(graphs: [WITH_CONNECTORS], name: \\"link\\", args: {url: \\"https://specs.apollo.dev/connect/v0.1\\", as: \\"http\\", import: [\\"@source\\"]}) - // @join__directive(graphs: [WITH_CONNECTORS], name: \\"source\\", args: {name: \\"v1\\", http: {baseURL: \\"http://v1\\"}}) - // { - // 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 { - // WITH_CONNECTORS @join__graph(name: \\"with-connectors\\", 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: WITH_CONNECTORS) - // { - // resources: [Resource!]! @join__directive(graphs: [WITH_CONNECTORS], name: \\"http\\", args: {source: \\"v1\\", http: {GET: \\"/resources\\"}, selection: \\"\\"}) - // } - - // type Resource - // @join__type(graph: WITH_CONNECTORS, 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 v0.2", () => { - // const subgraphs = [ - // { - // name: "with-connectors-v0_2", - // typeDefs: parse(` - // extend schema - // @link( - // url: "https://specs.apollo.dev/federation/v2.11" - // import: ["@key"] - // ) - // @link( - // url: "https://specs.apollo.dev/connect/v0.2" - // import: ["@connect", "@source"] - // ) - // @source( - // name: "v1" - // http: { - // baseURL: "http://v1" - // path: "" - // queryParams: "" - // } - // errors: { message: "" extensions: "" } - // ) - - // type Query { - // resources: [Resource!]! - // @connect(source: "v1", http: { GET: "/resources" }, selection: "") - // } - - // type Resource @key(fields: "id") - // @connect( - // source: "v1" - // http: { - // GET: "/resources" - // path: "" - // queryParams: "" - // } - // batch: { maxSize: 5 } - // errors: { message: "" extensions: "" } - // selection: "" - // ) { - // id: ID! - // name: String! - // } - // `), - // }, - // { - // name: "with-connectors-v0_1", - // typeDefs: parse(` - // extend schema - // @link( - // url: "https://specs.apollo.dev/federation/v2.10" - // import: ["@key"] - // ) - // @link( - // url: "https://specs.apollo.dev/connect/v0.1" - // import: ["@connect", "@source"] - // ) - // @source(name: "v1", http: { baseURL: "http://v1" }) - - // type Query { - // widgets: [Widget!]! - // @connect(source: "v1", http: { GET: "/widgets" }, selection: "") - // } - - // type Widget @key(fields: "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/connect/v0.2\\", for: EXECUTION) - // @join__directive(graphs: [WITH_CONNECTORS_V0_1_], name: \\"link\\", args: {url: \\"https://specs.apollo.dev/connect/v0.1\\", import: [\\"@connect\\", \\"@source\\"]}) - // @join__directive(graphs: [WITH_CONNECTORS_V0_2_], name: \\"link\\", args: {url: \\"https://specs.apollo.dev/connect/v0.2\\", import: [\\"@connect\\", \\"@source\\"]}) - // @join__directive(graphs: [WITH_CONNECTORS_V0_1_], name: \\"source\\", args: {name: \\"v1\\", http: {baseURL: \\"http://v1\\"}}) - // @join__directive(graphs: [WITH_CONNECTORS_V0_2_], name: \\"source\\", args: {name: \\"v1\\", http: {baseURL: \\"http://v1\\", path: \\"\\", queryParams: \\"\\"}, errors: {message: \\"\\", extensions: \\"\\"}}) - // { - // 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 { - // WITH_CONNECTORS_V0_1_ @join__graph(name: \\"with-connectors-v0_1\\", url: \\"\\") - // WITH_CONNECTORS_V0_2_ @join__graph(name: \\"with-connectors-v0_2\\", 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: WITH_CONNECTORS_V0_1_) - // @join__type(graph: WITH_CONNECTORS_V0_2_) - // { - // widgets: [Widget!]! @join__field(graph: WITH_CONNECTORS_V0_1_) @join__directive(graphs: [WITH_CONNECTORS_V0_1_], name: \\"connect\\", args: {source: \\"v1\\", http: {GET: \\"/widgets\\"}, selection: \\"\\"}) - // resources: [Resource!]! @join__field(graph: WITH_CONNECTORS_V0_2_) @join__directive(graphs: [WITH_CONNECTORS_V0_2_], name: \\"connect\\", args: {source: \\"v1\\", http: {GET: \\"/resources\\"}, selection: \\"\\"}) - // } - - // type Widget - // @join__type(graph: WITH_CONNECTORS_V0_1_, key: \\"id\\") - // { - // id: ID! - // name: String! - // } - - // type Resource - // @join__type(graph: WITH_CONNECTORS_V0_2_, key: \\"id\\") - // @join__directive(graphs: [WITH_CONNECTORS_V0_2_], name: \\"connect\\", args: {source: \\"v1\\", http: {GET: \\"/resources\\", path: \\"\\", queryParams: \\"\\"}, batch: {maxSize: 5}, errors: {message: \\"\\", extensions: \\"\\"}, selection: \\"\\"}) - // { - // id: ID! - // name: String! - // }" - // `); - - // if (result.schema) { - // expect(printSchema(result.schema.toAPISchema())).toMatchInlineSnapshot(` - // "type Query { - // widgets: [Widget!]! - // resources: [Resource!]! - // } - - // type Widget { - // id: ID! - // name: String! - // } - - // type Resource { - // id: ID! - // name: String! - // }" - // `); - // } - // }); - - // it("composes with renames", () => { - // const subgraphs = [ - // { - // name: "with-connectors", - // typeDefs: parse(` - // extend schema - // @link( - // url: "https://specs.apollo.dev/federation/v2.10" - // import: ["@key"] - // ) - // @link( - // url: "https://specs.apollo.dev/connect/v0.1" - // as: "http" - // import: [ - // { name: "@connect", as: "@http" } - // { name: "@source", as: "@api" } - // ] - // ) - // @api(name: "v1", http: { baseURL: "http://v1" }) - - // type Query { - // resources: [Resource!]! - // @http(source: "v1", http: { GET: "/resources" }, selection: "") - // } - - // type Resource @key(fields: "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/connect/v0.2\\", for: EXECUTION) - // @join__directive(graphs: [WITH_CONNECTORS], name: \\"link\\", args: {url: \\"https://specs.apollo.dev/connect/v0.1\\", as: \\"http\\", import: [{name: \\"@connect\\", as: \\"@http\\"}, {name: \\"@source\\", as: \\"@api\\"}]}) - // @join__directive(graphs: [WITH_CONNECTORS], name: \\"api\\", args: {name: \\"v1\\", http: {baseURL: \\"http://v1\\"}}) - // { - // 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 { - // WITH_CONNECTORS @join__graph(name: \\"with-connectors\\", 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: WITH_CONNECTORS) - // { - // resources: [Resource!]! @join__directive(graphs: [WITH_CONNECTORS], name: \\"http\\", args: {source: \\"v1\\", http: {GET: \\"/resources\\"}, selection: \\"\\"}) - // } - - // type Resource - // @join__type(graph: WITH_CONNECTORS, 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("requires the http arg for @source", () => { - // const subgraphs = [ - // { - // name: "with-connectors", - // typeDefs: parse(` - // extend schema - // @link( - // url: "https://specs.apollo.dev/federation/v2.10" - // import: ["@key"] - // ) - // @link( - // url: "https://specs.apollo.dev/connect/v0.1" - // import: ["@connect", "@source"] - // ) - // @source(name: "v1") - - // type Query { - // resources: [Resource!]! - // @connect(source: "v1", http: { GET: "/resources" }, selection: "") - // } - - // type Resource { - // id: ID! - // name: String! - // } - // `), - // }, - // ]; - - // const result = composeServices(subgraphs); - // expect(result.errors?.length).toBe(1); - // const error = result.errors![0]; - // expect(error.message).toEqual( - // '[with-connectors] Directive "@source" argument "http" of type "connect__SourceHTTP!" is required, but it was not provided.' - // ); - // expect(error.extensions.code).toEqual("INVALID_GRAPHQL"); - // }); - - // it("requires the http arg for @connect", () => { - // const subgraphs = [ - // { - // name: "with-connectors", - // typeDefs: parse(` - // extend schema - // @link( - // url: "https://specs.apollo.dev/federation/v2.10" - // import: ["@key"] - // ) - // @link( - // url: "https://specs.apollo.dev/connect/v0.1" - // import: ["@connect", "@source"] - // ) - // @source(name: "v1", http: {baseURL: "http://127.0.0.1"}) - - // type Query { - // resources: [Resource!]! - // @connect(source: "v1", selection: "") - // } - - // type Resource { - // id: ID! - // name: String! - // } - // `), - // }, - // ]; - - // const result = composeServices(subgraphs); - // expect(result.errors?.length).toBe(1); - // const error = result.errors![0]; - // expect(error.message).toEqual( - // '[with-connectors] Directive "@connect" argument "http" of type "connect__ConnectHTTP!" is required, but it was not provided.' - // ); - // expect(error.extensions.code).toEqual("INVALID_GRAPHQL"); - // }); -}); diff --git a/composition-js/src/__tests__/cachetag.test.ts b/composition-js/src/__tests__/cachetag.test.ts new file mode 100644 index 000000000..803d6aab0 --- /dev/null +++ b/composition-js/src/__tests__/cachetag.test.ts @@ -0,0 +1,245 @@ +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) + { + 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: \\"cacheTag\\", args: {format: \\"resources\\"}) + } + + type Resource + @join__type(graph: PRODUCTS, key: \\"id\\") + @join__directive(graphs: [PRODUCTS], name: \\"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) + { + 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: \\"cacheTag\\", args: {format: \\"resources\\"}) + } + + type Resource + @join__type(graph: PRODUCTS, key: \\"id\\") + @join__type(graph: REVIEWS, key: \\"id\\") + @join__directive(graphs: [PRODUCTS, REVIEWS], name: \\"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!]! + }" + `); + } + }); +}); From f5873b64401b95ca81dd7513b02b1e1cca7b7c80 Mon Sep 17 00:00:00 2001 From: Duckki Oe Date: Thu, 26 Jun 2025 21:39:04 -0700 Subject: [PATCH 05/13] fixed `addJoinDirectiveDirectives` to use fully qualified name for federation directives --- composition-js/src/__tests__/cachetag.test.ts | 8 ++++---- composition-js/src/merging/merge.ts | 6 +++++- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/composition-js/src/__tests__/cachetag.test.ts b/composition-js/src/__tests__/cachetag.test.ts index 803d6aab0..0b2707dbc 100644 --- a/composition-js/src/__tests__/cachetag.test.ts +++ b/composition-js/src/__tests__/cachetag.test.ts @@ -87,12 +87,12 @@ describe("cacheTag spec and join__directive", () => { type Query @join__type(graph: PRODUCTS) { - resources: [Resource!]! @join__directive(graphs: [PRODUCTS], name: \\"cacheTag\\", args: {format: \\"resources\\"}) + 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: \\"cacheTag\\", args: {format: \\"resource-{$key.id}\\"}) + @join__directive(graphs: [PRODUCTS], name: \\"federation__cacheTag\\", args: {format: \\"resource-{$key.id}\\"}) { id: ID! name: String! @@ -214,13 +214,13 @@ describe("cacheTag spec and join__directive", () => { @join__type(graph: PRODUCTS) @join__type(graph: REVIEWS) { - resources: [Resource!]! @join__field(graph: PRODUCTS) @join__directive(graphs: [PRODUCTS], name: \\"cacheTag\\", args: {format: \\"resources\\"}) + 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: \\"cacheTag\\", args: {format: \\"resource-{$key.id}\\"}) + @join__directive(graphs: [PRODUCTS, REVIEWS], name: \\"federation__cacheTag\\", args: {format: \\"resource-{$key.id}\\"}) { id: ID! name: String! @join__field(graph: PRODUCTS) diff --git a/composition-js/src/merging/merge.ts b/composition-js/src/merging/merge.ts index 04733037c..f8ef22817 100644 --- a/composition-js/src/merging/merge.ts +++ b/composition-js/src/merging/merge.ts @@ -3166,6 +3166,7 @@ class Merger { for (const directive of source.appliedDirectives) { let shouldIncludeAsJoinDirective = false; + let fullDirectiveName = directive.name; if (directive.name === 'link') { const { url } = directive.arguments(); @@ -3201,11 +3202,14 @@ class Merger { && this.federationDirectiveUsingJoinDirective.has(directive.name) ) { shouldIncludeAsJoinDirective = true; + // Since federation directives are not directly imported, make it + // a fully qualified name. + fullDirectiveName = `federation__${directive.name}`; } } if (shouldIncludeAsJoinDirective) { - const existingJoins = (joinsByDirectiveName[directive.name] ??= []); + const existingJoins = (joinsByDirectiveName[fullDirectiveName] ??= []); let found = false; for (const existingJoin of existingJoins) { if (valueEquals(existingJoin.args, directive.arguments())) { From 280856064d143adf3d0bdc1535571757aaaec397 Mon Sep 17 00:00:00 2001 From: Duckki Oe Date: Thu, 26 Jun 2025 21:52:10 -0700 Subject: [PATCH 06/13] reworded a confusing comment on `federationDirectiveUsingJoinDirective.add()` call --- composition-js/src/merging/merge.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composition-js/src/merging/merge.ts b/composition-js/src/merging/merge.ts index f8ef22817..4de7cc447 100644 --- a/composition-js/src/merging/merge.ts +++ b/composition-js/src/merging/merge.ts @@ -420,7 +420,7 @@ class Merger { // Represent any applications of directives imported from these spec URLs // using @join__directive in the merged supergraph. this.joinDirectiveFeatureDefinitionsByIdentity.set(CONNECT_VERSIONS.identity, CONNECT_VERSIONS); - // Some federation directives are translated to @join__directive in the supergraph. + // Following federation directives are recorded in the supergraph using @join__directive. this.federationDirectiveUsingJoinDirective.add(FederationDirectiveName.CACHE_TAG); } From eaa1442dda3eb8c6efd1edb041b18e5d7085da19 Mon Sep 17 00:00:00 2001 From: Duckki Oe Date: Fri, 27 Jun 2025 17:02:48 -0700 Subject: [PATCH 07/13] define `@cacheKey` to be repeatable --- .../src/__tests__/subgraphValidation.test.ts | 23 +++++++++++++++++-- internals-js/src/specs/federationSpec.ts | 1 + 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/internals-js/src/__tests__/subgraphValidation.test.ts b/internals-js/src/__tests__/subgraphValidation.test.ts index 21566860e..a6f1ed186 100644 --- a/internals-js/src/__tests__/subgraphValidation.test.ts +++ b/internals-js/src/__tests__/subgraphValidation.test.ts @@ -1693,7 +1693,7 @@ describe('@listSize', () => { }); describe('@cacheTag', () => { - it('works on root field', () => { + it('applies on root field', () => { const doc = gql` extend schema @link( @@ -1702,7 +1702,26 @@ describe('@cacheTag', () => { ) type Query { - f(x: Int!): String! @cacheTag(format: "query-f-{$arg.x}") + 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'; diff --git a/internals-js/src/specs/federationSpec.ts b/internals-js/src/specs/federationSpec.ts index 73ef672b1..e75e42a42 100644 --- a/internals-js/src/specs/federationSpec.ts +++ b/internals-js/src/specs/federationSpec.ts @@ -187,6 +187,7 @@ export class FederationSpecDefinition extends FeatureDefinition { this.registerDirective(createDirectiveSpecification({ name: FederationDirectiveName.CACHE_TAG, locations: [DirectiveLocation.OBJECT, DirectiveLocation.INTERFACE, DirectiveLocation.FIELD_DEFINITION], + repeatable: true, args: [{ name: 'format', type: (schema) => new NonNullType(schema.stringType()) }], })); } From 398d16b1c549154c177957aaf41d2fa98a5c5faa Mon Sep 17 00:00:00 2001 From: Duckki Oe Date: Fri, 27 Jun 2025 17:09:22 -0700 Subject: [PATCH 08/13] prettier --- internals-js/src/__tests__/subgraphValidation.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internals-js/src/__tests__/subgraphValidation.test.ts b/internals-js/src/__tests__/subgraphValidation.test.ts index a6f1ed186..c5029e45f 100644 --- a/internals-js/src/__tests__/subgraphValidation.test.ts +++ b/internals-js/src/__tests__/subgraphValidation.test.ts @@ -1716,7 +1716,7 @@ describe('@cacheTag', () => { extend schema @link( url: "https://specs.apollo.dev/federation/v2.12" - import: ["@key" "@cacheTag"] + import: ["@key", "@cacheTag"] ) type P @key(fields: "id") @cacheTag(format: "p-{$.id}") { From 461967c8f1d3285dfd412897cb5dcf2050ce3dd5 Mon Sep 17 00:00:00 2001 From: Duckki Oe Date: Tue, 1 Jul 2025 16:45:59 -0700 Subject: [PATCH 09/13] added the supergraph-only link spec "cacheTag" - to indicate the supergraph uses `@federation__cacheTag` directive (wrapped in `@join__directive`). - The @cacheTag itself is not directly used in supergraphs. - added `useJoinDirective` option for directive specifications, so spec link can be added in supergraph while directives are composed using `@join__directive`. - fixed feature url computation logic in `addJoinDirectiveDirectives` to use `Schema.coreFeatures`. - fixed link identity check logic in `addJoinDirectiveDirectives`. --- composition-js/src/__tests__/cachetag.test.ts | 6 ++ composition-js/src/merging/merge.ts | 99 +++++++------------ .../src/directiveAndTypeSpecification.ts | 4 + internals-js/src/index.ts | 1 + internals-js/src/specs/cacheTagSpec.ts | 56 +++++++++++ internals-js/src/specs/federationSpec.ts | 10 +- 6 files changed, 105 insertions(+), 71 deletions(-) create mode 100644 internals-js/src/specs/cacheTagSpec.ts diff --git a/composition-js/src/__tests__/cachetag.test.ts b/composition-js/src/__tests__/cachetag.test.ts index 0b2707dbc..2c2a3074d 100644 --- a/composition-js/src/__tests__/cachetag.test.ts +++ b/composition-js/src/__tests__/cachetag.test.ts @@ -33,6 +33,7 @@ describe("cacheTag spec and join__directive", () => { "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 } @@ -53,6 +54,8 @@ describe("cacheTag spec and join__directive", () => { directive @join__directive(graphs: [join__Graph!], name: String!, args: join__DirectiveArguments) repeatable on SCHEMA | OBJECT | INTERFACE | FIELD_DEFINITION + directive @cacheTag(format: String!) repeatable on OBJECT | INTERFACE | FIELD_DEFINITION + enum link__Purpose { \\"\\"\\" \`SECURITY\` features provide metadata necessary to securely resolve fields. @@ -158,6 +161,7 @@ describe("cacheTag spec and join__directive", () => { "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 } @@ -178,6 +182,8 @@ describe("cacheTag spec and join__directive", () => { directive @join__directive(graphs: [join__Graph!], name: String!, args: join__DirectiveArguments) repeatable on SCHEMA | OBJECT | INTERFACE | FIELD_DEFINITION + directive @cacheTag(format: String!) repeatable on OBJECT | INTERFACE | FIELD_DEFINITION + enum link__Purpose { \\"\\"\\" \`SECURITY\` features provide metadata necessary to securely resolve fields. diff --git a/composition-js/src/merging/merge.ts b/composition-js/src/merging/merge.ts index 4de7cc447..c6cf88ef2 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, @@ -86,7 +85,6 @@ import { inaccessibleIdentity, FeatureDefinitions, CONNECT_VERSIONS, - FederationDirectiveName, } from "@apollo/federation-internals"; import { ASTNode, GraphQLError, DirectiveLocation } from "graphql"; import { @@ -380,8 +378,7 @@ class Merger { private inaccessibleDirectiveInSupergraph?: DirectiveDefinition; private latestFedVersionUsed: FeatureVersion; private joinDirectiveFeatureDefinitionsByIdentity = new Map(); - private federationDirectiveUsingJoinDirective = new Set(); - private schemaToImportNameToFeatureUrl = new Map>(); + private directiveUsingJoinDirective = new Set(); private fieldsWithFromContext: Set; private fieldsWithOverride: Set; @@ -404,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(); @@ -420,8 +411,6 @@ class Merger { // Represent any applications of directives imported from these spec URLs // using @join__directive in the merged supergraph. this.joinDirectiveFeatureDefinitionsByIdentity.set(CONNECT_VERSIONS.identity, CONNECT_VERSIONS); - // Following federation directives are recorded in the supergraph using @join__directive. - this.federationDirectiveUsingJoinDirective.add(FederationDirectiveName.CACHE_TAG); } private getLatestFederationVersionUsed(): FeatureVersion { @@ -550,6 +539,9 @@ class Merger { nameInSupergraph, compositionSpec, }); + if (compositionSpec.useJoinDirective) { + this.directiveUsingJoinDirective.add(nameInSupergraph); + } } } @@ -2970,6 +2962,12 @@ class Merger { } private mergeAppliedDirective(name: string, sources: Sources>, dest: SchemaElement) { + if (this.directiveUsingJoinDirective.has(name)) { + // This directive will be added as `@join__directive` by the `addJoinDirectiveDirectives` + // method. So, skip the normal merging logic here. + return; + } + // TODO: we currently "only" merge together applications that have the exact same arguments (with defaults expanded however), // but when an argument is an input object type, we should (?) ignore those fields that will not be included in the supergraph // due the intersection merging of input types, otherwise the merged value may be invalid for the supergraph. @@ -3112,33 +3110,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. @@ -3157,18 +3128,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; - let fullDirectiveName = directive.name; + // `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) { @@ -3184,32 +3155,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; - const featureUrl = linkImportIdentityURLMap.get(nameWithAtSymbol); // See if directives from this feature URL should use the @join__directive. shouldIncludeAsJoinDirective = this.shouldUseJoinDirectiveForURL( - featureUrl + sourceFeature?.feature.url ); - // See if this directive is one of the federation directives that - // should use the @join__directive. + // See if this directive is one of the directives that should use the @join__directive. if ( !shouldIncludeAsJoinDirective - && featureUrl?.identity == federationIdentity - && this.federationDirectiveUsingJoinDirective.has(directive.name) + && this.directiveUsingJoinDirective.has(directive.name) ) { shouldIncludeAsJoinDirective = true; - // Since federation directives are not directly imported, make it - // a fully qualified name. - fullDirectiveName = `federation__${directive.name}`; + 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[fullDirectiveName] ??= []); + const existingJoins = (joinsByDirectiveName[directiveNameForJoinDirective] ??= []); let found = false; for (const existingJoin of existingJoins) { if (valueEquals(existingJoin.args, directive.arguments())) { diff --git a/internals-js/src/directiveAndTypeSpecification.ts b/internals-js/src/directiveAndTypeSpecification.ts index 57b728dcc..6e478ae79 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 = undefined, 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, argumentsMerger, staticArgumentTransform, }; 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..7449d560a --- /dev/null +++ b/internals-js/src/specs/cacheTagSpec.ts @@ -0,0 +1,56 @@ +// 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 { DirectiveLocation } from "graphql"; +import { + CorePurpose, + FeatureDefinition, + FeatureDefinitions, + FeatureUrl, + FeatureVersion, +} from "./coreSpec"; +import { NonNullType } from "../definitions"; +import { createDirectiveSpecification } from "../directiveAndTypeSpecification"; + +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, + ); + + this.registerDirective(createDirectiveSpecification({ + name: CACHE_TAG, + locations: [DirectiveLocation.OBJECT, DirectiveLocation.INTERFACE, 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, + })); + } + + get defaultCorePurpose(): CorePurpose { + return 'EXECUTION'; + } +} + +export const CACHE_TAG_VERSIONS = + new FeatureDefinitions( + CacheTagSpecDefinition.identity + ).add( + new CacheTagSpecDefinition( + new FeatureVersion(0, 1), + new FeatureVersion(2, 12), + ), + ); diff --git a/internals-js/src/specs/federationSpec.ts b/internals-js/src/specs/federationSpec.ts index e75e42a42..d02a33bc3 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,7 +48,7 @@ export enum FederationDirectiveName { FROM_CONTEXT = 'fromContext', COST = 'cost', LIST_SIZE = 'listSize', - CACHE_TAG = 'cacheTag', + CACHE_TAG = CACHE_TAG_DIRECTIVE_NAME, } const fieldSetTypeSpec = createScalarTypeSpecification({ name: FederationTypeName.FIELD_SET }); @@ -184,12 +185,7 @@ export class FederationSpecDefinition extends FeatureDefinition { } if (version.gte(new FeatureVersion(2, 12))) { - this.registerDirective(createDirectiveSpecification({ - name: FederationDirectiveName.CACHE_TAG, - locations: [DirectiveLocation.OBJECT, DirectiveLocation.INTERFACE, DirectiveLocation.FIELD_DEFINITION], - repeatable: true, - args: [{ name: 'format', type: (schema) => new NonNullType(schema.stringType()) }], - })); + this.registerSubFeature(CACHE_TAG_VERSIONS.find(new FeatureVersion(0, 1))!); } } } From c424fcc8a00a9fe0a203d1da6ca53b90f587adde Mon Sep 17 00:00:00 2001 From: Duckki Oe Date: Tue, 1 Jul 2025 21:33:19 -0700 Subject: [PATCH 10/13] added a @cacheTag directive renaming test --- composition-js/src/__tests__/cachetag.test.ts | 113 ++++++++++++++++++ 1 file changed, 113 insertions(+) diff --git a/composition-js/src/__tests__/cachetag.test.ts b/composition-js/src/__tests__/cachetag.test.ts index 2c2a3074d..8c21a4287 100644 --- a/composition-js/src/__tests__/cachetag.test.ts +++ b/composition-js/src/__tests__/cachetag.test.ts @@ -248,4 +248,117 @@ describe("cacheTag spec and join__directive", () => { `); } }); + + 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, import: [{name: \\"@cacheTag\\", as: \\"@myCacheTag\\"}]) + { + 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 + + directive @myCacheTag(format: String!) repeatable on 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! + }" + `); + } + }); }); From b92924ca4f8a4078542a31f0296c721eb79dcae2 Mon Sep 17 00:00:00 2001 From: Duckki Oe Date: Wed, 2 Jul 2025 16:56:55 -0700 Subject: [PATCH 11/13] made `@cacheTag` a federation directive only, but not a cacheTag spec directive. - added cacheTag spec to the router supported spec list. --- composition-js/src/__tests__/cachetag.test.ts | 8 +------- composition-js/src/merging/merge.ts | 8 +++++++- internals-js/src/specs/cacheTagSpec.ts | 13 ------------- internals-js/src/specs/federationSpec.ts | 10 +++++++++- internals-js/src/supergraphs.ts | 1 + 5 files changed, 18 insertions(+), 22 deletions(-) diff --git a/composition-js/src/__tests__/cachetag.test.ts b/composition-js/src/__tests__/cachetag.test.ts index 8c21a4287..17064b22c 100644 --- a/composition-js/src/__tests__/cachetag.test.ts +++ b/composition-js/src/__tests__/cachetag.test.ts @@ -54,8 +54,6 @@ describe("cacheTag spec and join__directive", () => { directive @join__directive(graphs: [join__Graph!], name: String!, args: join__DirectiveArguments) repeatable on SCHEMA | OBJECT | INTERFACE | FIELD_DEFINITION - directive @cacheTag(format: String!) repeatable on OBJECT | INTERFACE | FIELD_DEFINITION - enum link__Purpose { \\"\\"\\" \`SECURITY\` features provide metadata necessary to securely resolve fields. @@ -182,8 +180,6 @@ describe("cacheTag spec and join__directive", () => { directive @join__directive(graphs: [join__Graph!], name: String!, args: join__DirectiveArguments) repeatable on SCHEMA | OBJECT | INTERFACE | FIELD_DEFINITION - directive @cacheTag(format: String!) repeatable on OBJECT | INTERFACE | FIELD_DEFINITION - enum link__Purpose { \\"\\"\\" \`SECURITY\` features provide metadata necessary to securely resolve fields. @@ -279,7 +275,7 @@ describe("cacheTag spec and join__directive", () => { "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, import: [{name: \\"@cacheTag\\", as: \\"@myCacheTag\\"}]) + @link(url: \\"https://specs.apollo.dev/cacheTag/v0.1\\", for: EXECUTION) { query: Query } @@ -300,8 +296,6 @@ describe("cacheTag spec and join__directive", () => { directive @join__directive(graphs: [join__Graph!], name: String!, args: join__DirectiveArguments) repeatable on SCHEMA | OBJECT | INTERFACE | FIELD_DEFINITION - directive @myCacheTag(format: String!) repeatable on OBJECT | INTERFACE | FIELD_DEFINITION - enum link__Purpose { \\"\\"\\" \`SECURITY\` features provide metadata necessary to securely resolve fields. diff --git a/composition-js/src/merging/merge.ts b/composition-js/src/merging/merge.ts index c6cf88ef2..ef801346c 100644 --- a/composition-js/src/merging/merge.ts +++ b/composition-js/src/merging/merge.ts @@ -547,7 +547,13 @@ class Merger { 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, diff --git a/internals-js/src/specs/cacheTagSpec.ts b/internals-js/src/specs/cacheTagSpec.ts index 7449d560a..a696bc054 100644 --- a/internals-js/src/specs/cacheTagSpec.ts +++ b/internals-js/src/specs/cacheTagSpec.ts @@ -2,7 +2,6 @@ // 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 { DirectiveLocation } from "graphql"; import { CorePurpose, FeatureDefinition, @@ -10,8 +9,6 @@ import { FeatureUrl, FeatureVersion, } from "./coreSpec"; -import { NonNullType } from "../definitions"; -import { createDirectiveSpecification } from "../directiveAndTypeSpecification"; export const CACHE_TAG = 'cacheTag'; @@ -28,16 +25,6 @@ export class CacheTagSpecDefinition extends FeatureDefinition { ), minimumFederationVersion, ); - - this.registerDirective(createDirectiveSpecification({ - name: CACHE_TAG, - locations: [DirectiveLocation.OBJECT, DirectiveLocation.INTERFACE, 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, - })); } get defaultCorePurpose(): CorePurpose { diff --git a/internals-js/src/specs/federationSpec.ts b/internals-js/src/specs/federationSpec.ts index d02a33bc3..7db779706 100644 --- a/internals-js/src/specs/federationSpec.ts +++ b/internals-js/src/specs/federationSpec.ts @@ -185,7 +185,15 @@ export class FederationSpecDefinition extends FeatureDefinition { } if (version.gte(new FeatureVersion(2, 12))) { - this.registerSubFeature(CACHE_TAG_VERSIONS.find(new FeatureVersion(0, 1))!); + this.registerDirective(createDirectiveSpecification({ + name: FederationDirectiveName.CACHE_TAG, + locations: [DirectiveLocation.OBJECT, DirectiveLocation.INTERFACE, 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'); From e8c3274fa84a9c8041df2fb571f85e26051314dc Mon Sep 17 00:00:00 2001 From: Duckki Oe Date: Wed, 2 Jul 2025 17:43:06 -0700 Subject: [PATCH 12/13] drop support for @cacheTag on interface --- internals-js/src/specs/federationSpec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internals-js/src/specs/federationSpec.ts b/internals-js/src/specs/federationSpec.ts index 7db779706..6d2b73111 100644 --- a/internals-js/src/specs/federationSpec.ts +++ b/internals-js/src/specs/federationSpec.ts @@ -187,7 +187,7 @@ export class FederationSpecDefinition extends FeatureDefinition { if (version.gte(new FeatureVersion(2, 12))) { this.registerDirective(createDirectiveSpecification({ name: FederationDirectiveName.CACHE_TAG, - locations: [DirectiveLocation.OBJECT, DirectiveLocation.INTERFACE, DirectiveLocation.FIELD_DEFINITION], + locations: [DirectiveLocation.OBJECT, DirectiveLocation.FIELD_DEFINITION], repeatable: true, args: [{ name: 'format', type: (schema) => new NonNullType(schema.stringType()) }], composes: true, From fc24723c3c036e5f9faa79b0d859c5fef98b1e95 Mon Sep 17 00:00:00 2001 From: Duckki Oe Date: Wed, 9 Jul 2025 18:30:22 -0700 Subject: [PATCH 13/13] addressed reviewer comments --- composition-js/src/merging/merge.ts | 17 ++++++++--------- .../src/directiveAndTypeSpecification.ts | 6 +++--- internals-js/src/specs/cacheTagSpec.ts | 7 ++----- 3 files changed, 13 insertions(+), 17 deletions(-) diff --git a/composition-js/src/merging/merge.ts b/composition-js/src/merging/merge.ts index ef801346c..6548b45a2 100644 --- a/composition-js/src/merging/merge.ts +++ b/composition-js/src/merging/merge.ts @@ -378,7 +378,7 @@ class Merger { private inaccessibleDirectiveInSupergraph?: DirectiveDefinition; private latestFedVersionUsed: FeatureVersion; private joinDirectiveFeatureDefinitionsByIdentity = new Map(); - private directiveUsingJoinDirective = new Set(); + private directivesUsingJoinDirective = new Set(); private fieldsWithFromContext: Set; private fieldsWithOverride: Set; @@ -540,7 +540,7 @@ class Merger { compositionSpec, }); if (compositionSpec.useJoinDirective) { - this.directiveUsingJoinDirective.add(nameInSupergraph); + this.directivesUsingJoinDirective.add(nameInSupergraph); } } } @@ -621,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 @@ -2968,12 +2973,6 @@ class Merger { } private mergeAppliedDirective(name: string, sources: Sources>, dest: SchemaElement) { - if (this.directiveUsingJoinDirective.has(name)) { - // This directive will be added as `@join__directive` by the `addJoinDirectiveDirectives` - // method. So, skip the normal merging logic here. - return; - } - // TODO: we currently "only" merge together applications that have the exact same arguments (with defaults expanded however), // but when an argument is an input object type, we should (?) ignore those fields that will not be included in the supergraph // due the intersection merging of input types, otherwise the merged value may be invalid for the supergraph. @@ -3168,7 +3167,7 @@ class Merger { // See if this directive is one of the directives that should use the @join__directive. if ( !shouldIncludeAsJoinDirective - && this.directiveUsingJoinDirective.has(directive.name) + && this.directivesUsingJoinDirective.has(directive.name) ) { shouldIncludeAsJoinDirective = true; if (sourceFeature) { diff --git a/internals-js/src/directiveAndTypeSpecification.ts b/internals-js/src/directiveAndTypeSpecification.ts index 6e478ae79..3be1c9d92 100644 --- a/internals-js/src/directiveAndTypeSpecification.ts +++ b/internals-js/src/directiveAndTypeSpecification.ts @@ -34,7 +34,7 @@ export type DirectiveSpecification = { export type DirectiveCompositionSpecification = { supergraphSpecification: (federationVersion: FeatureVersion) => FeatureDefinition, - useJoinDirective?: boolean, + useJoinDirective: boolean, argumentsMerger?: (schema: Schema, feature: CoreFeature) => ArgumentMerger | GraphQLError, staticArgumentTransform?: StaticArgumentsTransform, } @@ -80,7 +80,7 @@ export function createDirectiveSpecification({ args = [], composes = false, supergraphSpecification = undefined, - useJoinDirective = undefined, + useJoinDirective = false, staticArgumentTransform = undefined, }: { name: string, @@ -136,7 +136,7 @@ export function createDirectiveSpecification({ } composition = { supergraphSpecification, - useJoinDirective, + useJoinDirective: useJoinDirective ?? false, argumentsMerger, staticArgumentTransform, }; diff --git a/internals-js/src/specs/cacheTagSpec.ts b/internals-js/src/specs/cacheTagSpec.ts index a696bc054..7c4b39fe9 100644 --- a/internals-js/src/specs/cacheTagSpec.ts +++ b/internals-js/src/specs/cacheTagSpec.ts @@ -16,7 +16,7 @@ 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) { + constructor(version: FeatureVersion, minimumFederationVersion?: FeatureVersion) { super( new FeatureUrl( CacheTagSpecDefinition.identity, @@ -36,8 +36,5 @@ export const CACHE_TAG_VERSIONS = new FeatureDefinitions( CacheTagSpecDefinition.identity ).add( - new CacheTagSpecDefinition( - new FeatureVersion(0, 1), - new FeatureVersion(2, 12), - ), + new CacheTagSpecDefinition(new FeatureVersion(0, 1)), );