From 977e021a28751f80f5ed4f6f2a8730f374094a8f Mon Sep 17 00:00:00 2001 From: Taylor Ninesling Date: Tue, 9 Jul 2024 16:33:08 -0500 Subject: [PATCH 01/14] Started implementation of new directives using merge strategy instead of join --- .../__tests__/compose.demandControl.test.ts | 516 ++++++++++++++++++ .../src/argumentCompositionStrategies.ts | 31 ++ internals-js/src/federation.ts | 20 + internals-js/src/index.ts | 2 + internals-js/src/specs/costSpec.ts | 39 ++ internals-js/src/specs/federationSpec.ts | 12 +- internals-js/src/specs/listSizeSpec.ts | 40 ++ internals-js/src/supergraphs.ts | 2 + 8 files changed, 661 insertions(+), 1 deletion(-) create mode 100644 composition-js/src/__tests__/compose.demandControl.test.ts create mode 100644 internals-js/src/specs/costSpec.ts create mode 100644 internals-js/src/specs/listSizeSpec.ts 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..85116d370 --- /dev/null +++ b/composition-js/src/__tests__/compose.demandControl.test.ts @@ -0,0 +1,516 @@ +import { + asFed2SubgraphDocument, + costIdentity, + FEDERATION2_LINK_WITH_AUTO_EXPANDED_IMPORTS, + ServiceDefinition, + Supergraph +} from '@apollo/federation-internals'; +import { composeServices } from '../compose'; +import gql from 'graphql-tag'; +import { assertCompositionSuccess } from "./testHelper"; + +const subgraphWithCost = { + name: 'subgraphWithCost', + typeDefs: asFed2SubgraphDocument(gql` + extend schema @link(url: "https://specs.apollo.dev/cost/v0.1", import: ["@cost"]) + + enum AorB { + A + B + } + + type Query { + fieldWithCost: Int @cost(weight: 5) + inputFieldWithCost(input: Int @cost(weight: 10)): Int + enumWithCost: AorB @cost(weight: 15) + } + `), +}; + +const subgraphWithListSize = { + name: 'subgraphWithListSize', + typeDefs: asFed2SubgraphDocument(gql` + extend schema @link(url: "https://specs.apollo.dev/listSize/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 { + A + B + } + + type Query { + fieldWithCost: Int @renamedCost(weight: 5) + inputFieldWithCost(input: Int @renamedCost(weight: 10)): Int + enumWithCost: AorB @renamedCost(weight: 15) + } + `), +}; + +const subgraphWithRenamedListSize = { + name: 'subgraphWithListSize', + typeDefs: asFed2SubgraphDocument(gql` + extend schema @link(url: "https://specs.apollo.dev/listSize/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 { + A + B + } + + type Query { + fieldWithCost: Int @cost(weight: 5) + inputFieldWithCost(input: Int @cost(weight: 10)): Int + enumWithCost: AorB @cost(weight: 15) + } + `, + { 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 { + A + B + } + + type Query { + fieldWithCost: Int @renamedCost(weight: 5) + inputFieldWithCost(input: Int @renamedCost(weight: 10)): Int + enumWithCost: AorB @renamedCost(weight: 15) + } + `, +}; + +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) + } + `, +}; + +describe('demand control directive composition', () => { + it.each([ + subgraphWithCost, + subgraphWithRenamedCost, + subgraphWithCostFromFederationSpec, + subgraphWithRenamedCostFromFederationSpec + ])('does not include @cost as a core feature', (subgraph: ServiceDefinition) => { + const result = composeServices([subgraph]); + + assertCompositionSuccess(result); + expect(result.schema.coreFeatures?.getByIdentity(costIdentity)).toBeUndefined(); + }); + + it.each([ + subgraphWithListSize, + subgraphWithRenamedListSize, + subgraphWithListSizeFromFederationSpec, + subgraphWithRenamedListSizeFromFederationSpec + ])('does not include @listSize as a core feature', (subgraph: ServiceDefinition) => { + const result = composeServices([subgraph]); + + assertCompositionSuccess(result); + expect(result.schema.coreFeatures?.getByIdentity(costIdentity)).toBeUndefined(); + }); + + it.only('propagates @cost and @listSize to the supergraph using @join__directive', () => { + const result = composeServices([subgraphWithCost, subgraphWithListSize]); + assertCompositionSuccess(result); + + const costDirectiveApplications = result + .schema + .schemaDefinition + .rootType('query') + ?.field('fieldWithCost') + ?.appliedDirectivesOf('join__directive') + .toString(); + expect(costDirectiveApplications).toMatchString(`@join__directive(graphs: [SUBGRAPHWITHCOST], name: "cost", args: {weight: 5})`); + + const inputCostDirectiveApplications = result + .schema + .schemaDefinition + .rootType('query') + ?.field('inputFieldWithCost') + ?.argument("input") + ?.appliedDirectivesOf('join__directive') + .toString(); + expect(inputCostDirectiveApplications).toMatchString(`@join__directive(graphs: [SUBGRAPHWITHCOST], name: "cost", args: {weight: 10})`); + + const enumCostDirectiveApplications = result + .schema + .schemaDefinition + .rootType('query') + ?.field('enumWithCost') + ?.appliedDirectivesOf('join__directive') + .toString(); + expect(enumCostDirectiveApplications).toMatchString(`@join__directive(graphs: [SUBGRAPHWITHCOST], name: "cost", args: {weight: 15})`); + + const listSizeDirectiveApplications = result + .schema + .schemaDefinition + .rootType('query') + ?.field('fieldWithListSize') + ?.appliedDirectivesOf('join__directive') + .toString(); + expect(listSizeDirectiveApplications).toMatchString(`@join__directive(graphs: [SUBGRAPHWITHLISTSIZE], name: "listSize", args: {assumedSize: 2000, requireOneSlicingArgument: false})`); + }); + + it.each([ + [subgraphWithCost, subgraphWithListSize], + [subgraphWithCostFromFederationSpec, subgraphWithListSizeFromFederationSpec], + ])('propagates @cost and @listSize to the supergraph using @join__directive', (costSubgraph: ServiceDefinition, listSizeSubgraph: ServiceDefinition) => { + const result = composeServices([costSubgraph, listSizeSubgraph]); + assertCompositionSuccess(result); + + const costDirectiveApplications = result + .schema + .schemaDefinition + .rootType('query') + ?.field('fieldWithCost') + ?.appliedDirectivesOf('join__directive') + .toString(); + expect(costDirectiveApplications).toMatchString(`@join__directive(graphs: [SUBGRAPHWITHCOST], name: "cost", args: {weight: 5})`); + + const inputCostDirectiveApplications = result + .schema + .schemaDefinition + .rootType('query') + ?.field('inputFieldWithCost') + ?.argument("input") + ?.appliedDirectivesOf('join__directive') + .toString(); + expect(inputCostDirectiveApplications).toMatchString(`@join__directive(graphs: [SUBGRAPHWITHCOST], name: "cost", args: {weight: 10})`); + + const enumCostDirectiveApplications = result + .schema + .schemaDefinition + .rootType('query') + ?.field('enumWithCost') + ?.appliedDirectivesOf('join__directive') + .toString(); + expect(enumCostDirectiveApplications).toMatchString(`@join__directive(graphs: [SUBGRAPHWITHCOST], name: "cost", args: {weight: 15})`); + + const listSizeDirectiveApplications = result + .schema + .schemaDefinition + .rootType('query') + ?.field('fieldWithListSize') + ?.appliedDirectivesOf('join__directive') + .toString(); + expect(listSizeDirectiveApplications).toMatchString(`@join__directive(graphs: [SUBGRAPHWITHLISTSIZE], name: "listSize", args: {assumedSize: 2000, requireOneSlicingArgument: false})`); + }); + + describe('when renamed', () => { + it.each([ + [subgraphWithRenamedCost, subgraphWithRenamedListSize], + [subgraphWithRenamedCostFromFederationSpec, subgraphWithRenamedListSizeFromFederationSpec] + ])('propagates the renamed @cost and @listSize to the supergraph using @join__directive', (costSubgraph: ServiceDefinition, listSizeSubgraph: ServiceDefinition) => { + const result = composeServices([costSubgraph, listSizeSubgraph]); + assertCompositionSuccess(result); + + const costDirectiveApplications = result + .schema + .schemaDefinition + .rootType('query') + ?.field('fieldWithCost') + ?.appliedDirectivesOf('join__directive') + .toString(); + expect(costDirectiveApplications).toMatchString(`@join__directive(graphs: [SUBGRAPHWITHCOST], name: "renamedCost", args: {weight: 5})`); + + const inputCostDirectiveApplications = result + .schema + .schemaDefinition + .rootType('query') + ?.field('inputFieldWithCost') + ?.argument("input") + ?.appliedDirectivesOf('join__directive') + .toString(); + expect(inputCostDirectiveApplications).toMatchString(`@join__directive(graphs: [SUBGRAPHWITHCOST], name: "renamedCost", args: {weight: 10})`); + + const enumCostDirectiveApplications = result + .schema + .schemaDefinition + .rootType('query') + ?.field('enumWithCost') + ?.appliedDirectivesOf('join__directive') + .toString(); + expect(enumCostDirectiveApplications).toMatchString(`@join__directive(graphs: [SUBGRAPHWITHCOST], name: "renamedCost", args: {weight: 15})`); + + const listSizeDirectiveApplications = result + .schema + .schemaDefinition + .rootType('query') + ?.field('fieldWithListSize') + ?.appliedDirectivesOf('join__directive') + .toString(); + expect(listSizeDirectiveApplications).toMatchString(`@join__directive(graphs: [SUBGRAPHWITHLISTSIZE], name: "renamedListSize", args: {assumedSize: 2000, requireOneSlicingArgument: false})`); + }); + }); +}); + +describe('demand control directive extraction', () => { + it.each([ + subgraphWithCost, + subgraphWithRenamedCost, + subgraphWithCostFromFederationSpec, + subgraphWithRenamedCostFromFederationSpec + ])('extracts @cost from the supergraph', (subgraph: ServiceDefinition) => { + const result = composeServices([subgraph]); + assertCompositionSuccess(result); + expect(result.hints.length).toBe(0); + 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 { + A + B + } + + type Query { + fieldWithCost: Int @federation__cost(weight: 5) + inputFieldWithCost(input: Int @federation__cost(weight: 10)): Int + enumWithCost: AorB @federation__cost(weight: 15) + } + `); + }); + + it.each([ + subgraphWithListSize, + subgraphWithRenamedListSize, + subgraphWithListSizeFromFederationSpec, + subgraphWithRenamedListSizeFromFederationSpec + ])('extracts @listSize from the supergraph', (subgraph: ServiceDefinition) => { + const result = composeServices([subgraph]); + assertCompositionSuccess(result); + expect(result.hints.length).toBe(0); + 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 the correct @cost for different subgraphs with @shareable fields', () => { + 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.length).toBe(0); + const supergraph = Supergraph.build(result.supergraphSdl); + + expect(supergraph.subgraphs().get(subgraphA.name)?.toString()).toMatchString(` + schema + ${FEDERATION2_LINK_WITH_AUTO_EXPANDED_IMPORTS} + { + query: Query + } + + type Query { + sharedWithCost: Int @shareable @federation__cost(weight: 5) + } + `); + expect(supergraph.subgraphs().get(subgraphB.name)?.toString()).toMatchString(` + schema + ${FEDERATION2_LINK_WITH_AUTO_EXPANDED_IMPORTS} + { + query: Query + } + + type Query { + sharedWithCost: Int @shareable @federation__cost(weight: 10) + } + `); + }); + + it('extracts the correct @listSize for different subgraphs with @shareable fields', () => { + const subgraphA = { + name: 'subgraph-a', + typeDefs: asFed2SubgraphDocument(gql` + extend schema @link(url: "https://specs.apollo.dev/listSize/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/listSize/v0.1", import: ["@listSize"]) + + type Query { + sharedWithListSize: [Int] @shareable @listSize(assumedSize: 20) + } + `) + }; + + const result = composeServices([subgraphA, subgraphB]); + assertCompositionSuccess(result); + expect(result.hints.length).toBe(0); + const supergraph = Supergraph.build(result.supergraphSdl); + + expect(supergraph.subgraphs().get(subgraphA.name)?.toString()).toMatchString(` + schema + ${FEDERATION2_LINK_WITH_AUTO_EXPANDED_IMPORTS} + { + query: Query + } + + type Query { + sharedWithListSize: [Int] @shareable @federation__listSize(assumedSize: 10) + } + `); + expect(supergraph.subgraphs().get(subgraphB.name)?.toString()).toMatchString(` + schema + ${FEDERATION2_LINK_WITH_AUTO_EXPANDED_IMPORTS} + { + query: Query + } + + type Query { + sharedWithListSize: [Int] @shareable @federation__listSize(assumedSize: 20) + } + `); + }); + + it('extracts @listSize with dynamic cost arguments', () => { + const subgraphA = { + name: 'subgraph-a', + typeDefs: asFed2SubgraphDocument(gql` + extend schema @link(url: "https://specs.apollo.dev/listSize/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/listSize/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); + expect(result.hints.length).toBe(0); + const supergraph = Supergraph.build(result.supergraphSdl); + + expect(supergraph.subgraphs().get(subgraphA.name)?.toString()).toMatchString(` + schema + ${FEDERATION2_LINK_WITH_AUTO_EXPANDED_IMPORTS} + { + query: Query + } + + type HasInts { + ints: [Int!] @shareable + } + + type Query { + sizedList(first: Int!): HasInts @shareable @federation__listSize(sizedFields: ["ints"], slicingArguments: ["first"], requireOneSlicingArgument: true) + } + `); + expect(supergraph.subgraphs().get(subgraphB.name)?.toString()).toMatchString(` + schema + ${FEDERATION2_LINK_WITH_AUTO_EXPANDED_IMPORTS} + { + query: Query + } + + type HasInts { + ints: [Int!] @shareable + } + + type Query { + sizedList(first: Int!): HasInts @shareable @federation__listSize(sizedFields: ["ints"], slicingArguments: ["first"], requireOneSlicingArgument: false) + } + `); + }) +}); diff --git a/internals-js/src/argumentCompositionStrategies.ts b/internals-js/src/argumentCompositionStrategies.ts index 9ceebaa90..73644b9df 100644 --- a/internals-js/src/argumentCompositionStrategies.ts +++ b/internals-js/src/argumentCompositionStrategies.ts @@ -59,4 +59,35 @@ export const ARGUMENT_COMPOSITION_STRATEGIES = { return acc.concat(newValues); }, []), }, + NULLABLE_OR: { + name: 'NULLABLE_OR', + 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; + } + }), + }, + 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[][]) => { + const combined = new Set(); + for (const subgraphValues of values) { + for (const value of subgraphValues) { + combined.add(value); + } + } + return Array.from(combined); + } + } } diff --git a/internals-js/src/federation.ts b/internals-js/src/federation.ts index 5282accba..ca5d366f2 100644 --- a/internals-js/src/federation.ts +++ b/internals-js/src/federation.ts @@ -100,6 +100,8 @@ import { SourceFieldDirectiveArgs, SourceTypeDirectiveArgs, } from "./specs/sourceSpec"; +import { CostDirectiveArguments } from "./specs/costSpec"; +import { ListSizeDirectiveArguments } from "./specs/listSizeSpec"; const linkSpec = LINK_VERSIONS.latest(); const tagSpec = TAG_VERSIONS.latest(); @@ -1275,6 +1277,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 +1348,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; } diff --git a/internals-js/src/index.ts b/internals-js/src/index.ts index 3898ccfd8..f072902f4 100644 --- a/internals-js/src/index.ts +++ b/internals-js/src/index.ts @@ -25,3 +25,5 @@ export * from './specs/authenticatedSpec'; export * from './specs/requiresScopesSpec'; export * from './specs/policySpec'; export * from './specs/sourceSpec'; +export * from './specs/costSpec'; +export * from './specs/listSizeSpec'; diff --git a/internals-js/src/specs/costSpec.ts b/internals-js/src/specs/costSpec.ts new file mode 100644 index 000000000..7edcbafe4 --- /dev/null +++ b/internals-js/src/specs/costSpec.ts @@ -0,0 +1,39 @@ +import { DirectiveLocation } from 'graphql'; +import { createDirectiveSpecification } from '../directiveAndTypeSpecification'; +import { FeatureDefinition, FeatureDefinitions, FeatureUrl, FeatureVersion } from './coreSpec'; +import { 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), + })); + } +} + +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; +} diff --git a/internals-js/src/specs/federationSpec.ts b/internals-js/src/specs/federationSpec.ts index 16adeb26b..e22c7adfe 100644 --- a/internals-js/src/specs/federationSpec.ts +++ b/internals-js/src/specs/federationSpec.ts @@ -20,6 +20,8 @@ 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"; +import { LIST_SIZE_VERSIONS } from "./listSizeSpec"; export const federationIdentity = 'https://specs.apollo.dev/federation'; @@ -48,6 +50,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 +186,11 @@ 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))!); + this.registerSubFeature(LIST_SIZE_VERSIONS.find(new FeatureVersion(0, 1))!); + } } } @@ -194,6 +203,7 @@ export const FEDERATION_VERSIONS = new FeatureDefinitions 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_OR }, + ], + composes: true, + repeatable: false, + supergraphSpecification: (fedVersion) => LIST_SIZE_VERSIONS.getMinimumRequiredVersion(fedVersion) + })); + } +} + +export const LIST_SIZE_VERSIONS = new FeatureDefinitions(listSizeIdentity) + .add(new ListSizeSpecDefinition(new FeatureVersion(0, 1), new FeatureVersion(2, 9))); + +registerKnownFeature(LIST_SIZE_VERSIONS); + +export interface ListSizeDirectiveArguments { + assumedSize?: number; + slicingArguments?: string[]; + sizedFields?: string[]; + requireOneSlicingArgument?: boolean; +} diff --git a/internals-js/src/supergraphs.ts b/internals-js/src/supergraphs.ts index 65fe253cd..d1529414d 100644 --- a/internals-js/src/supergraphs.ts +++ b/internals-js/src/supergraphs.ts @@ -40,6 +40,8 @@ export const ROUTER_SUPPORTED_SUPERGRAPH_FEATURES = new Set([ 'https://specs.apollo.dev/policy/v0.1', 'https://specs.apollo.dev/source/v0.1', 'https://specs.apollo.dev/context/v0.1', + 'https://specs.apollo.dev/cost/v0.1', + 'https://specs.apollo.dev/listSize/v0.1', ]); const coreVersionZeroDotOneUrl = FeatureUrl.parse('https://specs.apollo.dev/core/v0.1'); From 484db3fdc00fc2fd93d239a998e51745f99c8a9a Mon Sep 17 00:00:00 2001 From: Taylor Ninesling Date: Tue, 9 Jul 2024 16:50:19 -0500 Subject: [PATCH 02/14] Composition works, but extraction doesn't --- .../__tests__/compose.demandControl.test.ts | 109 ++++-------------- internals-js/src/federation.ts | 4 +- 2 files changed, 23 insertions(+), 90 deletions(-) diff --git a/composition-js/src/__tests__/compose.demandControl.test.ts b/composition-js/src/__tests__/compose.demandControl.test.ts index 85116d370..bd12e0f72 100644 --- a/composition-js/src/__tests__/compose.demandControl.test.ts +++ b/composition-js/src/__tests__/compose.demandControl.test.ts @@ -1,6 +1,5 @@ import { asFed2SubgraphDocument, - costIdentity, FEDERATION2_LINK_WITH_AUTO_EXPANDED_IMPORTS, ServiceDefinition, Supergraph @@ -130,72 +129,6 @@ const subgraphWithRenamedListSizeFromFederationSpec = { }; describe('demand control directive composition', () => { - it.each([ - subgraphWithCost, - subgraphWithRenamedCost, - subgraphWithCostFromFederationSpec, - subgraphWithRenamedCostFromFederationSpec - ])('does not include @cost as a core feature', (subgraph: ServiceDefinition) => { - const result = composeServices([subgraph]); - - assertCompositionSuccess(result); - expect(result.schema.coreFeatures?.getByIdentity(costIdentity)).toBeUndefined(); - }); - - it.each([ - subgraphWithListSize, - subgraphWithRenamedListSize, - subgraphWithListSizeFromFederationSpec, - subgraphWithRenamedListSizeFromFederationSpec - ])('does not include @listSize as a core feature', (subgraph: ServiceDefinition) => { - const result = composeServices([subgraph]); - - assertCompositionSuccess(result); - expect(result.schema.coreFeatures?.getByIdentity(costIdentity)).toBeUndefined(); - }); - - it.only('propagates @cost and @listSize to the supergraph using @join__directive', () => { - const result = composeServices([subgraphWithCost, subgraphWithListSize]); - assertCompositionSuccess(result); - - const costDirectiveApplications = result - .schema - .schemaDefinition - .rootType('query') - ?.field('fieldWithCost') - ?.appliedDirectivesOf('join__directive') - .toString(); - expect(costDirectiveApplications).toMatchString(`@join__directive(graphs: [SUBGRAPHWITHCOST], name: "cost", args: {weight: 5})`); - - const inputCostDirectiveApplications = result - .schema - .schemaDefinition - .rootType('query') - ?.field('inputFieldWithCost') - ?.argument("input") - ?.appliedDirectivesOf('join__directive') - .toString(); - expect(inputCostDirectiveApplications).toMatchString(`@join__directive(graphs: [SUBGRAPHWITHCOST], name: "cost", args: {weight: 10})`); - - const enumCostDirectiveApplications = result - .schema - .schemaDefinition - .rootType('query') - ?.field('enumWithCost') - ?.appliedDirectivesOf('join__directive') - .toString(); - expect(enumCostDirectiveApplications).toMatchString(`@join__directive(graphs: [SUBGRAPHWITHCOST], name: "cost", args: {weight: 15})`); - - const listSizeDirectiveApplications = result - .schema - .schemaDefinition - .rootType('query') - ?.field('fieldWithListSize') - ?.appliedDirectivesOf('join__directive') - .toString(); - expect(listSizeDirectiveApplications).toMatchString(`@join__directive(graphs: [SUBGRAPHWITHLISTSIZE], name: "listSize", args: {assumedSize: 2000, requireOneSlicingArgument: false})`); - }); - it.each([ [subgraphWithCost, subgraphWithListSize], [subgraphWithCostFromFederationSpec, subgraphWithListSizeFromFederationSpec], @@ -208,9 +141,9 @@ describe('demand control directive composition', () => { .schemaDefinition .rootType('query') ?.field('fieldWithCost') - ?.appliedDirectivesOf('join__directive') + ?.appliedDirectivesOf('cost') .toString(); - expect(costDirectiveApplications).toMatchString(`@join__directive(graphs: [SUBGRAPHWITHCOST], name: "cost", args: {weight: 5})`); + expect(costDirectiveApplications).toMatchString(`@cost(weight: 5)`); const inputCostDirectiveApplications = result .schema @@ -218,27 +151,27 @@ describe('demand control directive composition', () => { .rootType('query') ?.field('inputFieldWithCost') ?.argument("input") - ?.appliedDirectivesOf('join__directive') + ?.appliedDirectivesOf('cost') .toString(); - expect(inputCostDirectiveApplications).toMatchString(`@join__directive(graphs: [SUBGRAPHWITHCOST], name: "cost", args: {weight: 10})`); + expect(inputCostDirectiveApplications).toMatchString(`@cost(weight: 10)`); const enumCostDirectiveApplications = result .schema .schemaDefinition .rootType('query') ?.field('enumWithCost') - ?.appliedDirectivesOf('join__directive') + ?.appliedDirectivesOf('cost') .toString(); - expect(enumCostDirectiveApplications).toMatchString(`@join__directive(graphs: [SUBGRAPHWITHCOST], name: "cost", args: {weight: 15})`); + expect(enumCostDirectiveApplications).toMatchString(`@cost(weight: 15)`); const listSizeDirectiveApplications = result .schema .schemaDefinition .rootType('query') ?.field('fieldWithListSize') - ?.appliedDirectivesOf('join__directive') + ?.appliedDirectivesOf('listSize') .toString(); - expect(listSizeDirectiveApplications).toMatchString(`@join__directive(graphs: [SUBGRAPHWITHLISTSIZE], name: "listSize", args: {assumedSize: 2000, requireOneSlicingArgument: false})`); + expect(listSizeDirectiveApplications).toMatchString(`@listSize(assumedSize: 2000, requireOneSlicingArgument: false)`); }); describe('when renamed', () => { @@ -254,9 +187,9 @@ describe('demand control directive composition', () => { .schemaDefinition .rootType('query') ?.field('fieldWithCost') - ?.appliedDirectivesOf('join__directive') + ?.appliedDirectivesOf('renamedCost') .toString(); - expect(costDirectiveApplications).toMatchString(`@join__directive(graphs: [SUBGRAPHWITHCOST], name: "renamedCost", args: {weight: 5})`); + expect(costDirectiveApplications).toMatchString(`@renamedCost(weight: 5)`); const inputCostDirectiveApplications = result .schema @@ -264,27 +197,27 @@ describe('demand control directive composition', () => { .rootType('query') ?.field('inputFieldWithCost') ?.argument("input") - ?.appliedDirectivesOf('join__directive') + ?.appliedDirectivesOf('renamedCost') .toString(); - expect(inputCostDirectiveApplications).toMatchString(`@join__directive(graphs: [SUBGRAPHWITHCOST], name: "renamedCost", args: {weight: 10})`); + expect(inputCostDirectiveApplications).toMatchString(`@renamedCost(weight: 10)`); const enumCostDirectiveApplications = result .schema .schemaDefinition .rootType('query') ?.field('enumWithCost') - ?.appliedDirectivesOf('join__directive') + ?.appliedDirectivesOf('renamedCost') .toString(); - expect(enumCostDirectiveApplications).toMatchString(`@join__directive(graphs: [SUBGRAPHWITHCOST], name: "renamedCost", args: {weight: 15})`); + expect(enumCostDirectiveApplications).toMatchString(`@renamedCost(weight: 15)`); const listSizeDirectiveApplications = result .schema .schemaDefinition .rootType('query') ?.field('fieldWithListSize') - ?.appliedDirectivesOf('join__directive') + ?.appliedDirectivesOf('renamedListSize') .toString(); - expect(listSizeDirectiveApplications).toMatchString(`@join__directive(graphs: [SUBGRAPHWITHLISTSIZE], name: "renamedListSize", args: {assumedSize: 2000, requireOneSlicingArgument: false})`); + expect(listSizeDirectiveApplications).toMatchString(`@renamedListSize(assumedSize: 2000, requireOneSlicingArgument: false)`); }); }); }); @@ -298,7 +231,7 @@ describe('demand control directive extraction', () => { ])('extracts @cost from the supergraph', (subgraph: ServiceDefinition) => { const result = composeServices([subgraph]); assertCompositionSuccess(result); - expect(result.hints.length).toBe(0); + expect(result.hints).toEqual([]); const extracted = Supergraph.build(result.supergraphSdl).subgraphs().get(subgraphWithCost.name); expect(extracted?.toString()).toMatchString(` @@ -329,7 +262,7 @@ describe('demand control directive extraction', () => { ])('extracts @listSize from the supergraph', (subgraph: ServiceDefinition) => { const result = composeServices([subgraph]); assertCompositionSuccess(result); - expect(result.hints.length).toBe(0); + expect(result.hints).toEqual([]); const extracted = Supergraph.build(result.supergraphSdl).subgraphs().get(subgraphWithListSize.name); expect(extracted?.toString()).toMatchString(` @@ -369,7 +302,7 @@ describe('demand control directive extraction', () => { const result = composeServices([subgraphA, subgraphB]); assertCompositionSuccess(result); - expect(result.hints.length).toBe(0); + expect(result.hints).toEqual([]); const supergraph = Supergraph.build(result.supergraphSdl); expect(supergraph.subgraphs().get(subgraphA.name)?.toString()).toMatchString(` @@ -420,7 +353,7 @@ describe('demand control directive extraction', () => { const result = composeServices([subgraphA, subgraphB]); assertCompositionSuccess(result); - expect(result.hints.length).toBe(0); + expect(result.hints).toEqual([]); const supergraph = Supergraph.build(result.supergraphSdl); expect(supergraph.subgraphs().get(subgraphA.name)?.toString()).toMatchString(` @@ -479,7 +412,7 @@ describe('demand control directive extraction', () => { const result = composeServices([subgraphA, subgraphB]); assertCompositionSuccess(result); - expect(result.hints.length).toBe(0); + expect(result.hints).toEqual([]); const supergraph = Supergraph.build(result.supergraphSdl); expect(supergraph.subgraphs().get(subgraphA.name)?.toString()).toMatchString(` diff --git a/internals-js/src/federation.ts b/internals-js/src/federation.ts index ca5d366f2..197683d4e 100644 --- a/internals-js/src/federation.ts +++ b/internals-js/src/federation.ts @@ -1851,9 +1851,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"])'; // 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"])'; From 680d07e74d2d129a9319f044d152927f9e8c8b5f Mon Sep 17 00:00:00 2001 From: Taylor Ninesling Date: Tue, 9 Jul 2024 17:30:02 -0500 Subject: [PATCH 03/14] Cost argument definition extraction working for demand control directives --- .../__tests__/compose.demandControl.test.ts | 24 +++++++++--------- .../src/argumentCompositionStrategies.ts | 20 +++++++++------ .../src/extractSubgraphsFromSupergraph.ts | 25 +++++++++++++++++-- internals-js/src/specs/listSizeSpec.ts | 2 +- 4 files changed, 49 insertions(+), 22 deletions(-) diff --git a/composition-js/src/__tests__/compose.demandControl.test.ts b/composition-js/src/__tests__/compose.demandControl.test.ts index bd12e0f72..c56509163 100644 --- a/composition-js/src/__tests__/compose.demandControl.test.ts +++ b/composition-js/src/__tests__/compose.demandControl.test.ts @@ -231,7 +231,7 @@ describe('demand control directive extraction', () => { ])('extracts @cost from the supergraph', (subgraph: ServiceDefinition) => { const result = composeServices([subgraph]); assertCompositionSuccess(result); - expect(result.hints).toEqual([]); + // expect(result.hints).toEqual([]); const extracted = Supergraph.build(result.supergraphSdl).subgraphs().get(subgraphWithCost.name); expect(extracted?.toString()).toMatchString(` @@ -262,7 +262,7 @@ describe('demand control directive extraction', () => { ])('extracts @listSize from the supergraph', (subgraph: ServiceDefinition) => { const result = composeServices([subgraph]); assertCompositionSuccess(result); - expect(result.hints).toEqual([]); + // expect(result.hints).toEqual([]); const extracted = Supergraph.build(result.supergraphSdl).subgraphs().get(subgraphWithListSize.name); expect(extracted?.toString()).toMatchString(` @@ -278,7 +278,7 @@ describe('demand control directive extraction', () => { `); }); - it('extracts the correct @cost for different subgraphs with @shareable fields', () => { + it('extracts the merged (max) @cost for different subgraphs with @shareable fields', () => { const subgraphA = { name: 'subgraph-a', typeDefs: asFed2SubgraphDocument(gql` @@ -302,7 +302,7 @@ describe('demand control directive extraction', () => { const result = composeServices([subgraphA, subgraphB]); assertCompositionSuccess(result); - expect(result.hints).toEqual([]); + // expect(result.hints).toEqual([]); const supergraph = Supergraph.build(result.supergraphSdl); expect(supergraph.subgraphs().get(subgraphA.name)?.toString()).toMatchString(` @@ -313,7 +313,7 @@ describe('demand control directive extraction', () => { } type Query { - sharedWithCost: Int @shareable @federation__cost(weight: 5) + sharedWithCost: Int @shareable @federation__cost(weight: 10) } `); expect(supergraph.subgraphs().get(subgraphB.name)?.toString()).toMatchString(` @@ -329,7 +329,7 @@ describe('demand control directive extraction', () => { `); }); - it('extracts the correct @listSize for different subgraphs with @shareable fields', () => { + it('extracts the merged @listSize for different subgraphs with @shareable fields', () => { const subgraphA = { name: 'subgraph-a', typeDefs: asFed2SubgraphDocument(gql` @@ -353,7 +353,7 @@ describe('demand control directive extraction', () => { const result = composeServices([subgraphA, subgraphB]); assertCompositionSuccess(result); - expect(result.hints).toEqual([]); + // expect(result.hints).toEqual([]); const supergraph = Supergraph.build(result.supergraphSdl); expect(supergraph.subgraphs().get(subgraphA.name)?.toString()).toMatchString(` @@ -364,7 +364,7 @@ describe('demand control directive extraction', () => { } type Query { - sharedWithListSize: [Int] @shareable @federation__listSize(assumedSize: 10) + sharedWithListSize: [Int] @shareable @federation__listSize(assumedSize: 20, requireOneSlicingArgument: true) } `); expect(supergraph.subgraphs().get(subgraphB.name)?.toString()).toMatchString(` @@ -375,7 +375,7 @@ describe('demand control directive extraction', () => { } type Query { - sharedWithListSize: [Int] @shareable @federation__listSize(assumedSize: 20) + sharedWithListSize: [Int] @shareable @federation__listSize(assumedSize: 20, requireOneSlicingArgument: true) } `); }); @@ -412,7 +412,7 @@ describe('demand control directive extraction', () => { const result = composeServices([subgraphA, subgraphB]); assertCompositionSuccess(result); - expect(result.hints).toEqual([]); + // expect(result.hints).toEqual([]); const supergraph = Supergraph.build(result.supergraphSdl); expect(supergraph.subgraphs().get(subgraphA.name)?.toString()).toMatchString(` @@ -427,7 +427,7 @@ describe('demand control directive extraction', () => { } type Query { - sizedList(first: Int!): HasInts @shareable @federation__listSize(sizedFields: ["ints"], slicingArguments: ["first"], requireOneSlicingArgument: true) + sizedList(first: Int!): HasInts @shareable @federation__listSize(slicingArguments: ["first"], sizedFields: ["ints"], requireOneSlicingArgument: false) } `); expect(supergraph.subgraphs().get(subgraphB.name)?.toString()).toMatchString(` @@ -442,7 +442,7 @@ describe('demand control directive extraction', () => { } type Query { - sizedList(first: Int!): HasInts @shareable @federation__listSize(sizedFields: ["ints"], slicingArguments: ["first"], requireOneSlicingArgument: false) + sizedList(first: Int!): HasInts @shareable @federation__listSize(slicingArguments: ["first"], sizedFields: ["ints"], requireOneSlicingArgument: false) } `); }) diff --git a/internals-js/src/argumentCompositionStrategies.ts b/internals-js/src/argumentCompositionStrategies.ts index 73644b9df..85a870773 100644 --- a/internals-js/src/argumentCompositionStrategies.ts +++ b/internals-js/src/argumentCompositionStrategies.ts @@ -59,8 +59,8 @@ export const ARGUMENT_COMPOSITION_STRATEGIES = { return acc.concat(newValues); }, []), }, - NULLABLE_OR: { - name: 'NULLABLE_OR', + 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) { @@ -68,9 +68,9 @@ export const ARGUMENT_COMPOSITION_STRATEGIES = { } else if (next === null || next === undefined) { return acc; } else { - return acc || next; + return acc && next; } - }), + }, undefined), }, NULLABLE_MAX: { name: 'NULLABLE_MAX', @@ -80,11 +80,17 @@ export const ARGUMENT_COMPOSITION_STRATEGIES = { NULLABLE_UNION: { name: 'NULLABLE_UNION', isTypeSupported: (_: Schema, type: InputType) => ({ valid: isListType(type) }), - mergeValues: (values: any[][]) => { + mergeValues: (values: any[]) => { + if (values.every((v) => v === undefined)) { + return undefined; + } + const combined = new Set(); for (const subgraphValues of values) { - for (const value of subgraphValues) { - combined.add(value); + 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..19ec8ff49 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, errorCauses, isFederationDirectiveDefinedInSchema, printErrors } from "."; function filteredTypes( supergraph: Schema, @@ -644,7 +644,17 @@ 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); + + const costDirective = arg.appliedDirectivesOf(FederationDirectiveName.COST).pop(); + if (costDirective) { + argDef.applyDirective(subgraph.metadata().costDirective().name, costDirective.arguments()); + } + + const listSizeDirective = arg.appliedDirectivesOf(FederationDirectiveName.LIST_SIZE).pop(); + if (listSizeDirective) { + argDef.applyDirective(subgraph.metadata().listSizeDirective().name, listSizeDirective.arguments()); + } } if (joinFieldArgs?.requires) { subgraphField.applyDirective(subgraph.metadata().requiresDirective(), {'fields': joinFieldArgs.requires}); @@ -689,6 +699,17 @@ function addSubgraphField({ if (isShareable && !external && !usedOverridden) { subgraphField.applyDirective(subgraph.metadata().shareableDirective()); } + + const costDirective = field.appliedDirectivesOf(FederationDirectiveName.COST).pop(); + if (costDirective) { + subgraphField.applyDirective(subgraph.metadata().costDirective().name, costDirective.arguments()); + } + + const listSizeDirective = field.appliedDirectivesOf(FederationDirectiveName.LIST_SIZE).pop(); + if (listSizeDirective) { + subgraphField.applyDirective(subgraph.metadata().listSizeDirective().name, listSizeDirective.arguments()); + } + return subgraphField; } diff --git a/internals-js/src/specs/listSizeSpec.ts b/internals-js/src/specs/listSizeSpec.ts index f8b49bf9b..fca71e321 100644 --- a/internals-js/src/specs/listSizeSpec.ts +++ b/internals-js/src/specs/listSizeSpec.ts @@ -18,7 +18,7 @@ export class ListSizeSpecDefinition extends FeatureDefinition { { 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_OR }, + { name: 'requireOneSlicingArgument', type: (schema) => schema.booleanType(), defaultValue: true, compositionStrategy: ARGUMENT_COMPOSITION_STRATEGIES.NULLABLE_AND }, ], composes: true, repeatable: false, From 0e29ce05c88228035b4760fb8ff79773363e3739 Mon Sep 17 00:00:00 2001 From: Taylor Ninesling Date: Wed, 10 Jul 2024 10:07:56 -0500 Subject: [PATCH 04/14] Carried over directive name mapping from other implementation, but the link structure is slightly different. Possibly due to each directive having its own spec. --- .../__tests__/compose.demandControl.test.ts | 27 ++++++++++++++++ .../src/extractSubgraphsFromSupergraph.ts | 32 ++++++++++++++++--- 2 files changed, 55 insertions(+), 4 deletions(-) diff --git a/composition-js/src/__tests__/compose.demandControl.test.ts b/composition-js/src/__tests__/compose.demandControl.test.ts index c56509163..808ba7b31 100644 --- a/composition-js/src/__tests__/compose.demandControl.test.ts +++ b/composition-js/src/__tests__/compose.demandControl.test.ts @@ -223,6 +223,33 @@ describe('demand control directive composition', () => { }); describe('demand control directive extraction', () => { + it.only('extracts @cost from the supergraph', () => { + const result = composeServices([subgraphWithRenamedCost]); + assertCompositionSuccess(result); + // expect(result.hints).toEqual([]); + console.log(result.supergraphSdl); + 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 { + A + B + } + + type Query { + fieldWithCost: Int @federation__cost(weight: 5) + inputFieldWithCost(input: Int @federation__cost(weight: 10)): Int + enumWithCost: AorB @federation__cost(weight: 15) + } + `); + }); + it.each([ subgraphWithCost, subgraphWithRenamedCost, diff --git a/internals-js/src/extractSubgraphsFromSupergraph.ts b/internals-js/src/extractSubgraphsFromSupergraph.ts index 19ec8ff49..99846cccf 100644 --- a/internals-js/src/extractSubgraphsFromSupergraph.ts +++ b/internals-js/src/extractSubgraphsFromSupergraph.ts @@ -434,6 +434,9 @@ 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,13 +471,30 @@ function extractObjOrItfContent(args: ExtractArguments, info: TypeInfo { + const originalDirectiveNames: Record = {}; + for (const linkDirective of args.supergraph.schemaDefinition.appliedDirectivesOf("link")) { // TODO: Link spec? + if (linkDirective.arguments().as) { + const renamedFeature = args.supergraph.coreFeatures?.getByIdentity(linkDirective.arguments().url); + console.log(`Link ${linkDirective} has renamed ${renamedFeature}`); + } + for (const { name, as } of linkDirective.arguments().args?.import ?? []) { + if (name && as) { + originalDirectiveNames[name.replace('@', '')] = as.replace('@', ''); + } + } + } + + return originalDirectiveNames; +} + function extractInputObjContent(args: ExtractArguments, info: TypeInfo[]) { const fieldDirective = args.joinSpec.fieldDirective(args.supergraph); @@ -631,12 +651,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) @@ -700,12 +722,14 @@ function addSubgraphField({ subgraphField.applyDirective(subgraph.metadata().shareableDirective()); } - const costDirective = field.appliedDirectivesOf(FederationDirectiveName.COST).pop(); + const costDirectiveName = originalDirectiveNames?.[FederationDirectiveName.COST] ?? FederationDirectiveName.COST; + const costDirective = field.appliedDirectivesOf(costDirectiveName).pop(); if (costDirective) { subgraphField.applyDirective(subgraph.metadata().costDirective().name, costDirective.arguments()); } - const listSizeDirective = field.appliedDirectivesOf(FederationDirectiveName.LIST_SIZE).pop(); + const listSizeDirectiveName = originalDirectiveNames?.[FederationDirectiveName.LIST_SIZE] ?? FederationDirectiveName.LIST_SIZE; + const listSizeDirective = field.appliedDirectivesOf(listSizeDirectiveName).pop(); if (listSizeDirective) { subgraphField.applyDirective(subgraph.metadata().listSizeDirective().name, listSizeDirective.arguments()); } From 33e36274addcf654a7f65f57065ad9f465fb957b Mon Sep 17 00:00:00 2001 From: Taylor Ninesling Date: Wed, 10 Jul 2024 12:18:43 -0500 Subject: [PATCH 05/14] Cost extraction for input fields --- .../__tests__/compose.demandControl.test.ts | 139 +++++++++++++++--- .../src/extractSubgraphsFromSupergraph.ts | 59 +++++--- 2 files changed, 161 insertions(+), 37 deletions(-) diff --git a/composition-js/src/__tests__/compose.demandControl.test.ts b/composition-js/src/__tests__/compose.demandControl.test.ts index 808ba7b31..94146a70f 100644 --- a/composition-js/src/__tests__/compose.demandControl.test.ts +++ b/composition-js/src/__tests__/compose.demandControl.test.ts @@ -1,6 +1,7 @@ import { asFed2SubgraphDocument, FEDERATION2_LINK_WITH_AUTO_EXPANDED_IMPORTS, + InputObjectType, ServiceDefinition, Supergraph } from '@apollo/federation-internals'; @@ -18,10 +19,15 @@ const subgraphWithCost = { B } + input InputTypeWithCost { + somethingWithCost: Int @cost(weight: 20) + } + type Query { fieldWithCost: Int @cost(weight: 5) - inputFieldWithCost(input: Int @cost(weight: 10)): Int + argWithCost(arg: Int @cost(weight: 10)): Int enumWithCost: AorB @cost(weight: 15) + inputWithCost(someInput: InputTypeWithCost): Int } `), }; @@ -47,10 +53,15 @@ const subgraphWithRenamedCost = { B } + input InputTypeWithCost { + somethingWithCost: Int @renamedCost(weight: 20) + } + type Query { fieldWithCost: Int @renamedCost(weight: 5) - inputFieldWithCost(input: Int @renamedCost(weight: 10)): Int + argWithCost(arg: Int @renamedCost(weight: 10)): Int enumWithCost: AorB @renamedCost(weight: 15) + inputWithCost(someInput: InputTypeWithCost): Int } `), }; @@ -74,11 +85,16 @@ const subgraphWithCostFromFederationSpec = { A B } + + input InputTypeWithCost { + somethingWithCost: Int @cost(weight: 20) + } type Query { fieldWithCost: Int @cost(weight: 5) - inputFieldWithCost(input: Int @cost(weight: 10)): Int + argWithCost(arg: Int @cost(weight: 10)): Int enumWithCost: AorB @cost(weight: 15) + inputWithCost(someInput: InputTypeWithCost): Int } `, { includeAllImports: true }, @@ -108,10 +124,15 @@ const subgraphWithRenamedCostFromFederationSpec = { B } + input InputTypeWithCost { + somethingWithCost: Int @renamedCost(weight: 20) + } + type Query { fieldWithCost: Int @renamedCost(weight: 5) - inputFieldWithCost(input: Int @renamedCost(weight: 10)): Int + argWithCost(arg: Int @renamedCost(weight: 10)): Int enumWithCost: AorB @renamedCost(weight: 15) + inputWithCost(someInput: InputTypeWithCost): Int } `, }; @@ -129,10 +150,62 @@ const subgraphWithRenamedListSizeFromFederationSpec = { }; describe('demand control directive composition', () => { + it.skip('propagates @cost and @listSize to the supergraph', () => { + const result = composeServices([subgraphWithCost, subgraphWithListSize]); + assertCompositionSuccess(result); + + const costDirectiveApplications = result + .schema + .schemaDefinition + .rootType('query') + ?.field('fieldWithCost') + ?.appliedDirectivesOf('cost') + .toString(); + expect(costDirectiveApplications).toMatchString(`@cost(weight: 5)`); + + const argCostDirectiveApplications = result + .schema + .schemaDefinition + .rootType('query') + ?.field('argWithCost') + ?.argument('arg') + ?.appliedDirectivesOf('cost') + .toString(); + expect(argCostDirectiveApplications).toMatchString(`@cost(weight: 10)`); + + const enumCostDirectiveApplications = result + .schema + .schemaDefinition + .rootType('query') + ?.field('enumWithCost') + ?.appliedDirectivesOf('cost') + .toString(); + expect(enumCostDirectiveApplications).toMatchString(`@cost(weight: 15)`); + + const inputWithCost = result + .schema + .schemaDefinition + .rootType('query') + ?.field('inputWithCost') + ?.argument('someInput') + ?.type as InputObjectType; + const inputCostDirectiveApplications = inputWithCost.field('somethingWithCost')?.appliedDirectivesOf('cost').toString(); + expect(inputCostDirectiveApplications).toMatchString(`@cost(weight: 20)`); + + const listSizeDirectiveApplications = result + .schema + .schemaDefinition + .rootType('query') + ?.field('fieldWithListSize') + ?.appliedDirectivesOf('listSize') + .toString(); + expect(listSizeDirectiveApplications).toMatchString(`@listSize(assumedSize: 2000, requireOneSlicingArgument: false)`); + }); + it.each([ [subgraphWithCost, subgraphWithListSize], [subgraphWithCostFromFederationSpec, subgraphWithListSizeFromFederationSpec], - ])('propagates @cost and @listSize to the supergraph using @join__directive', (costSubgraph: ServiceDefinition, listSizeSubgraph: ServiceDefinition) => { + ])('propagates @cost and @listSize to the supergraph', (costSubgraph: ServiceDefinition, listSizeSubgraph: ServiceDefinition) => { const result = composeServices([costSubgraph, listSizeSubgraph]); assertCompositionSuccess(result); @@ -145,15 +218,15 @@ describe('demand control directive composition', () => { .toString(); expect(costDirectiveApplications).toMatchString(`@cost(weight: 5)`); - const inputCostDirectiveApplications = result + const argCostDirectiveApplications = result .schema .schemaDefinition .rootType('query') - ?.field('inputFieldWithCost') - ?.argument("input") + ?.field('argWithCost') + ?.argument('arg') ?.appliedDirectivesOf('cost') .toString(); - expect(inputCostDirectiveApplications).toMatchString(`@cost(weight: 10)`); + expect(argCostDirectiveApplications).toMatchString(`@cost(weight: 10)`); const enumCostDirectiveApplications = result .schema @@ -164,6 +237,16 @@ describe('demand control directive composition', () => { .toString(); expect(enumCostDirectiveApplications).toMatchString(`@cost(weight: 15)`); + const inputWithCost = result + .schema + .schemaDefinition + .rootType('query') + ?.field('inputWithCost') + ?.argument('someInput') + ?.type as InputObjectType; + const inputCostDirectiveApplications = inputWithCost.field('somethingWithCost')?.appliedDirectivesOf('cost').toString(); + expect(inputCostDirectiveApplications).toMatchString(`@cost(weight: 20)`); + const listSizeDirectiveApplications = result .schema .schemaDefinition @@ -178,10 +261,19 @@ describe('demand control directive composition', () => { it.each([ [subgraphWithRenamedCost, subgraphWithRenamedListSize], [subgraphWithRenamedCostFromFederationSpec, subgraphWithRenamedListSizeFromFederationSpec] - ])('propagates the renamed @cost and @listSize to the supergraph using @join__directive', (costSubgraph: ServiceDefinition, listSizeSubgraph: ServiceDefinition) => { + ])('propagates the renamed @cost and @listSize to the supergraph', (costSubgraph: ServiceDefinition, listSizeSubgraph: ServiceDefinition) => { const result = composeServices([costSubgraph, listSizeSubgraph]); assertCompositionSuccess(result); + // 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 costLink = links.find((link) => link.arguments().url === "https://specs.apollo.dev/cost/v0.1"); + expect(costLink?.arguments().as).toBe("renamedCost"); + + const listSizeLink = links.find((link) => link.arguments().url === "https://specs.apollo.dev/listSize/v0.1"); + expect(listSizeLink?.arguments().as).toBe("renamedListSize"); + + // Ensure the directives are applied to the expected fields with the new names const costDirectiveApplications = result .schema .schemaDefinition @@ -191,15 +283,15 @@ describe('demand control directive composition', () => { .toString(); expect(costDirectiveApplications).toMatchString(`@renamedCost(weight: 5)`); - const inputCostDirectiveApplications = result + const argCostDirectiveApplications = result .schema .schemaDefinition .rootType('query') - ?.field('inputFieldWithCost') - ?.argument("input") + ?.field('argWithCost') + ?.argument("arg") ?.appliedDirectivesOf('renamedCost') .toString(); - expect(inputCostDirectiveApplications).toMatchString(`@renamedCost(weight: 10)`); + expect(argCostDirectiveApplications).toMatchString(`@renamedCost(weight: 10)`); const enumCostDirectiveApplications = result .schema @@ -223,11 +315,10 @@ describe('demand control directive composition', () => { }); describe('demand control directive extraction', () => { - it.only('extracts @cost from the supergraph', () => { - const result = composeServices([subgraphWithRenamedCost]); + it.skip('extracts @cost from the supergraph', () => { + const result = composeServices([subgraphWithCost]); assertCompositionSuccess(result); // expect(result.hints).toEqual([]); - console.log(result.supergraphSdl); const extracted = Supergraph.build(result.supergraphSdl).subgraphs().get(subgraphWithCost.name); expect(extracted?.toString()).toMatchString(` @@ -242,10 +333,15 @@ describe('demand control directive extraction', () => { B } + input InputTypeWithCost { + somethingWithCost: Int @federation__cost(weight: 20) + } + type Query { fieldWithCost: Int @federation__cost(weight: 5) - inputFieldWithCost(input: Int @federation__cost(weight: 10)): Int + argWithCost(arg: Int @federation__cost(weight: 10)): Int enumWithCost: AorB @federation__cost(weight: 15) + inputWithCost(someInput: InputTypeWithCost): Int } `); }); @@ -273,10 +369,15 @@ describe('demand control directive extraction', () => { B } + input InputTypeWithCost { + somethingWithCost: Int @federation__cost(weight: 20) + } + type Query { fieldWithCost: Int @federation__cost(weight: 5) - inputFieldWithCost(input: Int @federation__cost(weight: 10)): Int + argWithCost(arg: Int @federation__cost(weight: 10)): Int enumWithCost: AorB @federation__cost(weight: 15) + inputWithCost(someInput: InputTypeWithCost): Int } `); }); diff --git a/internals-js/src/extractSubgraphsFromSupergraph.ts b/internals-js/src/extractSubgraphsFromSupergraph.ts index 99846cccf..f8213b6d1 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, FederationDirectiveName, errorCauses, isFederationDirectiveDefinedInSchema, printErrors } from "."; +import { CONTEXT_VERSIONS, ContextSpecDefinition, DirectiveDefinition, FeatureUrl, FederationDirectiveName, costIdentity, errorCauses, isFederationDirectiveDefinedInSchema, listSizeIdentity, 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,8 +437,7 @@ function extractObjOrItfContent(args: ExtractArguments, info: TypeInfo { +function getOriginalDirectiveNames(supergraph: Schema): Record { const originalDirectiveNames: Record = {}; - for (const linkDirective of args.supergraph.schemaDefinition.appliedDirectivesOf("link")) { // TODO: Link spec? - if (linkDirective.arguments().as) { - const renamedFeature = args.supergraph.coreFeatures?.getByIdentity(linkDirective.arguments().url); - console.log(`Link ${linkDirective} has renamed ${renamedFeature}`); - } - for (const { name, as } of linkDirective.arguments().args?.import ?? []) { - if (name && as) { - originalDirectiveNames[name.replace('@', '')] = as.replace('@', ''); + for (const linkDirective of supergraph.schemaDefinition.appliedDirectivesOf("link")) { + if (linkDirective.arguments().url && linkDirective.arguments().as) { + const parsedUrl = FeatureUrl.maybeParse(linkDirective.arguments().url); + // Ideally, there's a map somewhere that can do this lookup instead of enumerating all the directives we care about, + // but it seems the original names are being stripped from the supergraph schema. + switch (parsedUrl?.identity) { + case costIdentity: + originalDirectiveNames[FederationDirectiveName.COST] = linkDirective.arguments().as; + break; + case listSizeIdentity: + originalDirectiveNames[FederationDirectiveName.LIST_SIZE] = linkDirective.arguments().as; + break; } } } @@ -497,6 +503,7 @@ function getOriginalDirectiveNames(args: ExtractArguments): Record[]) { const fieldDirective = args.joinSpec.fieldDirective(args.supergraph); + const originalDirectiveNames = args.originalDirectiveNames; for (const { type, subgraphsInfo } of info) { for (const field of type.fields()) { @@ -504,7 +511,7 @@ function extractInputObjContent(args: ExtractArguments, info: TypeInfo }): InputFieldDefinition { const copiedType = joinFieldArgs?.type ? decodeType(joinFieldArgs?.type, subgraph.schema, subgraph.name) @@ -754,6 +764,19 @@ function addSubgraphInputField({ const inputField = type.addField(field.name, copiedType); inputField.defaultValue = field.defaultValue + + const costDirectiveName = originalDirectiveNames?.[FederationDirectiveName.COST] ?? FederationDirectiveName.COST; + const costDirective = field.appliedDirectivesOf(costDirectiveName).pop(); + if (costDirective) { + inputField.applyDirective(subgraph.metadata().costDirective().name, costDirective.arguments()); + } + + const listSizeDirectiveName = originalDirectiveNames?.[FederationDirectiveName.LIST_SIZE] ?? FederationDirectiveName.LIST_SIZE; + const listSizeDirective = field.appliedDirectivesOf(listSizeDirectiveName).pop(); + if (listSizeDirective) { + inputField.applyDirective(subgraph.metadata().listSizeDirective().name, listSizeDirective.arguments()); + } + return inputField; } From 2ec9aa55399fbbe0e9db4b7c7e30a13ea10538b1 Mon Sep 17 00:00:00 2001 From: Taylor Ninesling Date: Wed, 10 Jul 2024 13:01:17 -0500 Subject: [PATCH 06/14] Cost extraction for enums --- .../__tests__/compose.demandControl.test.ts | 47 ++++++++++--------- .../src/extractSubgraphsFromSupergraph.ts | 15 ++++++ 2 files changed, 41 insertions(+), 21 deletions(-) diff --git a/composition-js/src/__tests__/compose.demandControl.test.ts b/composition-js/src/__tests__/compose.demandControl.test.ts index 94146a70f..1907fbb36 100644 --- a/composition-js/src/__tests__/compose.demandControl.test.ts +++ b/composition-js/src/__tests__/compose.demandControl.test.ts @@ -1,5 +1,6 @@ import { asFed2SubgraphDocument, + EnumType, FEDERATION2_LINK_WITH_AUTO_EXPANDED_IMPORTS, InputObjectType, ServiceDefinition, @@ -14,7 +15,7 @@ const subgraphWithCost = { typeDefs: asFed2SubgraphDocument(gql` extend schema @link(url: "https://specs.apollo.dev/cost/v0.1", import: ["@cost"]) - enum AorB { + enum AorB @cost(weight: 15) { A B } @@ -26,7 +27,7 @@ const subgraphWithCost = { type Query { fieldWithCost: Int @cost(weight: 5) argWithCost(arg: Int @cost(weight: 10)): Int - enumWithCost: AorB @cost(weight: 15) + enumWithCost: AorB inputWithCost(someInput: InputTypeWithCost): Int } `), @@ -48,7 +49,7 @@ const subgraphWithRenamedCost = { typeDefs: asFed2SubgraphDocument(gql` extend schema @link(url: "https://specs.apollo.dev/cost/v0.1", import: [{ name: "@cost", as: "@renamedCost" }]) - enum AorB { + enum AorB @renamedCost(weight: 15) { A B } @@ -60,7 +61,7 @@ const subgraphWithRenamedCost = { type Query { fieldWithCost: Int @renamedCost(weight: 5) argWithCost(arg: Int @renamedCost(weight: 10)): Int - enumWithCost: AorB @renamedCost(weight: 15) + enumWithCost: AorB inputWithCost(someInput: InputTypeWithCost): Int } `), @@ -81,7 +82,7 @@ const subgraphWithCostFromFederationSpec = { name: 'subgraphWithCost', typeDefs: asFed2SubgraphDocument( gql` - enum AorB { + enum AorB @cost(weight: 15) { A B } @@ -93,7 +94,7 @@ const subgraphWithCostFromFederationSpec = { type Query { fieldWithCost: Int @cost(weight: 5) argWithCost(arg: Int @cost(weight: 10)): Int - enumWithCost: AorB @cost(weight: 15) + enumWithCost: AorB inputWithCost(someInput: InputTypeWithCost): Int } `, @@ -119,7 +120,7 @@ const subgraphWithRenamedCostFromFederationSpec = { gql` extend schema @link(url: "https://specs.apollo.dev/federation/v2.9", import: [{ name: "@cost", as: "@renamedCost" }]) - enum AorB { + enum AorB @renamedCost(weight: 15) { A B } @@ -131,7 +132,7 @@ const subgraphWithRenamedCostFromFederationSpec = { type Query { fieldWithCost: Int @renamedCost(weight: 5) argWithCost(arg: Int @renamedCost(weight: 10)): Int - enumWithCost: AorB @renamedCost(weight: 15) + enumWithCost: AorB inputWithCost(someInput: InputTypeWithCost): Int } `, @@ -173,13 +174,13 @@ describe('demand control directive composition', () => { .toString(); expect(argCostDirectiveApplications).toMatchString(`@cost(weight: 10)`); - const enumCostDirectiveApplications = result + const enumWithCost = result .schema .schemaDefinition .rootType('query') ?.field('enumWithCost') - ?.appliedDirectivesOf('cost') - .toString(); + ?.type as EnumType; + const enumCostDirectiveApplications = enumWithCost.appliedDirectivesOf('cost').toString(); expect(enumCostDirectiveApplications).toMatchString(`@cost(weight: 15)`); const inputWithCost = result @@ -228,13 +229,13 @@ describe('demand control directive composition', () => { .toString(); expect(argCostDirectiveApplications).toMatchString(`@cost(weight: 10)`); - const enumCostDirectiveApplications = result + const enumWithCost = result .schema .schemaDefinition .rootType('query') ?.field('enumWithCost') - ?.appliedDirectivesOf('cost') - .toString(); + ?.type as EnumType; + const enumCostDirectiveApplications = enumWithCost.appliedDirectivesOf('cost').toString(); expect(enumCostDirectiveApplications).toMatchString(`@cost(weight: 15)`); const inputWithCost = result @@ -293,13 +294,13 @@ describe('demand control directive composition', () => { .toString(); expect(argCostDirectiveApplications).toMatchString(`@renamedCost(weight: 10)`); - const enumCostDirectiveApplications = result + const enumWithCost = result .schema .schemaDefinition .rootType('query') ?.field('enumWithCost') - ?.appliedDirectivesOf('renamedCost') - .toString(); + ?.type as EnumType; + const enumCostDirectiveApplications = enumWithCost.appliedDirectivesOf('renamedCost').toString(); expect(enumCostDirectiveApplications).toMatchString(`@renamedCost(weight: 15)`); const listSizeDirectiveApplications = result @@ -328,7 +329,9 @@ describe('demand control directive extraction', () => { query: Query } - enum AorB { + enum AorB + @federation__cost(weight: 15) + { A B } @@ -340,7 +343,7 @@ describe('demand control directive extraction', () => { type Query { fieldWithCost: Int @federation__cost(weight: 5) argWithCost(arg: Int @federation__cost(weight: 10)): Int - enumWithCost: AorB @federation__cost(weight: 15) + enumWithCost: AorB inputWithCost(someInput: InputTypeWithCost): Int } `); @@ -364,7 +367,9 @@ describe('demand control directive extraction', () => { query: Query } - enum AorB { + enum AorB + @federation__cost(weight: 15) + { A B } @@ -376,7 +381,7 @@ describe('demand control directive extraction', () => { type Query { fieldWithCost: Int @federation__cost(weight: 5) argWithCost(arg: Int @federation__cost(weight: 10)): Int - enumWithCost: AorB @federation__cost(weight: 15) + enumWithCost: AorB inputWithCost(someInput: InputTypeWithCost): Int } `); diff --git a/internals-js/src/extractSubgraphsFromSupergraph.ts b/internals-js/src/extractSubgraphsFromSupergraph.ts index f8213b6d1..8c242a230 100644 --- a/internals-js/src/extractSubgraphsFromSupergraph.ts +++ b/internals-js/src/extractSubgraphsFromSupergraph.ts @@ -533,8 +533,23 @@ 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()) { + const costDirectiveName = originalDirectiveNames?.[FederationDirectiveName.COST] ?? FederationDirectiveName.COST; + const costDirective = type.appliedDirectivesOf(costDirectiveName).pop(); + if (costDirective) { + subgraphType.applyDirective(subgraph.metadata().costDirective().name, costDirective.arguments()); + } + + const listSizeDirectiveName = originalDirectiveNames?.[FederationDirectiveName.LIST_SIZE] ?? FederationDirectiveName.LIST_SIZE; + const listSizeDirective = type.appliedDirectivesOf(listSizeDirectiveName).pop(); + if (listSizeDirective) { + subgraphType.applyDirective(subgraph.metadata().listSizeDirective().name, listSizeDirective.arguments()); + } + } + for (const value of type.values) { const enumValueApplications = enumValueDirective ? value.appliedDirectivesOf(enumValueDirective) : []; if (enumValueApplications.length === 0) { From 5f91e011691883376b9d68f7c520de50282b29c3 Mon Sep 17 00:00:00 2001 From: Taylor Ninesling Date: Wed, 10 Jul 2024 14:12:49 -0500 Subject: [PATCH 07/14] DRY things up a bit --- .../__tests__/compose.demandControl.test.ts | 305 ++++++------------ .../src/extractSubgraphsFromSupergraph.ts | 66 ++-- 2 files changed, 113 insertions(+), 258 deletions(-) diff --git a/composition-js/src/__tests__/compose.demandControl.test.ts b/composition-js/src/__tests__/compose.demandControl.test.ts index 1907fbb36..97ba4d354 100644 --- a/composition-js/src/__tests__/compose.demandControl.test.ts +++ b/composition-js/src/__tests__/compose.demandControl.test.ts @@ -1,12 +1,15 @@ import { + ArgumentDefinition, asFed2SubgraphDocument, EnumType, FEDERATION2_LINK_WITH_AUTO_EXPANDED_IMPORTS, + FieldDefinition, InputObjectType, + ObjectType, ServiceDefinition, Supergraph } from '@apollo/federation-internals'; -import { composeServices } from '../compose'; +import { composeServices, CompositionResult } from '../compose'; import gql from 'graphql-tag'; import { assertCompositionSuccess } from "./testHelper"; @@ -150,59 +153,56 @@ const subgraphWithRenamedListSizeFromFederationSpec = { `, }; -describe('demand control directive composition', () => { - it.skip('propagates @cost and @listSize to the supergraph', () => { - const result = composeServices([subgraphWithCost, subgraphWithListSize]); - assertCompositionSuccess(result); - - const costDirectiveApplications = result - .schema - .schemaDefinition - .rootType('query') - ?.field('fieldWithCost') - ?.appliedDirectivesOf('cost') - .toString(); - expect(costDirectiveApplications).toMatchString(`@cost(weight: 5)`); - - const argCostDirectiveApplications = result - .schema - .schemaDefinition - .rootType('query') - ?.field('argWithCost') - ?.argument('arg') - ?.appliedDirectivesOf('cost') - .toString(); - expect(argCostDirectiveApplications).toMatchString(`@cost(weight: 10)`); - - const enumWithCost = result - .schema - .schemaDefinition - .rootType('query') - ?.field('enumWithCost') - ?.type as EnumType; - const enumCostDirectiveApplications = enumWithCost.appliedDirectivesOf('cost').toString(); - expect(enumCostDirectiveApplications).toMatchString(`@cost(weight: 15)`); - - const inputWithCost = result - .schema - .schemaDefinition - .rootType('query') - ?.field('inputWithCost') - ?.argument('someInput') - ?.type as InputObjectType; - const inputCostDirectiveApplications = inputWithCost.field('somethingWithCost')?.appliedDirectivesOf('cost').toString(); - expect(inputCostDirectiveApplications).toMatchString(`@cost(weight: 20)`); - - const listSizeDirectiveApplications = result - .schema - .schemaDefinition - .rootType('query') - ?.field('fieldWithListSize') - ?.appliedDirectivesOf('listSize') - .toString(); - expect(listSizeDirectiveApplications).toMatchString(`@listSize(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], @@ -210,52 +210,20 @@ describe('demand control directive composition', () => { const result = composeServices([costSubgraph, listSizeSubgraph]); assertCompositionSuccess(result); - const costDirectiveApplications = result - .schema - .schemaDefinition - .rootType('query') - ?.field('fieldWithCost') - ?.appliedDirectivesOf('cost') - .toString(); - expect(costDirectiveApplications).toMatchString(`@cost(weight: 5)`); - - const argCostDirectiveApplications = result - .schema - .schemaDefinition - .rootType('query') - ?.field('argWithCost') - ?.argument('arg') - ?.appliedDirectivesOf('cost') - .toString(); - expect(argCostDirectiveApplications).toMatchString(`@cost(weight: 10)`); - - const enumWithCost = result - .schema - .schemaDefinition - .rootType('query') - ?.field('enumWithCost') - ?.type as EnumType; - const enumCostDirectiveApplications = enumWithCost.appliedDirectivesOf('cost').toString(); - expect(enumCostDirectiveApplications).toMatchString(`@cost(weight: 15)`); - - const inputWithCost = result - .schema - .schemaDefinition - .rootType('query') - ?.field('inputWithCost') - ?.argument('someInput') - ?.type as InputObjectType; - const inputCostDirectiveApplications = inputWithCost.field('somethingWithCost')?.appliedDirectivesOf('cost').toString(); - expect(inputCostDirectiveApplications).toMatchString(`@cost(weight: 20)`); - - const listSizeDirectiveApplications = result - .schema - .schemaDefinition - .rootType('query') - ?.field('fieldWithListSize') - ?.appliedDirectivesOf('listSize') - .toString(); - expect(listSizeDirectiveApplications).toMatchString(`@listSize(assumedSize: 2000, requireOneSlicingArgument: false)`); + 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', () => { @@ -275,80 +243,22 @@ describe('demand control directive composition', () => { expect(listSizeLink?.arguments().as).toBe("renamedListSize"); // Ensure the directives are applied to the expected fields with the new names - const costDirectiveApplications = result - .schema - .schemaDefinition - .rootType('query') - ?.field('fieldWithCost') - ?.appliedDirectivesOf('renamedCost') - .toString(); - expect(costDirectiveApplications).toMatchString(`@renamedCost(weight: 5)`); - - const argCostDirectiveApplications = result - .schema - .schemaDefinition - .rootType('query') - ?.field('argWithCost') - ?.argument("arg") - ?.appliedDirectivesOf('renamedCost') - .toString(); - expect(argCostDirectiveApplications).toMatchString(`@renamedCost(weight: 10)`); - - const enumWithCost = result - .schema - .schemaDefinition - .rootType('query') - ?.field('enumWithCost') - ?.type as EnumType; - const enumCostDirectiveApplications = enumWithCost.appliedDirectivesOf('renamedCost').toString(); - expect(enumCostDirectiveApplications).toMatchString(`@renamedCost(weight: 15)`); - - const listSizeDirectiveApplications = result - .schema - .schemaDefinition - .rootType('query') - ?.field('fieldWithListSize') - ?.appliedDirectivesOf('renamedListSize') - .toString(); - expect(listSizeDirectiveApplications).toMatchString(`@renamedListSize(assumedSize: 2000, requireOneSlicingArgument: false)`); - }); - }); -}); + const costDirectiveApplications = fieldWithCost(result)?.appliedDirectivesOf('renamedCost'); + expect(costDirectiveApplications?.toString()).toMatchString(`@renamedCost(weight: 5)`); -describe('demand control directive extraction', () => { - it.skip('extracts @cost from the supergraph', () => { - const result = composeServices([subgraphWithCost]); - assertCompositionSuccess(result); - // expect(result.hints).toEqual([]); - const extracted = Supergraph.build(result.supergraphSdl).subgraphs().get(subgraphWithCost.name); + const argCostDirectiveApplications = argumentWithCost(result)?.appliedDirectivesOf('renamedCost'); + expect(argCostDirectiveApplications?.toString()).toMatchString(`@renamedCost(weight: 10)`); - 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) - } + const enumCostDirectiveApplications = enumWithCost(result)?.appliedDirectivesOf('renamedCost'); + expect(enumCostDirectiveApplications?.toString()).toMatchString(`@renamedCost(weight: 15)`); - type Query { - fieldWithCost: Int @federation__cost(weight: 5) - argWithCost(arg: Int @federation__cost(weight: 10)): Int - enumWithCost: AorB - inputWithCost(someInput: InputTypeWithCost): Int - } - `); + const listSizeDirectiveApplications = fieldWithListSize(result)?.appliedDirectivesOf('renamedListSize'); + expect(listSizeDirectiveApplications?.toString()).toMatchString(`@renamedListSize(assumedSize: 2000, requireOneSlicingArgument: false)`); + }); }); +}); +describe('demand control directive extraction', () => { it.each([ subgraphWithCost, subgraphWithRenamedCost, @@ -438,18 +348,7 @@ describe('demand control directive extraction', () => { // expect(result.hints).toEqual([]); const supergraph = Supergraph.build(result.supergraphSdl); - expect(supergraph.subgraphs().get(subgraphA.name)?.toString()).toMatchString(` - schema - ${FEDERATION2_LINK_WITH_AUTO_EXPANDED_IMPORTS} - { - query: Query - } - - type Query { - sharedWithCost: Int @shareable @federation__cost(weight: 10) - } - `); - expect(supergraph.subgraphs().get(subgraphB.name)?.toString()).toMatchString(` + const expectedSchema = ` schema ${FEDERATION2_LINK_WITH_AUTO_EXPANDED_IMPORTS} { @@ -459,7 +358,11 @@ describe('demand control directive extraction', () => { 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 the merged @listSize for different subgraphs with @shareable fields', () => { @@ -489,18 +392,7 @@ describe('demand control directive extraction', () => { // expect(result.hints).toEqual([]); const supergraph = Supergraph.build(result.supergraphSdl); - expect(supergraph.subgraphs().get(subgraphA.name)?.toString()).toMatchString(` - schema - ${FEDERATION2_LINK_WITH_AUTO_EXPANDED_IMPORTS} - { - query: Query - } - - type Query { - sharedWithListSize: [Int] @shareable @federation__listSize(assumedSize: 20, requireOneSlicingArgument: true) - } - `); - expect(supergraph.subgraphs().get(subgraphB.name)?.toString()).toMatchString(` + const expectedSubgraph = ` schema ${FEDERATION2_LINK_WITH_AUTO_EXPANDED_IMPORTS} { @@ -510,7 +402,9 @@ describe('demand control directive extraction', () => { 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); }); it('extracts @listSize with dynamic cost arguments', () => { @@ -548,22 +442,7 @@ describe('demand control directive extraction', () => { // expect(result.hints).toEqual([]); const supergraph = Supergraph.build(result.supergraphSdl); - expect(supergraph.subgraphs().get(subgraphA.name)?.toString()).toMatchString(` - 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(subgraphB.name)?.toString()).toMatchString(` + const expectedSubgraph = ` schema ${FEDERATION2_LINK_WITH_AUTO_EXPANDED_IMPORTS} { @@ -577,6 +456,8 @@ describe('demand control directive extraction', () => { 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); + }); }); diff --git a/internals-js/src/extractSubgraphsFromSupergraph.ts b/internals-js/src/extractSubgraphsFromSupergraph.ts index 8c242a230..4edccf206 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, FeatureUrl, FederationDirectiveName, costIdentity, errorCauses, isFederationDirectiveDefinedInSchema, listSizeIdentity, printErrors } from "."; +import { CONTEXT_VERSIONS, ContextSpecDefinition, DirectiveDefinition, FeatureUrl, FederationDirectiveName, SchemaElement, costIdentity, errorCauses, isFederationDirectiveDefinedInSchema, listSizeIdentity, printErrors } from "."; function filteredTypes( supergraph: Schema, @@ -537,19 +537,9 @@ function extractEnumTypeContent(args: ExtractArguments, info: TypeInfo for (const { type, subgraphsInfo } of info) { for (const { type: subgraphType, subgraph } of subgraphsInfo.values()) { - const costDirectiveName = originalDirectiveNames?.[FederationDirectiveName.COST] ?? FederationDirectiveName.COST; - const costDirective = type.appliedDirectivesOf(costDirectiveName).pop(); - if (costDirective) { - subgraphType.applyDirective(subgraph.metadata().costDirective().name, costDirective.arguments()); - } - - const listSizeDirectiveName = originalDirectiveNames?.[FederationDirectiveName.LIST_SIZE] ?? FederationDirectiveName.LIST_SIZE; - const listSizeDirective = type.appliedDirectivesOf(listSizeDirectiveName).pop(); - if (listSizeDirective) { - subgraphType.applyDirective(subgraph.metadata().listSizeDirective().name, listSizeDirective.arguments()); - } + propagateDemandControlDirectives(type, subgraphType, subgraph, originalDirectiveNames); } - + for (const value of type.values) { const enumValueApplications = enumValueDirective ? value.appliedDirectivesOf(enumValueDirective) : []; if (enumValueApplications.length === 0) { @@ -662,6 +652,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); @@ -685,22 +689,11 @@ function addSubgraphField({ const copiedFieldType = joinFieldArgs?.type ? decodeType(joinFieldArgs.type, subgraph.schema, subgraph.name) : copyType(field.type!, subgraph.schema, subgraph.name); - const costDirectiveName = originalDirectiveNames?.[FederationDirectiveName.COST] ?? FederationDirectiveName.COST; - const listSizeDirectiveName = originalDirectiveNames?.[FederationDirectiveName.LIST_SIZE] ?? FederationDirectiveName.LIST_SIZE; const subgraphField = type.addField(field.name, copiedFieldType); for (const arg of field.arguments()) { const argDef = subgraphField.addArgument(arg.name, copyType(arg.type!, subgraph.schema, subgraph.name), arg.defaultValue); - - const costDirective = arg.appliedDirectivesOf(costDirectiveName).pop(); - if (costDirective) { - argDef.applyDirective(subgraph.metadata().costDirective().name, costDirective.arguments()); - } - - const listSizeDirective = arg.appliedDirectivesOf(listSizeDirectiveName).pop(); - if (listSizeDirective) { - argDef.applyDirective(subgraph.metadata().listSizeDirective().name, listSizeDirective.arguments()); - } + propagateDemandControlDirectives(arg, argDef, subgraph, originalDirectiveNames) } if (joinFieldArgs?.requires) { subgraphField.applyDirective(subgraph.metadata().requiresDirective(), {'fields': joinFieldArgs.requires}); @@ -746,16 +739,7 @@ function addSubgraphField({ subgraphField.applyDirective(subgraph.metadata().shareableDirective()); } - - const costDirective = field.appliedDirectivesOf(costDirectiveName).pop(); - if (costDirective) { - subgraphField.applyDirective(subgraph.metadata().costDirective().name, costDirective.arguments()); - } - - const listSizeDirective = field.appliedDirectivesOf(listSizeDirectiveName).pop(); - if (listSizeDirective) { - subgraphField.applyDirective(subgraph.metadata().listSizeDirective().name, listSizeDirective.arguments()); - } + propagateDemandControlDirectives(field, subgraphField, subgraph, originalDirectiveNames); return subgraphField; } @@ -780,17 +764,7 @@ function addSubgraphInputField({ const inputField = type.addField(field.name, copiedType); inputField.defaultValue = field.defaultValue - const costDirectiveName = originalDirectiveNames?.[FederationDirectiveName.COST] ?? FederationDirectiveName.COST; - const costDirective = field.appliedDirectivesOf(costDirectiveName).pop(); - if (costDirective) { - inputField.applyDirective(subgraph.metadata().costDirective().name, costDirective.arguments()); - } - - const listSizeDirectiveName = originalDirectiveNames?.[FederationDirectiveName.LIST_SIZE] ?? FederationDirectiveName.LIST_SIZE; - const listSizeDirective = field.appliedDirectivesOf(listSizeDirectiveName).pop(); - if (listSizeDirective) { - inputField.applyDirective(subgraph.metadata().listSizeDirective().name, listSizeDirective.arguments()); - } + propagateDemandControlDirectives(field, inputField, subgraph, originalDirectiveNames); return inputField; } From 2fb41eb11667d9cd2b3daf4688a09ed4bf11d45d Mon Sep 17 00:00:00 2001 From: Taylor Ninesling Date: Wed, 10 Jul 2024 14:27:27 -0500 Subject: [PATCH 08/14] Create changeset --- .changeset/happy-bats-exist.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/happy-bats-exist.md 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. From b7b63b2fffdb0bda3a7962e3591f468c6d909b9a Mon Sep 17 00:00:00 2001 From: Taylor Ninesling Date: Wed, 10 Jul 2024 14:48:58 -0500 Subject: [PATCH 09/14] Test hints --- .../__tests__/compose.demandControl.test.ts | 267 ++++++++++++------ 1 file changed, 178 insertions(+), 89 deletions(-) diff --git a/composition-js/src/__tests__/compose.demandControl.test.ts b/composition-js/src/__tests__/compose.demandControl.test.ts index 97ba4d354..2ac5ffe42 100644 --- a/composition-js/src/__tests__/compose.demandControl.test.ts +++ b/composition-js/src/__tests__/compose.demandControl.test.ts @@ -209,6 +209,7 @@ describe('demand control directive composition', () => { ])('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)`); @@ -233,6 +234,7 @@ describe('demand control directive composition', () => { ])('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"); @@ -256,6 +258,96 @@ describe('demand control directive composition', () => { expect(listSizeDirectiveApplications?.toString()).toMatchString(`@renamedListSize(assumedSize: 2000, requireOneSlicingArgument: false)`); }); }); + + 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/listSize/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/listSize/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', () => { @@ -267,7 +359,6 @@ describe('demand control directive extraction', () => { ])('extracts @cost from the supergraph', (subgraph: ServiceDefinition) => { const result = composeServices([subgraph]); assertCompositionSuccess(result); - // expect(result.hints).toEqual([]); const extracted = Supergraph.build(result.supergraphSdl).subgraphs().get(subgraphWithCost.name); expect(extracted?.toString()).toMatchString(` @@ -305,7 +396,6 @@ describe('demand control directive extraction', () => { ])('extracts @listSize from the supergraph', (subgraph: ServiceDefinition) => { const result = composeServices([subgraph]); assertCompositionSuccess(result); - // expect(result.hints).toEqual([]); const extracted = Supergraph.build(result.supergraphSdl).subgraphs().get(subgraphWithListSize.name); expect(extracted?.toString()).toMatchString(` @@ -321,92 +411,6 @@ describe('demand control directive extraction', () => { `); }); - it('extracts the merged (max) @cost for different subgraphs with @shareable fields', () => { - 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).toEqual([]); - 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 the merged @listSize for different subgraphs with @shareable fields', () => { - const subgraphA = { - name: 'subgraph-a', - typeDefs: asFed2SubgraphDocument(gql` - extend schema @link(url: "https://specs.apollo.dev/listSize/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/listSize/v0.1", import: ["@listSize"]) - - type Query { - sharedWithListSize: [Int] @shareable @listSize(assumedSize: 20) - } - `) - }; - - const result = composeServices([subgraphA, subgraphB]); - assertCompositionSuccess(result); - // expect(result.hints).toEqual([]); - 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); - }); - it('extracts @listSize with dynamic cost arguments', () => { const subgraphA = { name: 'subgraph-a', @@ -439,7 +443,6 @@ describe('demand control directive extraction', () => { const result = composeServices([subgraphA, subgraphB]); assertCompositionSuccess(result); - // expect(result.hints).toEqual([]); const supergraph = Supergraph.build(result.supergraphSdl); const expectedSubgraph = ` @@ -460,4 +463,90 @@ describe('demand control directive extraction', () => { 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/listSize/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/listSize/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); + }); + }); }); From 670bfc2e6644d0a1126907aee9b799c7445f6342 Mon Sep 17 00:00:00 2001 From: Taylor Ninesling Date: Wed, 10 Jul 2024 14:50:29 -0500 Subject: [PATCH 10/14] Disallow composing cost and listSize --- composition-js/src/composeDirectiveManager.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/composition-js/src/composeDirectiveManager.ts b/composition-js/src/composeDirectiveManager.ts index e2fe7b170..351987a50 100644 --- a/composition-js/src/composeDirectiveManager.ts +++ b/composition-js/src/composeDirectiveManager.ts @@ -66,6 +66,8 @@ const DISALLOWED_IDENTITIES = [ 'https://specs.apollo.dev/requiresScopes', 'https://specs.apollo.dev/source', 'https://specs.apollo.dev/context', + 'https://specs.apollo.dev/cost', + 'https://specs.apollo.dev/listSize', ]; export class ComposeDirectiveManager { From e26a614bb13a4b6a2a0d18860c583100ec2e1f49 Mon Sep 17 00:00:00 2001 From: Taylor Ninesling Date: Thu, 11 Jul 2024 10:49:34 -0500 Subject: [PATCH 11/14] Add test for link import mismatch --- .../__tests__/compose.demandControl.test.ts | 35 ++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/composition-js/src/__tests__/compose.demandControl.test.ts b/composition-js/src/__tests__/compose.demandControl.test.ts index 2ac5ffe42..02aedb797 100644 --- a/composition-js/src/__tests__/compose.demandControl.test.ts +++ b/composition-js/src/__tests__/compose.demandControl.test.ts @@ -11,7 +11,7 @@ import { } from '@apollo/federation-internals'; import { composeServices, CompositionResult } from '../compose'; import gql from 'graphql-tag'; -import { assertCompositionSuccess } from "./testHelper"; +import { assertCompositionSuccess, errors } from "./testHelper"; const subgraphWithCost = { name: 'subgraphWithCost', @@ -259,6 +259,39 @@ describe('demand control directive composition', () => { }); }); + 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 = { From 536547839a588cd6c156aa9e1eab7b64aa578afc Mon Sep 17 00:00:00 2001 From: Taylor Ninesling Date: Thu, 11 Jul 2024 11:43:58 -0500 Subject: [PATCH 12/14] Add cost and listSize to full import link statement --- composition-js/src/__tests__/compose.composeDirective.test.ts | 2 +- internals-js/src/federation.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/composition-js/src/__tests__/compose.composeDirective.test.ts b/composition-js/src/__tests__/compose.composeDirective.test.ts index e263b57d4..2b8667204 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"?', ] ]); }); diff --git a/internals-js/src/federation.ts b/internals-js/src/federation.ts index 197683d4e..c8d85bb99 100644 --- a/internals-js/src/federation.ts +++ b/internals-js/src/federation.ts @@ -1851,7 +1851,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.9", 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.9", import: ["@key", "@requires", "@provides", "@external", "@tag", "@extends", "@shareable", "@inaccessible", "@override", "@composeDirective", "@interfaceObject"])'; From bb512c6b2e4321c350019bc3e8d20d0629f9dcf8 Mon Sep 17 00:00:00 2001 From: Taylor Ninesling Date: Thu, 11 Jul 2024 12:20:32 -0500 Subject: [PATCH 13/14] Add argument merger tests for new nullable strategies --- ...e.directiveArgumentMergeStrategies.test.ts | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/composition-js/src/__tests__/compose.directiveArgumentMergeStrategies.test.ts b/composition-js/src/__tests__/compose.directiveArgumentMergeStrategies.test.ts index 7307a96ba..c7eae94b2 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', From 36d6c71e2c150b6aed0d0bc18362cf31609e7343 Mon Sep 17 00:00:00 2001 From: Taylor Ninesling Date: Wed, 17 Jul 2024 12:45:55 -0500 Subject: [PATCH 14/14] Handle multiple composable directives from the same spec (#3086) # Overview This PR targets the branch of an existing open PR. The main difference is that this one combines the new `@cost` and `@listSize` directives are combined into the same spec. This required a small update to how the supergraph schema is prepared. When a directive spec definition is imported by a subgraph, it was previously included in the supergraph without an import argument. So a subgraph with `@link(url: "https://specs.apollo.dev/cost/v0.1", import: ["@cost"])`, is translated to the supergraph as `@link(url: "https://specs.apollo.dev/cost/v0.1")`. If multiple imports are included, the supergraph wouldn't get any of them. With this change, the supergraph now gets an equivalent `@link(url: "https://specs.apollo.dev/cost/v0.1", import: ["@cost"])`. ## Renaming The core/link directive can have an argument called "as" to rename the directive from a spec. This would take a subgraph import `@link(url: "https://specs.apollo.dev/cost/v0.1", import: [{ name: "@cost", as: "@renamedCost" }])`, and translate it to `@link(url: "https://specs.apollo.dev/cost/v0.1", as: "renamedCost")`. With this change, the supergraph now gets an equivalent `@link(url: "https://specs.apollo.dev/cost/v0.1", import: [{ name: "@cost", as: "@renamedCost" }])`. ## Import aggregation As we add directive spec features to the supergraph, we check for existing links with the same feature version (both the link identity and the version must match). If one is found, we append the new directive to the existing list of imports. --- .../compose.composeDirective.test.ts.snap | 2 +- .../compose.composeDirective.test.ts | 4 +- .../__tests__/compose.demandControl.test.ts | 24 +++++------ ...e.directiveArgumentMergeStrategies.test.ts | 2 +- composition-js/src/composeDirectiveManager.ts | 1 - composition-js/src/merging/merge.ts | 4 +- .../__tests__/gateway/lifecycle-hooks.test.ts | 2 +- .../src/extractSubgraphsFromSupergraph.ts | 18 +++------ internals-js/src/federation.ts | 3 +- internals-js/src/index.ts | 1 - internals-js/src/specs/coreSpec.ts | 21 ++++++++++ internals-js/src/specs/costSpec.ts | 23 ++++++++++- internals-js/src/specs/federationSpec.ts | 2 - internals-js/src/specs/listSizeSpec.ts | 40 ------------------- internals-js/src/supergraphs.ts | 1 - 15 files changed, 68 insertions(+), 80 deletions(-) delete mode 100644 internals-js/src/specs/listSizeSpec.ts 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 2b8667204..fe1d845d4 100644 --- a/composition-js/src/__tests__/compose.composeDirective.test.ts +++ b/composition-js/src/__tests__/compose.composeDirective.test.ts @@ -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 index 02aedb797..31a11fc26 100644 --- a/composition-js/src/__tests__/compose.demandControl.test.ts +++ b/composition-js/src/__tests__/compose.demandControl.test.ts @@ -39,7 +39,7 @@ const subgraphWithCost = { const subgraphWithListSize = { name: 'subgraphWithListSize', typeDefs: asFed2SubgraphDocument(gql` - extend schema @link(url: "https://specs.apollo.dev/listSize/v0.1", import: ["@listSize"]) + extend schema @link(url: "https://specs.apollo.dev/cost/v0.1", import: ["@listSize"]) type Query { fieldWithListSize: [String!] @listSize(assumedSize: 2000, requireOneSlicingArgument: false) @@ -73,7 +73,7 @@ const subgraphWithRenamedCost = { const subgraphWithRenamedListSize = { name: 'subgraphWithListSize', typeDefs: asFed2SubgraphDocument(gql` - extend schema @link(url: "https://specs.apollo.dev/listSize/v0.1", import: [{ name: "@listSize", as: "@renamedListSize" }]) + 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) @@ -238,11 +238,9 @@ describe('demand control directive composition', () => { // 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 costLink = links.find((link) => link.arguments().url === "https://specs.apollo.dev/cost/v0.1"); - expect(costLink?.arguments().as).toBe("renamedCost"); - - const listSizeLink = links.find((link) => link.arguments().url === "https://specs.apollo.dev/listSize/v0.1"); - expect(listSizeLink?.arguments().as).toBe("renamedListSize"); + 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'); @@ -341,7 +339,7 @@ describe('demand control directive composition', () => { const subgraphA = { name: 'subgraph-a', typeDefs: asFed2SubgraphDocument(gql` - extend schema @link(url: "https://specs.apollo.dev/listSize/v0.1", import: ["@listSize"]) + extend schema @link(url: "https://specs.apollo.dev/cost/v0.1", import: ["@listSize"]) type Query { sharedWithListSize: [Int] @shareable @listSize(assumedSize: 10) @@ -351,7 +349,7 @@ describe('demand control directive composition', () => { const subgraphB = { name: 'subgraph-b', typeDefs: asFed2SubgraphDocument(gql` - extend schema @link(url: "https://specs.apollo.dev/listSize/v0.1", import: ["@listSize"]) + extend schema @link(url: "https://specs.apollo.dev/cost/v0.1", import: ["@listSize"]) type Query { sharedWithListSize: [Int] @shareable @listSize(assumedSize: 20) @@ -448,7 +446,7 @@ describe('demand control directive extraction', () => { const subgraphA = { name: 'subgraph-a', typeDefs: asFed2SubgraphDocument(gql` - extend schema @link(url: "https://specs.apollo.dev/listSize/v0.1", import: ["@listSize"]) + 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) @@ -462,7 +460,7 @@ describe('demand control directive extraction', () => { const subgraphB = { name: 'subgraph-b', typeDefs: asFed2SubgraphDocument(gql` - extend schema @link(url: "https://specs.apollo.dev/listSize/v0.1", import: ["@listSize"]) + 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) @@ -545,7 +543,7 @@ describe('demand control directive extraction', () => { const subgraphA = { name: 'subgraph-a', typeDefs: asFed2SubgraphDocument(gql` - extend schema @link(url: "https://specs.apollo.dev/listSize/v0.1", import: ["@listSize"]) + extend schema @link(url: "https://specs.apollo.dev/cost/v0.1", import: ["@listSize"]) type Query { sharedWithListSize: [Int] @shareable @listSize(assumedSize: 10) @@ -555,7 +553,7 @@ describe('demand control directive extraction', () => { const subgraphB = { name: 'subgraph-b', typeDefs: asFed2SubgraphDocument(gql` - extend schema @link(url: "https://specs.apollo.dev/listSize/v0.1", import: ["@listSize"]) + extend schema @link(url: "https://specs.apollo.dev/cost/v0.1", import: ["@listSize"]) type Query { sharedWithListSize: [Int] @shareable @listSize(assumedSize: 20) diff --git a/composition-js/src/__tests__/compose.directiveArgumentMergeStrategies.test.ts b/composition-js/src/__tests__/compose.directiveArgumentMergeStrategies.test.ts index c7eae94b2..b1be5c54c 100644 --- a/composition-js/src/__tests__/compose.directiveArgumentMergeStrategies.test.ts +++ b/composition-js/src/__tests__/compose.directiveArgumentMergeStrategies.test.ts @@ -219,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 351987a50..b4cfd706b 100644 --- a/composition-js/src/composeDirectiveManager.ts +++ b/composition-js/src/composeDirectiveManager.ts @@ -67,7 +67,6 @@ const DISALLOWED_IDENTITIES = [ 'https://specs.apollo.dev/source', 'https://specs.apollo.dev/context', 'https://specs.apollo.dev/cost', - 'https://specs.apollo.dev/listSize', ]; 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/extractSubgraphsFromSupergraph.ts b/internals-js/src/extractSubgraphsFromSupergraph.ts index 4edccf206..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, FeatureUrl, FederationDirectiveName, SchemaElement, costIdentity, errorCauses, isFederationDirectiveDefinedInSchema, listSizeIdentity, printErrors } from "."; +import { CONTEXT_VERSIONS, ContextSpecDefinition, DirectiveDefinition, FederationDirectiveName, SchemaElement, errorCauses, isFederationDirectiveDefinedInSchema, printErrors } from "."; function filteredTypes( supergraph: Schema, @@ -483,17 +483,11 @@ function extractObjOrItfContent(args: ExtractArguments, info: TypeInfo { const originalDirectiveNames: Record = {}; for (const linkDirective of supergraph.schemaDefinition.appliedDirectivesOf("link")) { - if (linkDirective.arguments().url && linkDirective.arguments().as) { - const parsedUrl = FeatureUrl.maybeParse(linkDirective.arguments().url); - // Ideally, there's a map somewhere that can do this lookup instead of enumerating all the directives we care about, - // but it seems the original names are being stripped from the supergraph schema. - switch (parsedUrl?.identity) { - case costIdentity: - originalDirectiveNames[FederationDirectiveName.COST] = linkDirective.arguments().as; - break; - case listSizeIdentity: - originalDirectiveNames[FederationDirectiveName.LIST_SIZE] = linkDirective.arguments().as; - break; + 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('@', ''); + } } } } diff --git a/internals-js/src/federation.ts b/internals-js/src/federation.ts index c8d85bb99..6980ba593 100644 --- a/internals-js/src/federation.ts +++ b/internals-js/src/federation.ts @@ -100,8 +100,7 @@ import { SourceFieldDirectiveArgs, SourceTypeDirectiveArgs, } from "./specs/sourceSpec"; -import { CostDirectiveArguments } from "./specs/costSpec"; -import { ListSizeDirectiveArguments } from "./specs/listSizeSpec"; +import { CostDirectiveArguments, ListSizeDirectiveArguments } from "./specs/costSpec"; const linkSpec = LINK_VERSIONS.latest(); const tagSpec = TAG_VERSIONS.latest(); diff --git a/internals-js/src/index.ts b/internals-js/src/index.ts index f072902f4..9400a73ab 100644 --- a/internals-js/src/index.ts +++ b/internals-js/src/index.ts @@ -26,4 +26,3 @@ export * from './specs/requiresScopesSpec'; export * from './specs/policySpec'; export * from './specs/sourceSpec'; export * from './specs/costSpec'; -export * from './specs/listSizeSpec'; 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 index 7edcbafe4..f6f1bda54 100644 --- a/internals-js/src/specs/costSpec.ts +++ b/internals-js/src/specs/costSpec.ts @@ -1,7 +1,7 @@ import { DirectiveLocation } from 'graphql'; import { createDirectiveSpecification } from '../directiveAndTypeSpecification'; import { FeatureDefinition, FeatureDefinitions, FeatureUrl, FeatureVersion } from './coreSpec'; -import { NonNullType } from '../definitions'; +import { ListType, NonNullType } from '../definitions'; import { registerKnownFeature } from '../knownCoreFeatures'; import { ARGUMENT_COMPOSITION_STRATEGIES } from '../argumentCompositionStrategies'; @@ -26,6 +26,20 @@ export class CostSpecDefinition extends FeatureDefinition { 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) + })); } } @@ -37,3 +51,10 @@ 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 e22c7adfe..0b8c52542 100644 --- a/internals-js/src/specs/federationSpec.ts +++ b/internals-js/src/specs/federationSpec.ts @@ -21,7 +21,6 @@ import { POLICY_VERSIONS } from './policySpec'; import { SOURCE_VERSIONS } from './sourceSpec'; import { CONTEXT_VERSIONS } from './contextSpec'; import { COST_VERSIONS } from "./costSpec"; -import { LIST_SIZE_VERSIONS } from "./listSizeSpec"; export const federationIdentity = 'https://specs.apollo.dev/federation'; @@ -189,7 +188,6 @@ export class FederationSpecDefinition extends FeatureDefinition { if (version.gte(new FeatureVersion(2, 9))) { this.registerSubFeature(COST_VERSIONS.find(new FeatureVersion(0, 1))!); - this.registerSubFeature(LIST_SIZE_VERSIONS.find(new FeatureVersion(0, 1))!); } } } diff --git a/internals-js/src/specs/listSizeSpec.ts b/internals-js/src/specs/listSizeSpec.ts deleted file mode 100644 index fca71e321..000000000 --- a/internals-js/src/specs/listSizeSpec.ts +++ /dev/null @@ -1,40 +0,0 @@ -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 listSizeIdentity = 'https://specs.apollo.dev/listSize'; - -export class ListSizeSpecDefinition extends FeatureDefinition { - constructor(version: FeatureVersion, readonly minimumFederationVersion: FeatureVersion) { - super(new FeatureUrl(listSizeIdentity, 'listSize', version), minimumFederationVersion); - - 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) => LIST_SIZE_VERSIONS.getMinimumRequiredVersion(fedVersion) - })); - } -} - -export const LIST_SIZE_VERSIONS = new FeatureDefinitions(listSizeIdentity) - .add(new ListSizeSpecDefinition(new FeatureVersion(0, 1), new FeatureVersion(2, 9))); - -registerKnownFeature(LIST_SIZE_VERSIONS); - -export interface ListSizeDirectiveArguments { - assumedSize?: number; - slicingArguments?: string[]; - sizedFields?: string[]; - requireOneSlicingArgument?: boolean; -} diff --git a/internals-js/src/supergraphs.ts b/internals-js/src/supergraphs.ts index d1529414d..3f37a103a 100644 --- a/internals-js/src/supergraphs.ts +++ b/internals-js/src/supergraphs.ts @@ -41,7 +41,6 @@ export const ROUTER_SUPPORTED_SUPERGRAPH_FEATURES = new Set([ 'https://specs.apollo.dev/source/v0.1', 'https://specs.apollo.dev/context/v0.1', 'https://specs.apollo.dev/cost/v0.1', - 'https://specs.apollo.dev/listSize/v0.1', ]); const coreVersionZeroDotOneUrl = FeatureUrl.parse('https://specs.apollo.dev/core/v0.1');