From 0ccfd937d4b4a576f890665ceebbd7986fac5d0c Mon Sep 17 00:00:00 2001 From: Taylor Ninesling Date: Mon, 22 Jul 2024 12:45:05 -0500 Subject: [PATCH] Implement @cost and @listSize directives for demand control (#3074) # Overview Implements two new directives for demand control, based on the [IBM Cost Specification](https://ibm.github.io/graphql-specs/cost-spec.html). ``` directive @cost(weight: Int!) on | ARGUMENT_DEFINITION | ENUM | FIELD_DEFINITION | INPUT_FIELD_DEFINITION | OBJECT | SCALAR directive @listSize( assumedSize: Int, slicingArguments: [String!], sizedFields: [String!], requireOneSlicingArgument: Boolean = true ) on FIELD_DEFINITION ``` The `@cost` directive allows users to specify a custom weight for fields, enums, input objects, and arguments. The weight is used in the demand control cost calculation, both for static estimates as well as actual cost calculations. The `@listSize` directive allows users to specify expected sizes of list fields in their schema. This can be a static value set through `assumedSize` or a dynamic value using `slicingArguments` to get the value from some paging parameters. ## Differences from the spec The main difference from the IBM spec is that we use an `Int!` for weight argument of `@cost`. This allows the parser to enforce this is parameterized with proper numeric values instead of finding out at runtime that an invalid `String!` weight was passed. ## Caveats for shared fields When `@cost` or `@listSize` are used on a `@shareable` field with different values, the composed directive will use a merged value that takes the maximum weight or assumed size, when applicable. --- .changeset/happy-bats-exist.md | 6 + .../compose.composeDirective.test.ts.snap | 2 +- .../compose.composeDirective.test.ts | 6 +- .../__tests__/compose.demandControl.test.ts | 583 ++++++++++++++++++ ...e.directiveArgumentMergeStrategies.test.ts | 38 +- composition-js/src/composeDirectiveManager.ts | 1 + composition-js/src/merging/merge.ts | 4 +- .../__tests__/gateway/lifecycle-hooks.test.ts | 2 +- .../src/argumentCompositionStrategies.ts | 37 ++ .../src/extractSubgraphsFromSupergraph.ts | 63 +- internals-js/src/federation.ts | 23 +- internals-js/src/index.ts | 1 + internals-js/src/specs/coreSpec.ts | 21 + internals-js/src/specs/costSpec.ts | 60 ++ internals-js/src/specs/federationSpec.ts | 10 +- internals-js/src/supergraphs.ts | 1 + 16 files changed, 841 insertions(+), 17 deletions(-) create mode 100644 .changeset/happy-bats-exist.md create mode 100644 composition-js/src/__tests__/compose.demandControl.test.ts create mode 100644 internals-js/src/specs/costSpec.ts diff --git a/.changeset/happy-bats-exist.md b/.changeset/happy-bats-exist.md new file mode 100644 index 000000000..c81f41a6c --- /dev/null +++ b/.changeset/happy-bats-exist.md @@ -0,0 +1,6 @@ +--- +"@apollo/composition": minor +"@apollo/federation-internals": minor +--- + +Implements two new directives for defining custom costs for demand control. The `@cost` directive allows setting a custom weight to a particular field in the graph, overriding the default cost calculation. The `@listSize` directive gives the cost calculator information about how to estimate the size of lists returned by subgraphs. This can either be a static size or a value derived from input arguments, such as paging parameters. diff --git a/composition-js/src/__tests__/__snapshots__/compose.composeDirective.test.ts.snap b/composition-js/src/__tests__/__snapshots__/compose.composeDirective.test.ts.snap index 6d651f4ec..b99934cd0 100644 --- a/composition-js/src/__tests__/__snapshots__/compose.composeDirective.test.ts.snap +++ b/composition-js/src/__tests__/__snapshots__/compose.composeDirective.test.ts.snap @@ -4,7 +4,7 @@ exports[`composing custom core directives custom tag directive works when federa "schema @link(url: \\"https://specs.apollo.dev/link/v1.0\\") @link(url: \\"https://specs.apollo.dev/join/v0.3\\", for: EXECUTION) - @link(url: \\"https://specs.apollo.dev/tag/v0.3\\", as: \\"mytag\\") + @link(url: \\"https://specs.apollo.dev/tag/v0.3\\", import: [{name: \\"@tag\\", as: \\"@mytag\\"}]) @link(url: \\"https://custom.dev/tag/v1.0\\", import: [\\"@tag\\"]) { query: Query diff --git a/composition-js/src/__tests__/compose.composeDirective.test.ts b/composition-js/src/__tests__/compose.composeDirective.test.ts index e263b57d4..fe1d845d4 100644 --- a/composition-js/src/__tests__/compose.composeDirective.test.ts +++ b/composition-js/src/__tests__/compose.composeDirective.test.ts @@ -796,7 +796,7 @@ describe('composing custom core directives', () => { expect(errors(result)).toStrictEqual([ [ 'DIRECTIVE_COMPOSITION_ERROR', - 'Could not find matching directive definition for argument to @composeDirective "@fooz" in subgraph "subgraphA". Did you mean "@foo"?', + 'Could not find matching directive definition for argument to @composeDirective "@fooz" in subgraph "subgraphA". Did you mean "@foo" or "@cost"?', ] ]); }); @@ -926,8 +926,8 @@ describe('composing custom core directives', () => { expectCoreFeature(schema, 'https://custom.dev/tag', '1.0', [{ name: '@tag' }]); const feature = schema.coreFeatures?.getByIdentity('https://specs.apollo.dev/tag'); expect(feature?.url.toString()).toBe('https://specs.apollo.dev/tag/v0.3'); - expect(feature?.imports).toEqual([]); - expect(feature?.nameInSchema).toEqual('mytag'); + expect(feature?.imports).toEqual([{ name: '@tag', as: '@mytag' }]); + expect(feature?.nameInSchema).toEqual('tag'); expect(printSchema(schema)).toMatchSnapshot(); }); diff --git a/composition-js/src/__tests__/compose.demandControl.test.ts b/composition-js/src/__tests__/compose.demandControl.test.ts new file mode 100644 index 000000000..31a11fc26 --- /dev/null +++ b/composition-js/src/__tests__/compose.demandControl.test.ts @@ -0,0 +1,583 @@ +import { + ArgumentDefinition, + asFed2SubgraphDocument, + EnumType, + FEDERATION2_LINK_WITH_AUTO_EXPANDED_IMPORTS, + FieldDefinition, + InputObjectType, + ObjectType, + ServiceDefinition, + Supergraph +} from '@apollo/federation-internals'; +import { composeServices, CompositionResult } from '../compose'; +import gql from 'graphql-tag'; +import { assertCompositionSuccess, errors } from "./testHelper"; + +const subgraphWithCost = { + name: 'subgraphWithCost', + typeDefs: asFed2SubgraphDocument(gql` + extend schema @link(url: "https://specs.apollo.dev/cost/v0.1", import: ["@cost"]) + + enum AorB @cost(weight: 15) { + A + B + } + + input InputTypeWithCost { + somethingWithCost: Int @cost(weight: 20) + } + + type Query { + fieldWithCost: Int @cost(weight: 5) + argWithCost(arg: Int @cost(weight: 10)): Int + enumWithCost: AorB + inputWithCost(someInput: InputTypeWithCost): Int + } + `), +}; + +const subgraphWithListSize = { + name: 'subgraphWithListSize', + typeDefs: asFed2SubgraphDocument(gql` + extend schema @link(url: "https://specs.apollo.dev/cost/v0.1", import: ["@listSize"]) + + type Query { + fieldWithListSize: [String!] @listSize(assumedSize: 2000, requireOneSlicingArgument: false) + } + `), +}; + +const subgraphWithRenamedCost = { + name: 'subgraphWithCost', + typeDefs: asFed2SubgraphDocument(gql` + extend schema @link(url: "https://specs.apollo.dev/cost/v0.1", import: [{ name: "@cost", as: "@renamedCost" }]) + + enum AorB @renamedCost(weight: 15) { + A + B + } + + input InputTypeWithCost { + somethingWithCost: Int @renamedCost(weight: 20) + } + + type Query { + fieldWithCost: Int @renamedCost(weight: 5) + argWithCost(arg: Int @renamedCost(weight: 10)): Int + enumWithCost: AorB + inputWithCost(someInput: InputTypeWithCost): Int + } + `), +}; + +const subgraphWithRenamedListSize = { + name: 'subgraphWithListSize', + typeDefs: asFed2SubgraphDocument(gql` + extend schema @link(url: "https://specs.apollo.dev/cost/v0.1", import: [{ name: "@listSize", as: "@renamedListSize" }]) + + type Query { + fieldWithListSize: [String!] @renamedListSize(assumedSize: 2000, requireOneSlicingArgument: false) + } + `), +}; + +const subgraphWithCostFromFederationSpec = { + name: 'subgraphWithCost', + typeDefs: asFed2SubgraphDocument( + gql` + enum AorB @cost(weight: 15) { + A + B + } + + input InputTypeWithCost { + somethingWithCost: Int @cost(weight: 20) + } + + type Query { + fieldWithCost: Int @cost(weight: 5) + argWithCost(arg: Int @cost(weight: 10)): Int + enumWithCost: AorB + inputWithCost(someInput: InputTypeWithCost): Int + } + `, + { includeAllImports: true }, + ), +}; + +const subgraphWithListSizeFromFederationSpec = { + name: 'subgraphWithListSize', + typeDefs: asFed2SubgraphDocument( + gql` + type Query { + fieldWithListSize: [String!] @listSize(assumedSize: 2000, requireOneSlicingArgument: false) + } + `, + { includeAllImports: true }, + ), +}; + +const subgraphWithRenamedCostFromFederationSpec = { + name: 'subgraphWithCost', + typeDefs: + gql` + extend schema @link(url: "https://specs.apollo.dev/federation/v2.9", import: [{ name: "@cost", as: "@renamedCost" }]) + + enum AorB @renamedCost(weight: 15) { + A + B + } + + input InputTypeWithCost { + somethingWithCost: Int @renamedCost(weight: 20) + } + + type Query { + fieldWithCost: Int @renamedCost(weight: 5) + argWithCost(arg: Int @renamedCost(weight: 10)): Int + enumWithCost: AorB + inputWithCost(someInput: InputTypeWithCost): Int + } + `, +}; + +const subgraphWithRenamedListSizeFromFederationSpec = { + name: 'subgraphWithListSize', + typeDefs: + gql` + extend schema @link(url: "https://specs.apollo.dev/federation/v2.9", import: [{ name: "@listSize", as: "@renamedListSize" }]) + + type Query { + fieldWithListSize: [String!] @renamedListSize(assumedSize: 2000, requireOneSlicingArgument: false) + } + `, +}; + +// Used to test @cost applications on FIELD_DEFINITION +function fieldWithCost(result: CompositionResult): FieldDefinition | undefined { + return result + .schema + ?.schemaDefinition + .rootType('query') + ?.field('fieldWithCost'); +} + +// Used to test @cost applications on ARGUMENT_DEFINITION +function argumentWithCost(result: CompositionResult): ArgumentDefinition> | undefined { + return result + .schema + ?.schemaDefinition + .rootType('query') + ?.field('argWithCost') + ?.argument('arg'); +} + +// Used to test @cost applications on ENUM +function enumWithCost(result: CompositionResult): EnumType | undefined { + return result + .schema + ?.schemaDefinition + .rootType('query') + ?.field('enumWithCost') + ?.type as EnumType; +} + +// Used to test @cost applications on INPUT_FIELD_DEFINITION +function inputWithCost(result: CompositionResult): InputObjectType | undefined { + return result + .schema + ?.schemaDefinition + .rootType('query') + ?.field('inputWithCost') + ?.argument('someInput') + ?.type as InputObjectType; +} + +// Used to test @listSize applications on FIELD_DEFINITION +function fieldWithListSize(result: CompositionResult): FieldDefinition | undefined { + return result + .schema + ?.schemaDefinition + .rootType('query') + ?.field('fieldWithListSize'); +} + +describe('demand control directive composition', () => { + it.each([ + [subgraphWithCost, subgraphWithListSize], + [subgraphWithCostFromFederationSpec, subgraphWithListSizeFromFederationSpec], + ])('propagates @cost and @listSize to the supergraph', (costSubgraph: ServiceDefinition, listSizeSubgraph: ServiceDefinition) => { + const result = composeServices([costSubgraph, listSizeSubgraph]); + assertCompositionSuccess(result); + expect(result.hints).toEqual([]); + + const costDirectiveApplications = fieldWithCost(result)?.appliedDirectivesOf('cost'); + expect(costDirectiveApplications?.toString()).toMatchString(`@cost(weight: 5)`); + + const argCostDirectiveApplications = argumentWithCost(result)?.appliedDirectivesOf('cost'); + expect(argCostDirectiveApplications?.toString()).toMatchString(`@cost(weight: 10)`); + + const enumCostDirectiveApplications = enumWithCost(result)?.appliedDirectivesOf('cost'); + expect(enumCostDirectiveApplications?.toString()).toMatchString(`@cost(weight: 15)`); + + const inputCostDirectiveApplications = inputWithCost(result)?.field('somethingWithCost')?.appliedDirectivesOf('cost'); + expect(inputCostDirectiveApplications?.toString()).toMatchString(`@cost(weight: 20)`); + + const listSizeDirectiveApplications = fieldWithListSize(result)?.appliedDirectivesOf('listSize'); + expect(listSizeDirectiveApplications?.toString()).toMatchString(`@listSize(assumedSize: 2000, requireOneSlicingArgument: false)`); + }); + + describe('when renamed', () => { + it.each([ + [subgraphWithRenamedCost, subgraphWithRenamedListSize], + [subgraphWithRenamedCostFromFederationSpec, subgraphWithRenamedListSizeFromFederationSpec] + ])('propagates the renamed @cost and @listSize to the supergraph', (costSubgraph: ServiceDefinition, listSizeSubgraph: ServiceDefinition) => { + const result = composeServices([costSubgraph, listSizeSubgraph]); + assertCompositionSuccess(result); + expect(result.hints).toEqual([]); + + // Ensure the new directive names are specified in the supergraph so we can use them during extraction + const links = result.schema.schemaDefinition.appliedDirectivesOf("link"); + const costLinks = links.filter((link) => link.arguments().url === "https://specs.apollo.dev/cost/v0.1"); + expect(costLinks.length).toBe(1); + expect(costLinks[0].toString()).toEqual(`@link(url: "https://specs.apollo.dev/cost/v0.1", import: [{name: "@cost", as: "@renamedCost"}, {name: "@listSize", as: "@renamedListSize"}])`); + + // Ensure the directives are applied to the expected fields with the new names + const costDirectiveApplications = fieldWithCost(result)?.appliedDirectivesOf('renamedCost'); + expect(costDirectiveApplications?.toString()).toMatchString(`@renamedCost(weight: 5)`); + + const argCostDirectiveApplications = argumentWithCost(result)?.appliedDirectivesOf('renamedCost'); + expect(argCostDirectiveApplications?.toString()).toMatchString(`@renamedCost(weight: 10)`); + + const enumCostDirectiveApplications = enumWithCost(result)?.appliedDirectivesOf('renamedCost'); + expect(enumCostDirectiveApplications?.toString()).toMatchString(`@renamedCost(weight: 15)`); + + const listSizeDirectiveApplications = fieldWithListSize(result)?.appliedDirectivesOf('renamedListSize'); + expect(listSizeDirectiveApplications?.toString()).toMatchString(`@renamedListSize(assumedSize: 2000, requireOneSlicingArgument: false)`); + }); + }); + + describe('when renamed in one subgraph but not the other', () => { + it('does not compose', () => { + const subgraphWithDefaultName = { + name: 'subgraphWithDefaultName', + typeDefs: asFed2SubgraphDocument(gql` + extend schema @link(url: "https://specs.apollo.dev/cost/v0.1", import: ["@cost"]) + + type Query { + field1: Int @cost(weight: 5) + } + `), + }; + const subgraphWithDifferentName = { + name: 'subgraphWithDifferentName', + typeDefs: asFed2SubgraphDocument(gql` + extend schema @link(url: "https://specs.apollo.dev/cost/v0.1", import: [{ name: "@cost", as: "@renamedCost" }]) + + type Query { + field2: Int @renamedCost(weight: 10) + } + `), + }; + + const result = composeServices([subgraphWithDefaultName, subgraphWithDifferentName]); + expect(errors(result)).toEqual([ + [ + "LINK_IMPORT_NAME_MISMATCH", + `The "@cost" directive (from https://specs.apollo.dev/cost/v0.1) is imported with mismatched name between subgraphs: it is imported as "@renamedCost" in subgraph "subgraphWithDifferentName" but "@cost" in subgraph "subgraphWithDefaultName"` + ] + ]); + }); + }); + + describe('when used on @shareable fields', () => { + it('hints about merged @cost arguments', () => { + const subgraphA = { + name: 'subgraph-a', + typeDefs: asFed2SubgraphDocument(gql` + extend schema @link(url: "https://specs.apollo.dev/cost/v0.1", import: ["@cost"]) + + type Query { + sharedWithCost: Int @shareable @cost(weight: 5) + } + `) + }; + const subgraphB = { + name: 'subgraph-b', + typeDefs: asFed2SubgraphDocument(gql` + extend schema @link(url: "https://specs.apollo.dev/cost/v0.1", import: ["@cost"]) + + type Query { + sharedWithCost: Int @shareable @cost(weight: 10) + } + `) + }; + + const result = composeServices([subgraphA, subgraphB]); + assertCompositionSuccess(result); + expect(result.hints).toMatchInlineSnapshot(` + Array [ + CompositionHint { + "coordinate": undefined, + "definition": Object { + "code": "MERGED_NON_REPEATABLE_DIRECTIVE_ARGUMENTS", + "description": "A non-repeatable directive has been applied to a schema element in different subgraphs with different arguments and the arguments values were merged using the directive configured strategies.", + "level": Object { + "name": "INFO", + "value": 40, + }, + }, + "element": undefined, + "message": "Directive @cost is applied to \\"Query.sharedWithCost\\" in multiple subgraphs with different arguments. Merging strategies used by arguments: { \\"weight\\": MAX }", + "nodes": undefined, + }, + ] + `); + }); + + it('hints about merged @listSize arguments', () => { + const subgraphA = { + name: 'subgraph-a', + typeDefs: asFed2SubgraphDocument(gql` + extend schema @link(url: "https://specs.apollo.dev/cost/v0.1", import: ["@listSize"]) + + type Query { + sharedWithListSize: [Int] @shareable @listSize(assumedSize: 10) + } + `) + }; + const subgraphB = { + name: 'subgraph-b', + typeDefs: asFed2SubgraphDocument(gql` + extend schema @link(url: "https://specs.apollo.dev/cost/v0.1", import: ["@listSize"]) + + type Query { + sharedWithListSize: [Int] @shareable @listSize(assumedSize: 20) + } + `) + }; + + const result = composeServices([subgraphA, subgraphB]); + assertCompositionSuccess(result); + expect(result.hints).toMatchInlineSnapshot(` + Array [ + CompositionHint { + "coordinate": undefined, + "definition": Object { + "code": "MERGED_NON_REPEATABLE_DIRECTIVE_ARGUMENTS", + "description": "A non-repeatable directive has been applied to a schema element in different subgraphs with different arguments and the arguments values were merged using the directive configured strategies.", + "level": Object { + "name": "INFO", + "value": 40, + }, + }, + "element": undefined, + "message": "Directive @listSize is applied to \\"Query.sharedWithListSize\\" in multiple subgraphs with different arguments. Merging strategies used by arguments: { \\"assumedSize\\": NULLABLE_MAX, \\"slicingArguments\\": NULLABLE_UNION, \\"sizedFields\\": NULLABLE_UNION, \\"requireOneSlicingArgument\\": NULLABLE_AND }", + "nodes": undefined, + }, + ] + `); + }); + }); +}); + +describe('demand control directive extraction', () => { + it.each([ + subgraphWithCost, + subgraphWithRenamedCost, + subgraphWithCostFromFederationSpec, + subgraphWithRenamedCostFromFederationSpec + ])('extracts @cost from the supergraph', (subgraph: ServiceDefinition) => { + const result = composeServices([subgraph]); + assertCompositionSuccess(result); + const extracted = Supergraph.build(result.supergraphSdl).subgraphs().get(subgraphWithCost.name); + + expect(extracted?.toString()).toMatchString(` + schema + ${FEDERATION2_LINK_WITH_AUTO_EXPANDED_IMPORTS} + { + query: Query + } + + enum AorB + @federation__cost(weight: 15) + { + A + B + } + + input InputTypeWithCost { + somethingWithCost: Int @federation__cost(weight: 20) + } + + type Query { + fieldWithCost: Int @federation__cost(weight: 5) + argWithCost(arg: Int @federation__cost(weight: 10)): Int + enumWithCost: AorB + inputWithCost(someInput: InputTypeWithCost): Int + } + `); + }); + + it.each([ + subgraphWithListSize, + subgraphWithRenamedListSize, + subgraphWithListSizeFromFederationSpec, + subgraphWithRenamedListSizeFromFederationSpec + ])('extracts @listSize from the supergraph', (subgraph: ServiceDefinition) => { + const result = composeServices([subgraph]); + assertCompositionSuccess(result); + const extracted = Supergraph.build(result.supergraphSdl).subgraphs().get(subgraphWithListSize.name); + + expect(extracted?.toString()).toMatchString(` + schema + ${FEDERATION2_LINK_WITH_AUTO_EXPANDED_IMPORTS} + { + query: Query + } + + type Query { + fieldWithListSize: [String!] @federation__listSize(assumedSize: 2000, requireOneSlicingArgument: false) + } + `); + }); + + it('extracts @listSize with dynamic cost arguments', () => { + const subgraphA = { + name: 'subgraph-a', + typeDefs: asFed2SubgraphDocument(gql` + extend schema @link(url: "https://specs.apollo.dev/cost/v0.1", import: ["@listSize"]) + + type Query { + sizedList(first: Int!): HasInts @shareable @listSize(slicingArguments: ["first"], sizedFields: ["ints"], requireOneSlicingArgument: true) + } + + type HasInts { + ints: [Int!] @shareable + } + `) + }; + const subgraphB = { + name: 'subgraph-b', + typeDefs: asFed2SubgraphDocument(gql` + extend schema @link(url: "https://specs.apollo.dev/cost/v0.1", import: ["@listSize"]) + + type Query { + sizedList(first: Int!): HasInts @shareable @listSize(slicingArguments: ["first"], sizedFields: ["ints"], requireOneSlicingArgument: false) + } + + type HasInts { + ints: [Int!] @shareable + } + `) + }; + + const result = composeServices([subgraphA, subgraphB]); + assertCompositionSuccess(result); + const supergraph = Supergraph.build(result.supergraphSdl); + + const expectedSubgraph = ` + schema + ${FEDERATION2_LINK_WITH_AUTO_EXPANDED_IMPORTS} + { + query: Query + } + + type HasInts { + ints: [Int!] @shareable + } + + type Query { + sizedList(first: Int!): HasInts @shareable @federation__listSize(slicingArguments: ["first"], sizedFields: ["ints"], requireOneSlicingArgument: false) + } + `; + expect(supergraph.subgraphs().get(subgraphA.name)?.toString()).toMatchString(expectedSubgraph); + expect(supergraph.subgraphs().get(subgraphB.name)?.toString()).toMatchString(expectedSubgraph); + }); + + describe('when used on @shareable fields', () => { + it('extracts @cost using the max weight across subgraphs', () => { + const subgraphA = { + name: 'subgraph-a', + typeDefs: asFed2SubgraphDocument(gql` + extend schema @link(url: "https://specs.apollo.dev/cost/v0.1", import: ["@cost"]) + + type Query { + sharedWithCost: Int @shareable @cost(weight: 5) + } + `) + }; + const subgraphB = { + name: 'subgraph-b', + typeDefs: asFed2SubgraphDocument(gql` + extend schema @link(url: "https://specs.apollo.dev/cost/v0.1", import: ["@cost"]) + + type Query { + sharedWithCost: Int @shareable @cost(weight: 10) + } + `) + }; + + const result = composeServices([subgraphA, subgraphB]); + assertCompositionSuccess(result); + const supergraph = Supergraph.build(result.supergraphSdl); + + const expectedSchema = ` + schema + ${FEDERATION2_LINK_WITH_AUTO_EXPANDED_IMPORTS} + { + query: Query + } + + type Query { + sharedWithCost: Int @shareable @federation__cost(weight: 10) + } + `; + // Even though different costs went in, the arguments are merged by taking the max weight. + // This means the extracted costs for the shared field have the same weight on the way out. + expect(supergraph.subgraphs().get(subgraphA.name)?.toString()).toMatchString(expectedSchema); + expect(supergraph.subgraphs().get(subgraphB.name)?.toString()).toMatchString(expectedSchema); + }); + + it('extracts @listSize using the max assumed size across subgraphs', () => { + const subgraphA = { + name: 'subgraph-a', + typeDefs: asFed2SubgraphDocument(gql` + extend schema @link(url: "https://specs.apollo.dev/cost/v0.1", import: ["@listSize"]) + + type Query { + sharedWithListSize: [Int] @shareable @listSize(assumedSize: 10) + } + `) + }; + const subgraphB = { + name: 'subgraph-b', + typeDefs: asFed2SubgraphDocument(gql` + extend schema @link(url: "https://specs.apollo.dev/cost/v0.1", import: ["@listSize"]) + + type Query { + sharedWithListSize: [Int] @shareable @listSize(assumedSize: 20) + } + `) + }; + + const result = composeServices([subgraphA, subgraphB]); + assertCompositionSuccess(result); + const supergraph = Supergraph.build(result.supergraphSdl); + + const expectedSubgraph = ` + schema + ${FEDERATION2_LINK_WITH_AUTO_EXPANDED_IMPORTS} + { + query: Query + } + + type Query { + sharedWithListSize: [Int] @shareable @federation__listSize(assumedSize: 20, requireOneSlicingArgument: true) + } + `; + expect(supergraph.subgraphs().get(subgraphA.name)?.toString()).toMatchString(expectedSubgraph); + expect(supergraph.subgraphs().get(subgraphB.name)?.toString()).toMatchString(expectedSubgraph); + }); + }); +}); diff --git a/composition-js/src/__tests__/compose.directiveArgumentMergeStrategies.test.ts b/composition-js/src/__tests__/compose.directiveArgumentMergeStrategies.test.ts index 7307a96ba..b1be5c54c 100644 --- a/composition-js/src/__tests__/compose.directiveArgumentMergeStrategies.test.ts +++ b/composition-js/src/__tests__/compose.directiveArgumentMergeStrategies.test.ts @@ -121,6 +121,42 @@ describe('composition of directive with non-trivial argument strategies', () => resultValues: { t: ['foo', 'bar'], k: ['v1', 'v2'], b: ['x'], }, + }, + { + name: 'nullable_and', + type: (schema: Schema) => schema.booleanType(), + compositionStrategy: ARGUMENT_COMPOSITION_STRATEGIES.NULLABLE_AND, + argValues: { + s1: { t: true, k: true }, + s2: { t: undefined, k: false, b: false }, + }, + resultValues: { + t: true, k: false, b: false, + }, + }, + { + name: 'nullable_max', + type: (schema: Schema) => schema.intType(), + compositionStrategy: ARGUMENT_COMPOSITION_STRATEGIES.NULLABLE_MAX, + argValues: { + s1: { t: 3, k: 1 }, + s2: { t: 2, k: undefined, b: undefined }, + }, + resultValues: { + t: 3, k: 1, b: undefined, + }, + }, + { + name: 'nullable_union', + type: (schema: Schema) => new ListType(new NonNullType(schema.stringType())), + compositionStrategy: ARGUMENT_COMPOSITION_STRATEGIES.NULLABLE_UNION, + argValues: { + s1: { t: ['foo', 'bar'], k: [] }, + s2: { t: ['foo'], k: ['v1', 'v2'], b: ['x'] }, + }, + resultValues: { + t: ['foo', 'bar'], k: ['v1', 'v2'], b: ['x'], + }, }])('works for $name', ({ name, type, compositionStrategy, argValues, resultValues }) => { createTestFeature({ url: 'https://specs.apollo.dev', @@ -183,7 +219,7 @@ describe('composition of directive with non-trivial argument strategies', () => const s = result.schema; expect(directiveStrings(s.schemaDefinition, name)).toStrictEqual([ - `@link(url: "https://specs.apollo.dev/${name}/v0.1")` + `@link(url: "https://specs.apollo.dev/${name}/v0.1", import: ["@${name}"])` ]); const t = s.type('T') as ObjectType; diff --git a/composition-js/src/composeDirectiveManager.ts b/composition-js/src/composeDirectiveManager.ts index e2fe7b170..b4cfd706b 100644 --- a/composition-js/src/composeDirectiveManager.ts +++ b/composition-js/src/composeDirectiveManager.ts @@ -66,6 +66,7 @@ const DISALLOWED_IDENTITIES = [ 'https://specs.apollo.dev/requiresScopes', 'https://specs.apollo.dev/source', 'https://specs.apollo.dev/context', + 'https://specs.apollo.dev/cost', ]; export class ComposeDirectiveManager { diff --git a/composition-js/src/merging/merge.ts b/composition-js/src/merging/merge.ts index 57e9b618a..6d5e06c23 100644 --- a/composition-js/src/merging/merge.ts +++ b/composition-js/src/merging/merge.ts @@ -449,7 +449,7 @@ class Merger { // don't bother adding the spec to the supergraph. if (nameInSupergraph) { const specInSupergraph = compositionSpec.supergraphSpecification(this.latestFedVersionUsed); - const errors = this.linkSpec.applyFeatureToSchema(this.merged, specInSupergraph, nameInSupergraph === specInSupergraph.url.name ? undefined : nameInSupergraph, specInSupergraph.defaultCorePurpose); + const errors = this.linkSpec.applyFeatureAsLink(this.merged, specInSupergraph, specInSupergraph.defaultCorePurpose, [{ name, as: name === nameInSupergraph ? undefined : nameInSupergraph }], ); assert(errors.length === 0, "We shouldn't have errors adding the join spec to the (still empty) supergraph schema"); const feature = this.merged?.coreFeatures?.getByIdentity(specInSupergraph.url.identity); assert(feature, 'Should have found the feature we just added'); @@ -459,7 +459,7 @@ class Merger { throw argumentsMerger; } this.mergedFederationDirectiveNames.add(nameInSupergraph); - this.mergedFederationDirectiveInSupergraph.set(specInSupergraph.url.name, { + this.mergedFederationDirectiveInSupergraph.set(name, { definition: this.merged.directive(nameInSupergraph)!, argumentsMerger, staticArgumentTransform: compositionSpec.staticArgumentTransform, diff --git a/gateway-js/src/__tests__/gateway/lifecycle-hooks.test.ts b/gateway-js/src/__tests__/gateway/lifecycle-hooks.test.ts index 43b65e309..458175876 100644 --- a/gateway-js/src/__tests__/gateway/lifecycle-hooks.test.ts +++ b/gateway-js/src/__tests__/gateway/lifecycle-hooks.test.ts @@ -149,7 +149,7 @@ describe('lifecycle hooks', () => { // the supergraph (even just formatting differences), this ID will change // and this test will have to updated. expect(secondCall[0]!.compositionId).toMatchInlineSnapshot( - `"6dc1bde2b9818fabec62208c5d8825abaa1bae89635fa6f3a5ffea7b78fc6d82"`, + `"4aa2278e35df345ff5959a30546d2e9ef9e997204b4ffee4a42344b578b36068"`, ); // second call should have previous info in the second arg expect(secondCall[1]!.compositionId).toEqual(expectedFirstId); diff --git a/internals-js/src/argumentCompositionStrategies.ts b/internals-js/src/argumentCompositionStrategies.ts index 9ceebaa90..85a870773 100644 --- a/internals-js/src/argumentCompositionStrategies.ts +++ b/internals-js/src/argumentCompositionStrategies.ts @@ -59,4 +59,41 @@ export const ARGUMENT_COMPOSITION_STRATEGIES = { return acc.concat(newValues); }, []), }, + NULLABLE_AND: { + name: 'NULLABLE_AND', + isTypeSupported: supportFixedTypes((schema: Schema) => [schema.booleanType()]), + mergeValues: (values: (boolean | null | undefined)[]) => values.reduce((acc, next) => { + if (acc === null || acc === undefined) { + return next; + } else if (next === null || next === undefined) { + return acc; + } else { + return acc && next; + } + }, undefined), + }, + NULLABLE_MAX: { + name: 'NULLABLE_MAX', + isTypeSupported: supportFixedTypes((schema: Schema) => [schema.intType(), new NonNullType(schema.intType())]), + mergeValues: (values: any[]) => values.reduce((a: any, b: any) => a !== undefined && b !== undefined ? Math.max(a, b) : a ?? b, undefined), + }, + NULLABLE_UNION: { + name: 'NULLABLE_UNION', + isTypeSupported: (_: Schema, type: InputType) => ({ valid: isListType(type) }), + mergeValues: (values: any[]) => { + if (values.every((v) => v === undefined)) { + return undefined; + } + + const combined = new Set(); + for (const subgraphValues of values) { + if (Array.isArray(subgraphValues)) { + for (const value of subgraphValues) { + combined.add(value); + } + } + } + return Array.from(combined); + } + } } diff --git a/internals-js/src/extractSubgraphsFromSupergraph.ts b/internals-js/src/extractSubgraphsFromSupergraph.ts index 6ee15899a..1d8a2e4d3 100644 --- a/internals-js/src/extractSubgraphsFromSupergraph.ts +++ b/internals-js/src/extractSubgraphsFromSupergraph.ts @@ -40,7 +40,7 @@ import { parseSelectionSet } from "./operations"; import fs from 'fs'; import path from 'path'; import { validateStringContainsBoolean } from "./utils"; -import { CONTEXT_VERSIONS, ContextSpecDefinition, DirectiveDefinition, errorCauses, isFederationDirectiveDefinedInSchema, printErrors } from "."; +import { CONTEXT_VERSIONS, ContextSpecDefinition, DirectiveDefinition, FederationDirectiveName, SchemaElement, errorCauses, isFederationDirectiveDefinedInSchema, printErrors } from "."; function filteredTypes( supergraph: Schema, @@ -224,11 +224,13 @@ export function extractSubgraphsFromSupergraph(supergraph: Schema, validateExtra } const types = filteredTypes(supergraph, joinSpec, coreFeatures.coreDefinition); + const originalDirectiveNames = getOriginalDirectiveNames(supergraph); const args: ExtractArguments = { supergraph, subgraphs, joinSpec, filteredTypes: types, + originalDirectiveNames, getSubgraph, getSubgraphEnumValue, }; @@ -292,6 +294,7 @@ type ExtractArguments = { subgraphs: Subgraphs, joinSpec: JoinSpecDefinition, filteredTypes: NamedType[], + originalDirectiveNames: Record, getSubgraph: (application: Directive) => Subgraph | undefined, getSubgraphEnumValue: (subgraphName: string) => string } @@ -434,6 +437,8 @@ function extractObjOrItfContent(args: ExtractArguments, info: TypeInfo 1; for (const { type: subgraphType, subgraph } of subgraphsInfo.values()) { - addSubgraphField({ field, type: subgraphType, subgraph, isShareable }); + addSubgraphField({ field, type: subgraphType, subgraph, isShareable, originalDirectiveNames }); } } else { const isShareable = isObjectType(type) @@ -468,15 +473,31 @@ function extractObjOrItfContent(args: ExtractArguments, info: TypeInfo { + const originalDirectiveNames: Record = {}; + for (const linkDirective of supergraph.schemaDefinition.appliedDirectivesOf("link")) { + if (linkDirective.arguments().url && linkDirective.arguments().import) { + for (const importedDirective of linkDirective.arguments().import) { + if (importedDirective.name && importedDirective.as) { + originalDirectiveNames[importedDirective.name.replace('@', '')] = importedDirective.as.replace('@', ''); } } } } + + return originalDirectiveNames; } function extractInputObjContent(args: ExtractArguments, info: TypeInfo[]) { const fieldDirective = args.joinSpec.fieldDirective(args.supergraph); + const originalDirectiveNames = args.originalDirectiveNames; for (const { type, subgraphsInfo } of info) { for (const field of type.fields()) { @@ -484,7 +505,7 @@ function extractInputObjContent(args: ExtractArguments, info: TypeInfo[]) { // This was added in join 0.3, so it can genuinely be undefined. const enumValueDirective = args.joinSpec.enumValueDirective(args.supergraph); + const originalDirectiveNames = args.originalDirectiveNames; for (const { type, subgraphsInfo } of info) { + for (const { type: subgraphType, subgraph } of subgraphsInfo.values()) { + propagateDemandControlDirectives(type, subgraphType, subgraph, originalDirectiveNames); + } + for (const value of type.values) { const enumValueApplications = enumValueDirective ? value.appliedDirectivesOf(enumValueDirective) : []; if (enumValueApplications.length === 0) { @@ -620,6 +646,20 @@ function maybeDumpSubgraphSchema(subgraph: Subgraph): string { } } +function propagateDemandControlDirectives(source: SchemaElement, dest: SchemaElement, subgraph: Subgraph, originalDirectiveNames?: Record) { + const costDirectiveName = originalDirectiveNames?.[FederationDirectiveName.COST] ?? FederationDirectiveName.COST; + const costDirective = source.appliedDirectivesOf(costDirectiveName).pop(); + if (costDirective) { + dest.applyDirective(subgraph.metadata().costDirective().name, costDirective.arguments()); + } + + const listSizeDirectiveName = originalDirectiveNames?.[FederationDirectiveName.LIST_SIZE] ?? FederationDirectiveName.LIST_SIZE; + const listSizeDirective = source.appliedDirectivesOf(listSizeDirectiveName).pop(); + if (listSizeDirective) { + dest.applyDirective(subgraph.metadata().listSizeDirective().name, listSizeDirective.arguments()); + } +} + function errorToString(e: any,): string { const causes = errorCauses(e); return causes ? printErrors(causes) : String(e); @@ -631,12 +671,14 @@ function addSubgraphField({ subgraph, isShareable, joinFieldArgs, + originalDirectiveNames, }: { field: FieldDefinition, type: ObjectType | InterfaceType, subgraph: Subgraph, isShareable: boolean, joinFieldArgs?: JoinFieldDirectiveArguments, + originalDirectiveNames?: Record, }): FieldDefinition { const copiedFieldType = joinFieldArgs?.type ? decodeType(joinFieldArgs.type, subgraph.schema, subgraph.name) @@ -644,7 +686,8 @@ function addSubgraphField({ const subgraphField = type.addField(field.name, copiedFieldType); for (const arg of field.arguments()) { - subgraphField.addArgument(arg.name, copyType(arg.type!, subgraph.schema, subgraph.name), arg.defaultValue); + const argDef = subgraphField.addArgument(arg.name, copyType(arg.type!, subgraph.schema, subgraph.name), arg.defaultValue); + propagateDemandControlDirectives(arg, argDef, subgraph, originalDirectiveNames) } if (joinFieldArgs?.requires) { subgraphField.applyDirective(subgraph.metadata().requiresDirective(), {'fields': joinFieldArgs.requires}); @@ -689,6 +732,9 @@ function addSubgraphField({ if (isShareable && !external && !usedOverridden) { subgraphField.applyDirective(subgraph.metadata().shareableDirective()); } + + propagateDemandControlDirectives(field, subgraphField, subgraph, originalDirectiveNames); + return subgraphField; } @@ -697,11 +743,13 @@ function addSubgraphInputField({ type, subgraph, joinFieldArgs, + originalDirectiveNames, }: { field: InputFieldDefinition, type: InputObjectType, subgraph: Subgraph, joinFieldArgs?: JoinFieldDirectiveArguments, + originalDirectiveNames?: Record }): InputFieldDefinition { const copiedType = joinFieldArgs?.type ? decodeType(joinFieldArgs?.type, subgraph.schema, subgraph.name) @@ -709,6 +757,9 @@ function addSubgraphInputField({ const inputField = type.addField(field.name, copiedType); inputField.defaultValue = field.defaultValue + + propagateDemandControlDirectives(field, inputField, subgraph, originalDirectiveNames); + return inputField; } diff --git a/internals-js/src/federation.ts b/internals-js/src/federation.ts index 5282accba..6980ba593 100644 --- a/internals-js/src/federation.ts +++ b/internals-js/src/federation.ts @@ -100,6 +100,7 @@ import { SourceFieldDirectiveArgs, SourceTypeDirectiveArgs, } from "./specs/sourceSpec"; +import { CostDirectiveArguments, ListSizeDirectiveArguments } from "./specs/costSpec"; const linkSpec = LINK_VERSIONS.latest(); const tagSpec = TAG_VERSIONS.latest(); @@ -1275,6 +1276,14 @@ export class FederationMetadata { return this.getPost20FederationDirective(FederationDirectiveName.CONTEXT); } + costDirective(): Post20FederationDirectiveDefinition { + return this.getPost20FederationDirective(FederationDirectiveName.COST); + } + + listSizeDirective(): Post20FederationDirectiveDefinition { + return this.getPost20FederationDirective(FederationDirectiveName.LIST_SIZE); + } + allFederationDirectives(): DirectiveDefinition[] { const baseDirectives: DirectiveDefinition[] = [ this.keyDirective(), @@ -1338,6 +1347,16 @@ export class FederationMetadata { baseDirectives.push(fromContextDirective); } + const costDirective = this.costDirective(); + if (isFederationDirectiveDefinedInSchema(costDirective)) { + baseDirectives.push(costDirective); + } + + const listSizeDirective = this.listSizeDirective(); + if (isFederationDirectiveDefinedInSchema(listSizeDirective)) { + baseDirectives.push(listSizeDirective); + } + return baseDirectives; } @@ -1831,9 +1850,9 @@ 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.8", import: ["@key", "@requires", "@provides", "@external", "@tag", "@extends", "@shareable", "@inaccessible", "@override", "@composeDirective", "@interfaceObject", "@authenticated", "@requiresScopes", "@policy", "@sourceAPI", "@sourceType", "@sourceField", "@context", "@fromContext"])'; +export const FEDERATION2_LINK_WITH_FULL_IMPORTS = '@link(url: "https://specs.apollo.dev/federation/v2.9", import: ["@key", "@requires", "@provides", "@external", "@tag", "@extends", "@shareable", "@inaccessible", "@override", "@composeDirective", "@interfaceObject", "@authenticated", "@requiresScopes", "@policy", "@sourceAPI", "@sourceType", "@sourceField", "@context", "@fromContext", "@cost", "@listSize"])'; // 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.8", import: ["@key", "@requires", "@provides", "@external", "@tag", "@extends", "@shareable", "@inaccessible", "@override", "@composeDirective", "@interfaceObject"])'; +export const FEDERATION2_LINK_WITH_AUTO_EXPANDED_IMPORTS = '@link(url: "https://specs.apollo.dev/federation/v2.9", import: ["@key", "@requires", "@provides", "@external", "@tag", "@extends", "@shareable", "@inaccessible", "@override", "@composeDirective", "@interfaceObject"])'; // This is the federation @link for tests that go through the SchemaUpgrader. export const FEDERATION2_LINK_WITH_AUTO_EXPANDED_IMPORTS_UPGRADED = '@link(url: "https://specs.apollo.dev/federation/v2.4", 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 3898ccfd8..9400a73ab 100644 --- a/internals-js/src/index.ts +++ b/internals-js/src/index.ts @@ -25,3 +25,4 @@ export * from './specs/authenticatedSpec'; export * from './specs/requiresScopesSpec'; export * from './specs/policySpec'; export * from './specs/sourceSpec'; +export * from './specs/costSpec'; diff --git a/internals-js/src/specs/coreSpec.ts b/internals-js/src/specs/coreSpec.ts index 8c4b95200..909769ef4 100644 --- a/internals-js/src/specs/coreSpec.ts +++ b/internals-js/src/specs/coreSpec.ts @@ -552,6 +552,27 @@ export class CoreSpecDefinition extends FeatureDefinition { return feature.addElementsToSchema(schema); } + applyFeatureAsLink(schema: Schema, feature: FeatureDefinition, purpose?: CorePurpose, imports?: CoreImport[]): GraphQLError[] { + const existing = schema.schemaDefinition.appliedDirectivesOf(linkDirectiveDefaultName).find((link) => link.arguments().url === feature.toString()); + if (existing) { + existing.remove(); + } + + const coreDirective = this.coreDirective(schema); + const args: LinkDirectiveArgs = { + url: feature.toString(), + import: (existing?.arguments().import ?? []).concat(imports?.map((i) => i.as ? { name: `@${i.name}`, as: `@${i.as}` } : `@${i.name}`)), + feature: undefined, + }; + + if (this.supportPurposes() && purpose) { + args.for = purpose; + } + + schema.schemaDefinition.applyDirective(coreDirective, args); + return feature.addElementsToSchema(schema); + } + extractFeatureUrl(args: CoreOrLinkDirectiveArgs): FeatureUrl { return FeatureUrl.parse(args[this.urlArgName()]!); } diff --git a/internals-js/src/specs/costSpec.ts b/internals-js/src/specs/costSpec.ts new file mode 100644 index 000000000..f6f1bda54 --- /dev/null +++ b/internals-js/src/specs/costSpec.ts @@ -0,0 +1,60 @@ +import { DirectiveLocation } from 'graphql'; +import { createDirectiveSpecification } from '../directiveAndTypeSpecification'; +import { FeatureDefinition, FeatureDefinitions, FeatureUrl, FeatureVersion } from './coreSpec'; +import { ListType, NonNullType } from '../definitions'; +import { registerKnownFeature } from '../knownCoreFeatures'; +import { ARGUMENT_COMPOSITION_STRATEGIES } from '../argumentCompositionStrategies'; + +export const costIdentity = 'https://specs.apollo.dev/cost'; + +export class CostSpecDefinition extends FeatureDefinition { + constructor(version: FeatureVersion, readonly minimumFederationVersion: FeatureVersion) { + super(new FeatureUrl(costIdentity, 'cost', version), minimumFederationVersion); + + this.registerDirective(createDirectiveSpecification({ + name: 'cost', + locations: [ + DirectiveLocation.ARGUMENT_DEFINITION, + DirectiveLocation.ENUM, + DirectiveLocation.FIELD_DEFINITION, + DirectiveLocation.INPUT_FIELD_DEFINITION, + DirectiveLocation.OBJECT, + DirectiveLocation.SCALAR + ], + args: [{ name: 'weight', type: (schema) => new NonNullType(schema.intType()), compositionStrategy: ARGUMENT_COMPOSITION_STRATEGIES.MAX }], + composes: true, + repeatable: false, + supergraphSpecification: (fedVersion) => COST_VERSIONS.getMinimumRequiredVersion(fedVersion), + })); + + this.registerDirective(createDirectiveSpecification({ + name: 'listSize', + locations: [DirectiveLocation.FIELD_DEFINITION], + args: [ + { name: 'assumedSize', type: (schema) => schema.intType(), compositionStrategy: ARGUMENT_COMPOSITION_STRATEGIES.NULLABLE_MAX }, + { name: 'slicingArguments', type: (schema) => new ListType(new NonNullType(schema.stringType())), compositionStrategy: ARGUMENT_COMPOSITION_STRATEGIES.NULLABLE_UNION }, + { name: 'sizedFields', type: (schema) => new ListType(new NonNullType(schema.stringType())), compositionStrategy: ARGUMENT_COMPOSITION_STRATEGIES.NULLABLE_UNION }, + { name: 'requireOneSlicingArgument', type: (schema) => schema.booleanType(), defaultValue: true, compositionStrategy: ARGUMENT_COMPOSITION_STRATEGIES.NULLABLE_AND }, + ], + composes: true, + repeatable: false, + supergraphSpecification: (fedVersion) => COST_VERSIONS.getMinimumRequiredVersion(fedVersion) + })); + } +} + +export const COST_VERSIONS = new FeatureDefinitions(costIdentity) + .add(new CostSpecDefinition(new FeatureVersion(0, 1), new FeatureVersion(2, 9))); + +registerKnownFeature(COST_VERSIONS); + +export interface CostDirectiveArguments { + weight: number; +} + +export interface ListSizeDirectiveArguments { + assumedSize?: number; + slicingArguments?: string[]; + sizedFields?: string[]; + requireOneSlicingArgument?: boolean; +} diff --git a/internals-js/src/specs/federationSpec.ts b/internals-js/src/specs/federationSpec.ts index 16adeb26b..0b8c52542 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 { SOURCE_VERSIONS } from './sourceSpec'; import { CONTEXT_VERSIONS } from './contextSpec'; +import { COST_VERSIONS } from "./costSpec"; export const federationIdentity = 'https://specs.apollo.dev/federation'; @@ -48,6 +49,8 @@ export enum FederationDirectiveName { SOURCE_FIELD = 'sourceField', CONTEXT = 'context', FROM_CONTEXT = 'fromContext', + COST = 'cost', + LIST_SIZE = 'listSize', } const fieldSetTypeSpec = createScalarTypeSpecification({ name: FederationTypeName.FIELD_SET }); @@ -182,6 +185,10 @@ export class FederationSpecDefinition extends FeatureDefinition { if (version.gte(new FeatureVersion(2, 8))) { this.registerSubFeature(CONTEXT_VERSIONS.find(new FeatureVersion(0, 1))!); } + + if (version.gte(new FeatureVersion(2, 9))) { + this.registerSubFeature(COST_VERSIONS.find(new FeatureVersion(0, 1))!); + } } } @@ -194,6 +201,7 @@ export const FEDERATION_VERSIONS = new FeatureDefinitions