From 22defef31428f6f894d8ccb84a767d71fdccf4b3 Mon Sep 17 00:00:00 2001 From: Lenny Burdette Date: Thu, 11 Apr 2024 09:07:10 -0400 Subject: [PATCH] feat: add connect/v0.1 spec replaces the source/v0.1 spec also attempts to clean up imported types that aren't used when serializating directives with join__directive --- composition-js/src/__tests__/compose.test.ts | 222 +++++++++++++++++++ composition-js/src/merging/merge.ts | 44 ++++ internals-js/src/index.ts | 1 + internals-js/src/specs/connectSpec.ts | 146 ++++++++++++ 4 files changed, 413 insertions(+) create mode 100644 internals-js/src/specs/connectSpec.ts diff --git a/composition-js/src/__tests__/compose.test.ts b/composition-js/src/__tests__/compose.test.ts index 67bcbd295..c21fa84bc 100644 --- a/composition-js/src/__tests__/compose.test.ts +++ b/composition-js/src/__tests__/compose.test.ts @@ -5203,3 +5203,225 @@ describe('@source* directives', () => { }); }); }); + +describe("connect spec and join__directive", () => { + it("composes", () => { + const subgraphs = [ + { + name: "with-connectors", + typeDefs: gql` + extend schema + @link( + url: "https://specs.apollo.dev/federation/v2.7" + import: ["@key"] + ) + @link( + url: "https://specs.apollo.dev/connect/v0.1" + import: ["@connect", "@source"] + ) + @source(name: "v1", http: { baseURL: "http://v1" }) + + type Query { + resources: [Resource!]! + @connect(source: "v1", http: { GET: "/resources" }) + } + + type Resource @key(fields: "id") { + id: ID! + name: String! + } + `, + }, + ]; + + const result = composeServices(subgraphs); + expect(result.errors ?? []).toEqual([]); + const printed = printSchema(result.schema!); + expect(printed).toMatchInlineSnapshot(` + "schema + @link(url: \\"https://specs.apollo.dev/link/v1.0\\") + @link(url: \\"https://specs.apollo.dev/join/v0.4\\", for: EXECUTION) + @join__directive(graphs: [WITH_CONNECTORS], name: \\"link\\", args: {url: \\"https://specs.apollo.dev/connect/v0.1\\", import: [\\"@connect\\", \\"@source\\"]}) + @join__directive(graphs: [WITH_CONNECTORS], name: \\"source\\", args: {name: \\"v1\\", http: {baseURL: \\"http://v1\\"}}) + { + query: Query + } + + directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA + + directive @join__graph(name: String!, url: String!) on ENUM_VALUE + + directive @join__type(graph: join__Graph!, key: join__FieldSet, extension: Boolean! = false, resolvable: Boolean! = true, isInterfaceObject: Boolean! = false) repeatable on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT | SCALAR + + directive @join__field(graph: join__Graph, requires: join__FieldSet, provides: join__FieldSet, type: String, external: Boolean, override: String, usedOverridden: Boolean, overrideLabel: String) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION + + directive @join__implements(graph: join__Graph!, interface: String!) repeatable on OBJECT | INTERFACE + + directive @join__unionMember(graph: join__Graph!, member: String!) repeatable on UNION + + directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE + + directive @join__directive(graphs: [join__Graph!], name: String!, args: join__DirectiveArguments) repeatable on SCHEMA | OBJECT | INTERFACE | FIELD_DEFINITION + + enum link__Purpose { + \\"\\"\\" + \`SECURITY\` features provide metadata necessary to securely resolve fields. + \\"\\"\\" + SECURITY + + \\"\\"\\" + \`EXECUTION\` features provide metadata necessary for operation execution. + \\"\\"\\" + EXECUTION + } + + scalar link__Import + + enum join__Graph { + WITH_CONNECTORS @join__graph(name: \\"with-connectors\\", url: \\"\\") + } + + scalar join__FieldSet + + scalar join__DirectiveArguments + + type Query + @join__type(graph: WITH_CONNECTORS) + { + resources: [Resource!]! @join__directive(graphs: [WITH_CONNECTORS], name: \\"connect\\", args: {source: \\"v1\\", http: {GET: \\"/resources\\"}}) + } + + type Resource + @join__type(graph: WITH_CONNECTORS, key: \\"id\\") + { + id: ID! + name: String! + }" + `); + + if (result.schema) { + expect(printSchema(result.schema.toAPISchema())).toMatchInlineSnapshot(` + "type Query { + resources: [Resource!]! + } + + type Resource { + id: ID! + name: String! + }" + `); + } + }); + + it("composes with renames", () => { + const subgraphs = [ + { + name: "with-connectors", + typeDefs: gql` + extend schema + @link( + url: "https://specs.apollo.dev/federation/v2.7" + import: ["@key"] + ) + @link( + url: "https://specs.apollo.dev/connect/v0.1" + as: "http" + import: [ + { name: "@connect", as: "@http" } + { name: "@source", as: "@api" } + ] + ) + @api(name: "v1", http: { baseURL: "http://v1" }) + + type Query { + resources: [Resource!]! + @http(source: "v1", http: { GET: "/resources" }) + } + + type Resource @key(fields: "id") { + id: ID! + name: String! + } + `, + }, + ]; + + const result = composeServices(subgraphs); + expect(result.errors ?? []).toEqual([]); + const printed = printSchema(result.schema!); + expect(printed).toMatchInlineSnapshot(` + "schema + @link(url: \\"https://specs.apollo.dev/link/v1.0\\") + @link(url: \\"https://specs.apollo.dev/join/v0.4\\", for: EXECUTION) + @join__directive(graphs: [WITH_CONNECTORS], name: \\"link\\", args: {url: \\"https://specs.apollo.dev/connect/v0.1\\", as: \\"http\\", import: [{name: \\"@connect\\", as: \\"@http\\"}, {name: \\"@source\\", as: \\"@api\\"}]}) + @join__directive(graphs: [WITH_CONNECTORS], name: \\"api\\", args: {name: \\"v1\\", http: {baseURL: \\"http://v1\\"}}) + { + query: Query + } + + directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA + + directive @join__graph(name: String!, url: String!) on ENUM_VALUE + + directive @join__type(graph: join__Graph!, key: join__FieldSet, extension: Boolean! = false, resolvable: Boolean! = true, isInterfaceObject: Boolean! = false) repeatable on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT | SCALAR + + directive @join__field(graph: join__Graph, requires: join__FieldSet, provides: join__FieldSet, type: String, external: Boolean, override: String, usedOverridden: Boolean, overrideLabel: String) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION + + directive @join__implements(graph: join__Graph!, interface: String!) repeatable on OBJECT | INTERFACE + + directive @join__unionMember(graph: join__Graph!, member: String!) repeatable on UNION + + directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE + + directive @join__directive(graphs: [join__Graph!], name: String!, args: join__DirectiveArguments) repeatable on SCHEMA | OBJECT | INTERFACE | FIELD_DEFINITION + + enum link__Purpose { + \\"\\"\\" + \`SECURITY\` features provide metadata necessary to securely resolve fields. + \\"\\"\\" + SECURITY + + \\"\\"\\" + \`EXECUTION\` features provide metadata necessary for operation execution. + \\"\\"\\" + EXECUTION + } + + scalar link__Import + + enum join__Graph { + WITH_CONNECTORS @join__graph(name: \\"with-connectors\\", url: \\"\\") + } + + scalar join__FieldSet + + scalar join__DirectiveArguments + + type Query + @join__type(graph: WITH_CONNECTORS) + { + resources: [Resource!]! @join__directive(graphs: [WITH_CONNECTORS], name: \\"http\\", args: {source: \\"v1\\", http: {GET: \\"/resources\\"}}) + } + + type Resource + @join__type(graph: WITH_CONNECTORS, key: \\"id\\") + { + id: ID! + name: String! + }" + `); + + if (result.schema) { + expect(printSchema(result.schema.toAPISchema())).toMatchInlineSnapshot(` + "type Query { + resources: [Resource!]! + } + + type Resource { + id: ID! + name: String! + }" + `); + } + }); +}); diff --git a/composition-js/src/merging/merge.ts b/composition-js/src/merging/merge.ts index fd639471e..5f385f90f 100644 --- a/composition-js/src/merging/merge.ts +++ b/composition-js/src/merging/merge.ts @@ -76,6 +76,9 @@ import { FeatureUrl, CoreFeature, Subgraph, + connectIdentity, + coreFeatureDefinitionIfKnown, + FeatureDefinition, } from "@apollo/federation-internals"; import { ASTNode, GraphQLError, DirectiveLocation } from "graphql"; import { @@ -341,6 +344,7 @@ class Merger { [ // Represent any applications of directives imported from these spec URLs // using @join__directive in the merged supergraph. sourceIdentity, + connectIdentity, ].forEach(url => this.joinDirectiveIdentityURLs.add(url)); } @@ -557,6 +561,10 @@ class Merger { // we want to make sure everything is ready. this.addMissingInterfaceObjectFieldsToImplementations(); + // After converting some `@link`ed definitions to use `@join__directive`, + // we might have some imported scalars and input types to remove from the schema. + this.removeTypesAfterJoinDirectiveSerialization(this.merged); + // If we already encountered errors, `this.merged` is probably incomplete. Let's not risk adding errors that // are only an artifact of that incompleteness as it's confusing. if (this.errors.length === 0) { @@ -2816,6 +2824,42 @@ class Merger { }); } + // After merging, if we added any join__directive directives, we want to + // remove types imported from the original `@link` directive to avoid + // orphaned types. When extractSubgraphsFromSupergraph is called, it will + // add the types back to the subgraph. + // + // TODO: this doesn't handle renamed imports + private removeTypesAfterJoinDirectiveSerialization(schema: Schema) { + const joinDirectiveLinks = schema.directives() + .filter(d => d.name === 'join__directive') + .flatMap(d => d.applications()) + .filter(a => a.arguments().name === 'link'); + + // We can't use `.nameInSchema()` because the `@link` directive isn't + // directly in the schema, it's obscured by the `@join__directive` directive + const joinDirectiveFieldsWithNamespaces = Object.fromEntries(joinDirectiveLinks.flatMap(link => { + const url = link.arguments().args.url; + const parsed = FeatureUrl.parse(url); + if (parsed) { + const featureDefinition = coreFeatureDefinitionIfKnown(parsed); + if (featureDefinition) { + const nameInSchema = link.arguments().args.as ?? featureDefinition.url.name; + return [[nameInSchema, featureDefinition]] as [string, FeatureDefinition][]; + } + } + + // TODO: error if we can't parse URLs or find core feature definitions? + return [] as [string, FeatureDefinition][]; + })); + + for (const [namespace, featureDefinition] of Object.entries(joinDirectiveFieldsWithNamespaces)) { + featureDefinition.allElementNames().forEach(name => { + schema.type(`${namespace}__${name}`)?.removeRecursive() + }); + } + } + private filterSubgraphs(predicate: (schema: Schema) => boolean): string[] { return this.subgraphsSchema.map((s, i) => predicate(s) ? this.names[i] : undefined).filter(n => n !== undefined) as string[]; } diff --git a/internals-js/src/index.ts b/internals-js/src/index.ts index 78d0d9950..ef21152b2 100644 --- a/internals-js/src/index.ts +++ b/internals-js/src/index.ts @@ -24,3 +24,4 @@ export * from './specs/authenticatedSpec'; export * from './specs/requiresScopesSpec'; export * from './specs/policySpec'; export * from './specs/sourceSpec'; +export * from './specs/connectSpec'; diff --git a/internals-js/src/specs/connectSpec.ts b/internals-js/src/specs/connectSpec.ts new file mode 100644 index 000000000..cbe6e5558 --- /dev/null +++ b/internals-js/src/specs/connectSpec.ts @@ -0,0 +1,146 @@ +import { DirectiveLocation, GraphQLError } from 'graphql'; +import { CorePurpose, FeatureDefinition, FeatureDefinitions, FeatureUrl, FeatureVersion } from "./coreSpec"; +import { + Schema, + NonNullType, + InputObjectType, + InputFieldDefinition, + ListType, +} from '../definitions'; +import { registerKnownFeature } from '../knownCoreFeatures'; +import { createDirectiveSpecification } from '../directiveAndTypeSpecification'; + +export const connectIdentity = 'https://specs.apollo.dev/connect'; + +const CONNECT = "connect"; +const SOURCE = "source"; +const URL_PATH_TEMPLATE = "URLPathTemplate"; +const JSON_SELECTION = "JSONSelection"; +const CONNECT_HTTP = "ConnectHTTP"; +const SOURCE_HTTP = "SourceHTTP"; +const HTTP_HEADER_MAPPING = "HTTPHeaderMapping"; + +export class ConnectSpecDefinition extends FeatureDefinition { + constructor(version: FeatureVersion, readonly minimumFederationVersion: FeatureVersion) { + super(new FeatureUrl(connectIdentity, 'connect', version), minimumFederationVersion); + + this.registerDirective(createDirectiveSpecification({ + name: CONNECT, + locations: [DirectiveLocation.FIELD_DEFINITION], + repeatable: true, + // We "compose" these directives using the `@join__directive` mechanism, + // so they do not need to be composed in the way passing `composes: true` + // here implies. + composes: false, + })); + + this.registerDirective(createDirectiveSpecification({ + name: SOURCE, + locations: [DirectiveLocation.SCHEMA], + repeatable: true, + composes: false, + })); + + this.registerType({ name: URL_PATH_TEMPLATE, checkOrAdd: () => [] }); + this.registerType({ name: JSON_SELECTION, checkOrAdd: () => [] }); + this.registerType({ name: CONNECT_HTTP, checkOrAdd: () => [] }); + this.registerType({ name: SOURCE_HTTP, checkOrAdd: () => [] }); + this.registerType({ name: HTTP_HEADER_MAPPING, checkOrAdd: () => [] }); + } + + addElementsToSchema(schema: Schema): GraphQLError[] { + /* scalar URLPathTemplate */ + const URLPathTemplate = this.addScalarType(schema, URL_PATH_TEMPLATE); + + /* scalar JSONSelection */ + const JSONSelection = this.addScalarType(schema, JSON_SELECTION); + + /* + directive @connect( + source: String + http: ConnectHTTP + selection: JSONSelection! + ) repeatable on FIELD_DEFINITION + */ + const connect = this.addDirective(schema, CONNECT).addLocations(DirectiveLocation.FIELD_DEFINITION); + connect.repeatable = true; + + connect.addArgument(SOURCE, schema.stringType()); + + /* + input HTTPHeaderMapping { + name: String! + as: String + value: String + } + */ + const HTTPHeaderMapping = schema.addType(new InputObjectType(this.typeNameInSchema(schema, HTTP_HEADER_MAPPING)!)); + HTTPHeaderMapping.addField(new InputFieldDefinition('name')).type = + new NonNullType(schema.stringType()); + HTTPHeaderMapping.addField(new InputFieldDefinition('as')).type = + schema.stringType(); + HTTPHeaderMapping.addField(new InputFieldDefinition('value')).type = + schema.stringType(); + + /* + input ConnectHTTP { + GET: URLPathTemplate + POST: URLPathTemplate + PUT: URLPathTemplate + PATCH: URLPathTemplate + DELETE: URLPathTemplate + body: JSONSelection + headers: [HTTPHeaderMapping!] + } + */ + const ConnectHTTP = schema.addType(new InputObjectType(this.typeNameInSchema(schema, CONNECT_HTTP)!)); + ConnectHTTP.addField(new InputFieldDefinition('GET')).type = URLPathTemplate; + ConnectHTTP.addField(new InputFieldDefinition('POST')).type = URLPathTemplate; + ConnectHTTP.addField(new InputFieldDefinition('PUT')).type = URLPathTemplate; + ConnectHTTP.addField(new InputFieldDefinition('PATCH')).type = URLPathTemplate; + ConnectHTTP.addField(new InputFieldDefinition('DELETE')).type = URLPathTemplate; + ConnectHTTP.addField(new InputFieldDefinition('body')).type = JSONSelection; + ConnectHTTP.addField(new InputFieldDefinition('headers')).type = + new ListType(new NonNullType(HTTPHeaderMapping)); + connect.addArgument('http', ConnectHTTP); + + connect.addArgument('selection', JSONSelection); + + /* + directive @source( + name: String! + http: ConnectHTTP + ) repeatable on SCHEMA + */ + const source = this.addDirective(schema, SOURCE).addLocations( + DirectiveLocation.SCHEMA, + ); + source.repeatable = true; + source.addArgument('name', new NonNullType(schema.stringType())); + + /* + input SourceHTTP { + baseURL: String! + headers: [HTTPHeaderMapping!] + } + */ + const SourceHTTP = schema.addType(new InputObjectType(this.typeNameInSchema(schema, SOURCE_HTTP)!)); + SourceHTTP.addField(new InputFieldDefinition('baseURL')).type = + new NonNullType(schema.stringType()); + SourceHTTP.addField(new InputFieldDefinition('headers')).type = + new ListType(new NonNullType(HTTPHeaderMapping)); + + source.addArgument('http', SourceHTTP); + + return []; + } + + get defaultCorePurpose(): CorePurpose { + return 'EXECUTION'; + } +} + +export const CONNECT_VERSIONS = new FeatureDefinitions(connectIdentity) + .add(new ConnectSpecDefinition(new FeatureVersion(0, 1), new FeatureVersion(2, 8))); + +registerKnownFeature(CONNECT_VERSIONS);