From 8927e315ab0e865ef3ff12320f265ee95588b899 Mon Sep 17 00:00:00 2001 From: Lenny Burdette Date: Thu, 13 Feb 2025 14:25:36 -0500 Subject: [PATCH] federation 2.10.0 (#3215) --- .changeset/five-ways-accept.md | 11 + .changeset/pre.json | 18 + .changeset/twenty-parents-wait.md | 5 + .changeset/young-pigs-crash.md | 5 + .circleci/config.yml | 1 + .github/workflows/release.yml | 10 +- composition-js/package.json | 6 +- composition-js/src/__tests__/compose.test.ts | 697 ++---------------- .../src/__tests__/connectors.test.ts | 321 ++++++++ composition-js/src/__tests__/hints.test.ts | 32 +- composition-js/src/compose.ts | 14 +- composition-js/src/merging/merge.ts | 45 +- .../package.json | 2 +- gateway-js/package.json | 8 +- gateway-js/src/__generated__/graphqlTypes.ts | 9 + internals-js/package.json | 2 +- .../src/__tests__/schemaUpgrader.test.ts | 16 +- internals-js/src/error.ts | 139 +--- internals-js/src/federation.ts | 85 +-- internals-js/src/index.ts | 2 +- internals-js/src/knownCoreFeatures.ts | 15 - internals-js/src/specs/connectSpec.ts | 148 ++++ internals-js/src/specs/coreSpec.ts | 9 +- internals-js/src/specs/federationSpec.ts | 13 +- internals-js/src/specs/sourceSpec.ts | 607 --------------- internals-js/src/supergraphs.ts | 3 +- package-lock.json | 66 +- query-graphs-js/package.json | 4 +- query-planner-js/package.json | 6 +- subgraph-js/package.json | 4 +- .../src/__tests__/buildSubgraphSchema.test.ts | 106 +++ subgraph-js/src/types.ts | 11 +- 32 files changed, 837 insertions(+), 1583 deletions(-) create mode 100644 .changeset/five-ways-accept.md create mode 100644 .changeset/pre.json create mode 100644 .changeset/twenty-parents-wait.md create mode 100644 .changeset/young-pigs-crash.md create mode 100644 composition-js/src/__tests__/connectors.test.ts create mode 100644 internals-js/src/specs/connectSpec.ts delete mode 100644 internals-js/src/specs/sourceSpec.ts diff --git a/.changeset/five-ways-accept.md b/.changeset/five-ways-accept.md new file mode 100644 index 000000000..c2de5aa46 --- /dev/null +++ b/.changeset/five-ways-accept.md @@ -0,0 +1,11 @@ +--- +"apollo-federation-integration-testsuite": minor +"@apollo/query-planner": minor +"@apollo/query-graphs": minor +"@apollo/composition": minor +"@apollo/federation-internals": minor +"@apollo/subgraph": minor +"@apollo/gateway": minor +--- + +Adds the ability to compose and serialize directives for [Apollo Connectors](https://go.apollo.dev/connectors). To use Apollo Connectors, compose your supergraphs using [GraphOS](https://www.apollographql.com/docs/graphos/platform/schema-management) or [rover](https://www.apollographql.com/docs/rover/commands/dev), and run your supergraph in [Apollo Router](https://www.apollographql.com/docs/graphos/routing) 2.0.0 or higher. diff --git a/.changeset/pre.json b/.changeset/pre.json new file mode 100644 index 000000000..b2354019f --- /dev/null +++ b/.changeset/pre.json @@ -0,0 +1,18 @@ +{ + "mode": "exit", + "tag": "alpha", + "initialVersions": { + "@apollo/composition": "2.9.0", + "apollo-federation-integration-testsuite": "2.9.0", + "@apollo/gateway": "2.9.0", + "@apollo/federation-internals": "2.9.0", + "@apollo/query-graphs": "2.9.0", + "@apollo/query-planner": "2.9.0", + "@apollo/subgraph": "2.9.0" + }, + "changesets": [ + "twenty-parents-wait", + "warm-moles-jog", + "young-pigs-crash" + ] +} diff --git a/.changeset/twenty-parents-wait.md b/.changeset/twenty-parents-wait.md new file mode 100644 index 000000000..7ce545cba --- /dev/null +++ b/.changeset/twenty-parents-wait.md @@ -0,0 +1,5 @@ +--- +"@apollo/federation-internals": patch +--- + +Incorporate changes from v2.9.3 diff --git a/.changeset/young-pigs-crash.md b/.changeset/young-pigs-crash.md new file mode 100644 index 000000000..b7470632b --- /dev/null +++ b/.changeset/young-pigs-crash.md @@ -0,0 +1,5 @@ +--- +"@apollo/subgraph": patch +--- + +When resolving references, skip type resolution if the reference resolves to null. diff --git a/.circleci/config.yml b/.circleci/config.yml index 16f8769d9..7ac5ffc08 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -13,6 +13,7 @@ jobs: type: string docker: - image: cimg/base:stable + resource_class: large steps: - checkout - node/install: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7a4d5baa0..8ca7fbfc5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -25,11 +25,11 @@ jobs: - name: Install Dependencies run: npm i - + - name: Set env run: echo "FEDERATION_VERSION=$(npm --prefix ./internals-js version --json |jq -r '.["@apollo/federation-internals"]')" >> $GITHUB_ENV - - name: Create Release Pull Request + - name: Create Release Pull Request or Publish to npm id: changesets uses: changesets/action@v1 with: @@ -41,7 +41,7 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} NPM_TOKEN: ${{ secrets.NPM_TOKEN }} - + - name: Sleep for 20 seconds (arbitrary, give NPM time to populate new `latest` versions) if: steps.changesets.outputs.published == 'true' run: sleep 20 @@ -58,7 +58,7 @@ jobs: repo: 'federation-rs', workflow_id: '.github/workflows/release.yml', ref: 'main', - inputs: { + inputs: { version: "${{ env.FEDERATION_VERSION }}" } }) @@ -66,7 +66,7 @@ jobs: if: steps.changesets.outputs.published == 'true' # write token to the NPM rc file (npm login) run: echo //registry.npmjs.org/:_authToken=${{ secrets.NPM_TOKEN }} > ~/.npmrc - + - name: Update next tags if appropriate if: steps.changesets.outputs.published == 'true' run: node scripts/update-next-tags.mjs diff --git a/composition-js/package.json b/composition-js/package.json index facbde449..297a8da86 100644 --- a/composition-js/package.json +++ b/composition-js/package.json @@ -1,6 +1,6 @@ { "name": "@apollo/composition", - "version": "2.9.3", + "version": "2.10.0-alpha.4", "description": "Apollo Federation composition utilities", "main": "dist/index.js", "types": "dist/index.d.ts", @@ -27,8 +27,8 @@ "access": "public" }, "dependencies": { - "@apollo/federation-internals": "2.9.3", - "@apollo/query-graphs": "2.9.3" + "@apollo/federation-internals": "2.10.0-alpha.4", + "@apollo/query-graphs": "2.10.0-alpha.4" }, "peerDependencies": { "graphql": "^16.5.0" diff --git a/composition-js/src/__tests__/compose.test.ts b/composition-js/src/__tests__/compose.test.ts index e0a538fab..df63bf699 100644 --- a/composition-js/src/__tests__/compose.test.ts +++ b/composition-js/src/__tests__/compose.test.ts @@ -16,7 +16,7 @@ import { } from '@apollo/federation-internals'; import { CompositionOptions, CompositionResult, composeServices } from '../compose'; import gql from 'graphql-tag'; -import { print } from 'graphql'; +import {print} from 'graphql'; import { assertCompositionSuccess, schemas, @@ -4655,638 +4655,73 @@ describe('composition', () => { const authenticatedDirectiveExists = schema.directives().find(d => d.name === 'authenticated'); expect(authenticatedDirectiveExists).toBeUndefined(); }); -}); - -describe('@source* directives', () => { - const schemaA = gql` - extend schema - @link(url: "https://specs.apollo.dev/federation/v2.7", import: [ - "@key" - "@shareable" - ]) - @link(url: "https://specs.apollo.dev/source/v0.1", import: [ - "@sourceAPI" - "@sourceType" - "@sourceField" - ]) - @sourceAPI( - name: "A" - http: { baseURL: "https://api.a.com/v1" } - ) - - type Query { - resources: [Resource!]! @sourceField( - api: "A" - http: { GET: "/resources" } - ) @shareable - } - - type Resource @key(fields: "id") @sourceType( - api: "A" - http: { GET: "/resources/{id}" } - selection: "id description" - ) { - id: ID! - description: String! - } - `; - - const schemaB = gql` - extend schema - @link(url: "https://specs.apollo.dev/federation/v2.7", import: [ - "@key" - "@shareable" - ]) - @link(url: "https://specs.apollo.dev/source/v0.1", import: [ - "@sourceAPI" - "@sourceType" - "@sourceField" - ]) - @sourceAPI( - name: "A" - http: { baseURL: "https://api.a.com/v1" } - ) - - type Query { - resources: [Resource!]! @sourceField( - api: "A" - http: { GET: "/resources" } - ) @shareable - } - - type Resource @key(fields: "id") { - id: ID! - } - `; - - const schemaC = gql` - extend schema - @link(url: "https://specs.apollo.dev/federation/v2.7", import: [ - "@key" - "@shareable" - ]) - @link(url: "https://specs.apollo.dev/source/v0.1", import: [ - "@sourceAPI" - "@sourceType" - "@sourceField" - ]) - @sourceAPI( - name: "A" - http: { baseURL: "https://api.a.com/v1" } - ) - - type Resource @key(fields: "id") @sourceType( - api: "A" - http: { GET: "/resources/{id}" } - selection: "id creationDate" - ) { - id: ID! - creationDate: String! - } - `; - - it('single subgraph composition', () => { - const subgraphA = { - name: 'subgraphA', - typeDefs: schemaA, - }; - const result = composeServices([subgraphA]); - expect(result.errors ?? []).toEqual([]); - const printed = printSchema(result.schema!); - expect(printed).toContain( -`schema - @link(url: "https://specs.apollo.dev/link/v1.0") - @link(url: "https://specs.apollo.dev/join/v0.4", for: EXECUTION) - @join__directive(graphs: [SUBGRAPHA], name: "link", args: {url: "https://specs.apollo.dev/source/v0.1", import: ["@sourceAPI", "@sourceType", "@sourceField"]}) - @join__directive(graphs: [SUBGRAPHA], name: "sourceAPI", args: {name: "A", http: {baseURL: "https://api.a.com/v1"}}) -{ - query: Query -}`); - - expect(printed).toContain( - `directive @join__directive(graphs: [join__Graph!], name: String!, args: join__DirectiveArguments) repeatable on SCHEMA | OBJECT | INTERFACE | FIELD_DEFINITION` - ); - - expect(printed).toContain( -`type Query - @join__type(graph: SUBGRAPHA) -{ - resources: [Resource!]! @join__directive(graphs: [SUBGRAPHA], name: "sourceField", args: {api: "A", http: {GET: "/resources"}}) -}` - ); - - expect(printed).toContain( -`type Resource - @join__type(graph: SUBGRAPHA, key: "id") - @join__directive(graphs: [SUBGRAPHA], name: "sourceType", args: {api: "A", http: {GET: "/resources/{id}"}, selection: "id description"}) -{ - id: ID! - description: String! -}` - ); - }); - - it('subgraphA and subgraphB composition', () => { - const result = composeServices([ - { - name: 'subgraphA', - typeDefs: schemaA, - }, - { - name: 'subgraphB', - typeDefs: schemaB, - }, - ]); - expect(result.errors ?? []).toEqual([]); - const printed = printSchema(result.schema!); - - expect(printed).toContain( -`schema - @link(url: \"https://specs.apollo.dev/link/v1.0\") - @link(url: \"https://specs.apollo.dev/join/v0.4\", for: EXECUTION) - @join__directive(graphs: [SUBGRAPHA, SUBGRAPHB], name: \"link\", args: {url: \"https://specs.apollo.dev/source/v0.1\", import: [\"@sourceAPI\", \"@sourceType\", \"@sourceField\"]}) - @join__directive(graphs: [SUBGRAPHA, SUBGRAPHB], name: \"sourceAPI\", args: {name: \"A\", http: {baseURL: \"https://api.a.com/v1\"}}) -{ - query: Query -}` - ); - - expect(printed).toContain( -`type Query - @join__type(graph: SUBGRAPHA) - @join__type(graph: SUBGRAPHB) -{ - resources: [Resource!]! @join__directive(graphs: [SUBGRAPHA, SUBGRAPHB], name: \"sourceField\", args: {api: \"A\", http: {GET: \"/resources\"}}) -}` - ); - - expect(printed).toContain( -`type Resource - @join__type(graph: SUBGRAPHA, key: \"id\") - @join__type(graph: SUBGRAPHB, key: \"id\") - @join__directive(graphs: [SUBGRAPHA], name: \"sourceType\", args: {api: \"A\", http: {GET: \"/resources/{id}\"}, selection: \"id description\"}) -{ - id: ID! - description: String! @join__field(graph: SUBGRAPHA) -}` - ); - }); - - it('subgraphA and subgraphC composition', () => { - const result = composeServices([ - { - name: 'subgraphA', - typeDefs: schemaA, - }, - { - name: 'subgraphC', - typeDefs: schemaC, - }, - ]); - expect(result.errors ?? []).toEqual([]); - const printed = printSchema(result.schema!); - - expect(printed).toContain( -`schema - @link(url: \"https://specs.apollo.dev/link/v1.0\") - @link(url: \"https://specs.apollo.dev/join/v0.4\", for: EXECUTION) - @join__directive(graphs: [SUBGRAPHA, SUBGRAPHC], name: \"link\", args: {url: \"https://specs.apollo.dev/source/v0.1\", import: [\"@sourceAPI\", \"@sourceType\", \"@sourceField\"]}) - @join__directive(graphs: [SUBGRAPHA, SUBGRAPHC], name: \"sourceAPI\", args: {name: \"A\", http: {baseURL: \"https://api.a.com/v1\"}}) -{ - query: Query -}` - ); - - expect(printed).toContain( -`type Query - @join__type(graph: SUBGRAPHA) - @join__type(graph: SUBGRAPHC) -{ - resources: [Resource!]! @join__field(graph: SUBGRAPHA) @join__directive(graphs: [SUBGRAPHA], name: \"sourceField\", args: {api: \"A\", http: {GET: \"/resources\"}}) -}` - ); - - expect(printed).toContain( -`type Resource - @join__type(graph: SUBGRAPHA, key: \"id\") - @join__type(graph: SUBGRAPHC, key: \"id\") - @join__directive(graphs: [SUBGRAPHA], name: \"sourceType\", args: {api: \"A\", http: {GET: \"/resources/{id}\"}, selection: \"id description\"}) - @join__directive(graphs: [SUBGRAPHC], name: \"sourceType\", args: {api: \"A\", http: {GET: \"/resources/{id}\"}, selection: \"id creationDate\"}) -{ - id: ID! - description: String! @join__field(graph: SUBGRAPHA) - creationDate: String! @join__field(graph: SUBGRAPHC) -}` - ); - }); - - it('subgraphB and subgraphC composition', () => { - const result = composeServices([ - { - name: 'subgraphB', - typeDefs: schemaB, - }, - { - name: 'subgraphC', - typeDefs: schemaC, - }, - ]); - expect(result.errors ?? []).toEqual([]); - const printed = printSchema(result.schema!); - - expect(printed).toContain( -`schema - @link(url: \"https://specs.apollo.dev/link/v1.0\") - @link(url: \"https://specs.apollo.dev/join/v0.4\", for: EXECUTION) - @join__directive(graphs: [SUBGRAPHB, SUBGRAPHC], name: \"link\", args: {url: \"https://specs.apollo.dev/source/v0.1\", import: [\"@sourceAPI\", \"@sourceType\", \"@sourceField\"]}) - @join__directive(graphs: [SUBGRAPHB, SUBGRAPHC], name: \"sourceAPI\", args: {name: \"A\", http: {baseURL: \"https://api.a.com/v1\"}}) -{ - query: Query -}`); - - expect(printed).toContain( -`type Query - @join__type(graph: SUBGRAPHB) - @join__type(graph: SUBGRAPHC) -{ - resources: [Resource!]! @join__field(graph: SUBGRAPHB) @join__directive(graphs: [SUBGRAPHB], name: \"sourceField\", args: {api: \"A\", http: {GET: \"/resources\"}}) -}` - ); - - expect(printed).toContain( -`type Resource - @join__type(graph: SUBGRAPHB, key: \"id\") - @join__type(graph: SUBGRAPHC, key: \"id\") - @join__directive(graphs: [SUBGRAPHC], name: \"sourceType\", args: {api: \"A\", http: {GET: \"/resources/{id}\"}, selection: \"id creationDate\"}) -{ - id: ID! - creationDate: String! @join__field(graph: SUBGRAPHC) -}` - ); - }); - - it('subgraphA, subgraphB, and subgraphC composition', () => { - const result = composeServices([ - { - name: 'subgraphA', - typeDefs: schemaA, - }, - { - name: 'subgraphB', - typeDefs: schemaB, - }, - { - name: 'subgraphC', - typeDefs: schemaC, - }, - ]); - expect(result.errors ?? []).toEqual([]); - const printed = printSchema(result.schema!); - - expect(printed).toContain( -`schema - @link(url: \"https://specs.apollo.dev/link/v1.0\") - @link(url: \"https://specs.apollo.dev/join/v0.4\", for: EXECUTION) - @join__directive(graphs: [SUBGRAPHA, SUBGRAPHB, SUBGRAPHC], name: \"link\", args: {url: \"https://specs.apollo.dev/source/v0.1\", import: [\"@sourceAPI\", \"@sourceType\", \"@sourceField\"]}) - @join__directive(graphs: [SUBGRAPHA, SUBGRAPHB, SUBGRAPHC], name: \"sourceAPI\", args: {name: \"A\", http: {baseURL: \"https://api.a.com/v1\"}}) -{ - query: Query -}` - ); - - expect(printed).toContain( -`type Query - @join__type(graph: SUBGRAPHA) - @join__type(graph: SUBGRAPHB) - @join__type(graph: SUBGRAPHC) -{ - resources: [Resource!]! @join__field(graph: SUBGRAPHA) @join__field(graph: SUBGRAPHB) @join__directive(graphs: [SUBGRAPHA, SUBGRAPHB], name: \"sourceField\", args: {api: \"A\", http: {GET: \"/resources\"}}) -}` - ); - - expect(printed).toContain( -`type Resource - @join__type(graph: SUBGRAPHA, key: \"id\") - @join__type(graph: SUBGRAPHB, key: \"id\") - @join__type(graph: SUBGRAPHC, key: \"id\") - @join__directive(graphs: [SUBGRAPHA], name: \"sourceType\", args: {api: \"A\", http: {GET: \"/resources/{id}\"}, selection: \"id description\"}) - @join__directive(graphs: [SUBGRAPHC], name: \"sourceType\", args: {api: \"A\", http: {GET: \"/resources/{id}\"}, selection: \"id creationDate\"}) -{ - id: ID! - description: String! @join__field(graph: SUBGRAPHA) - creationDate: String! @join__field(graph: SUBGRAPHC) -}` - ) - }); - - describe('validation errors', () => { - const goodSchema = gql` - extend schema - @link(url: "https://specs.apollo.dev/federation/v2.7", import: ["@key"]) - @link(url: "https://specs.apollo.dev/source/v0.1", import: [ - "@sourceAPI" - "@sourceType" - "@sourceField" - ]) - @sourceAPI( - name: "A" - http: { baseURL: "https://api.a.com/v1" } - ) - { - query: Query - } - - type Query { - resources: [Resource!]! @sourceField( - api: "A" - http: { GET: "/resources" } - ) - } - - type Resource @key(fields: "id") @sourceType( - api: "A" - http: { GET: "/resources/{id}" } - selection: "id description" - ) { - id: ID! - description: String! - } - `; - - // TODO Test the following errors using badSchema: - // - [x] SOURCE_FEDERATION_VERSION_REQUIRED, - // - [x] SOURCE_API_NAME_INVALID, - // - [x] SOURCE_API_PROTOCOL_INVALID, - // - [x] SOURCE_API_HTTP_BASE_URL_INVALID, - // - [x] SOURCE_HTTP_HEADERS_INVALID, - // - [x] SOURCE_TYPE_API_ERROR, - // - [x] SOURCE_TYPE_PROTOCOL_INVALID, - // - [x] SOURCE_TYPE_HTTP_METHOD_INVALID, - // - [ ] SOURCE_TYPE_HTTP_PATH_INVALID, - // - [ ] SOURCE_TYPE_HTTP_BODY_INVALID, - // - [x] SOURCE_TYPE_ON_NON_OBJECT_OR_NON_ENTITY, - // - [ ] SOURCE_TYPE_SELECTION_INVALID, - // - [x] SOURCE_FIELD_API_ERROR, - // - [ ] SOURCE_FIELD_PROTOCOL_INVALID, - // - [x] SOURCE_FIELD_HTTP_METHOD_INVALID, - // - [ ] SOURCE_FIELD_HTTP_PATH_INVALID, - // - [ ] SOURCE_FIELD_HTTP_BODY_INVALID, - // - [ ] SOURCE_FIELD_SELECTION_INVALID, - // - [x] SOURCE_FIELD_NOT_ON_ROOT_OR_ENTITY_FIELD, - - const badSchema = gql` - extend schema - @link(url: "https://specs.apollo.dev/federation/v2.5", import: ["@key"]) - @link(url: "https://specs.apollo.dev/source/v0.1", import: [ - "@sourceAPI" - "@sourceType" - "@sourceField" - ]) - @sourceAPI( - name: "A?!" # Should be valid GraphQL identifier - http: { baseURL: "https://api.a.com/v1" } - ) - @sourceAPI( - name: "Bogus" - http: { - baseURL: "not a url" - headers: [ - { name: "i n v a l i d", value: "header value", as: "re|named" } - ] - } - ) - @sourceAPI( - name: "NoProtocol" - ) - { - query: Query - } - - type Query { - resources: [Resource!]! @sourceField( - api: "A" - http: { - GET: "/resources" - DELETE: "/resources" - } - ) - } - - type Resource @key(fields: "id") @sourceType( - api: "A" - http: { GET: "/resources/{id}" } - selection: "id description" - ) - @sourceType( - api: "Bogus" - http: { - GET: "/resources/{id}" - POST: "/resources" - } - selection: "id" - ) { - id: ID! - description: String! - } - - type NonEntity @sourceType( - api: "A" - # http: { GET: "/nonentities/{id}" } - selection: "id some_field" - ) { - id: ID! - someField: String! @sourceField( - api: "A" - selection: ".some_field" - ) - } - `; - - it('good schema composes without validation errors', () => { - const result = composeServices([{ - name: 'good', - typeDefs: goodSchema, - }]); - expect(result.errors ?? []).toEqual([]); - }); - - it('bad schema composes with validation errors', () => { - const result = composeServices([{ - name: 'bad', - typeDefs: badSchema, - }]); - - const messages = result.errors!.map(e => e.message); - - expect(messages).toContain( - '[bad] @sourceAPI(name: "A?!") must specify name using only [a-zA-Z0-9-_] characters' - ); - - expect(messages).toContain( - '[bad] @sourceAPI must specify one protocol from the set {http}' - ); - - expect(messages).toContain( - '[bad] @sourceType specifies unknown api A' - ); - - expect(messages).toContain( - '[bad] @sourceField specifies unknown api A' - ); - - try { - new URL('not a url'); - throw new Error('should have thrown'); - } catch (e) { - expect(messages).toContain( - // Different versions of Node.js stringify the URL error differently, - // so we avoid hard-coding that part of the expected error. - `[bad] @sourceAPI http.baseURL \"not a url\" must be valid URL (error: ${e.message})` - ); - } - - expect(messages).toContain( - '[bad] @sourceAPI header {\"name\":\"i n v a l i d\",\"value\":\"header value\",\"as\":\"re|named\"} specifies invalid name' - ); - - expect(messages).toContain( - '[bad] @sourceAPI header {\"name\":\"i n v a l i d\",\"value\":\"header value\",\"as\":\"re|named\"} specifies invalid \'as\' name' - ); - - expect(messages).toContain( - '[bad] @sourceType must specify exactly one of http.GET or http.POST' - ); - - expect(messages).toContain( - '[bad] @sourceField allows at most one of http.{GET,POST,PUT,PATCH,DELETE}' - ); - - expect(messages).toContain( - '[bad] @sourceType must be applied to an entity type that also has a @key directive' - ); - - expect(messages).toContain( - '[bad] @sourceType must specify same http argument as corresponding @sourceAPI for api A' - ); - - expect(messages).toContain( - '[bad] @sourceField must be applied to root Query or Mutation field or field of entity type' - ); - }); - - const renamedSchema = gql` - extend schema - @link(url: "https://specs.apollo.dev/federation/v2.7", import: ["@key"]) - @link(url: "https://specs.apollo.dev/source/v0.1", import: [ - { name: "@sourceAPI", as: "@api" } - { name: "@sourceType", as: "@type" } - { name: "@sourceField", as: "@field" } - ]) - @api( - name: "not an identifier" - http: { baseURL: "https://api.a.com/v1" } - ) - { - query: Query - } - - type Query { - resources: [Resource!]! @field( - api: "not an identifier" - http: { GET: "/resources" } - ) - } - type Resource @key(fields: "id") @type( - api: "not an identifier" - http: { GET: "/resources/{id}" } - selection: "id description" - ) { - id: ID! - description: String! - } - `; - - it('can handle the @source* directives being renamed', () => { - const result = composeServices([{ - name: 'renamed', - typeDefs: renamedSchema, - }]); + it('fed-354 repro @interfaceObject failure', () => { + const subgraph1 = { + name: 'Subgraph1', + url: 'https://Subgraph1', + typeDefs: gql` + type Query { + error_query: TicketField! + } + + type User @interfaceObject @key(fields: "id") { + id: ID! + } + + interface TicketField { + id: ID! + createdBy: User + } + + type TextTicketField implements TicketField @key(fields: "id") @shareable { + id: ID! + createdBy: User + } + ` + }; - const messages = result.errors!.map(e => e.message); + const subgraph2 = { + name: 'Subgraph2', + url: 'https://Subgraph2', + typeDefs: gql` + interface Ticket @key(fields : "id", resolvable : true) { + id: ID! + } + + interface User @key(fields : "id", resolvable : true) { + id: ID! + requestedTickets: [Ticket!]! + } + + interface TicketField { + createdBy: User + id: ID! + } + + type TextTicketField implements TicketField @shareable { + createdBy: User + id: ID! + } + + type Customer implements User @key(fields : "id", resolvable : true) @shareable { + id: ID! + requestedTickets: [Ticket!]! + } + + type Agent implements User @key(fields : "id", resolvable : true) @shareable { + id: ID! + requestedTickets: [Ticket!]! + } + + type Question implements Ticket @key(fields : "id", resolvable : true) { + fields: [TicketField!]! + id: ID! + } + ` + }; - expect(messages).toContain( - '[renamed] @api(name: "not an identifier") must specify name using only [a-zA-Z0-9-_] characters' - ); + const result = composeAsFed2Subgraphs([subgraph1, subgraph2]); + assertCompositionSuccess(result); }); - }); - - it('fed-354 repro @interfaceObject failure', () => { - const subgraph1 = { - name: 'Subgraph1', - url: 'https://Subgraph1', - typeDefs: gql` - type Query { - error_query: TicketField! - } - - type User @interfaceObject @key(fields: "id") { - id: ID! - } - - interface TicketField { - id: ID! - createdBy: User - } - - type TextTicketField implements TicketField @key(fields: "id") @shareable { - id: ID! - createdBy: User - } - ` - }; - - const subgraph2 = { - name: 'Subgraph2', - url: 'https://Subgraph2', - typeDefs: gql` - interface Ticket @key(fields : "id", resolvable : true) { - id: ID! - } - - interface User @key(fields : "id", resolvable : true) { - id: ID! - requestedTickets: [Ticket!]! - } - - interface TicketField { - createdBy: User - id: ID! - } - - type TextTicketField implements TicketField @shareable { - createdBy: User - id: ID! - } - - type Customer implements User @key(fields : "id", resolvable : true) @shareable { - id: ID! - requestedTickets: [Ticket!]! - } - - type Agent implements User @key(fields : "id", resolvable : true) @shareable { - id: ID! - requestedTickets: [Ticket!]! - } - - type Question implements Ticket @key(fields : "id", resolvable : true) { - fields: [TicketField!]! - id: ID! - } - ` - }; - - const result = composeAsFed2Subgraphs([subgraph1, subgraph2]); - assertCompositionSuccess(result); - }); }); diff --git a/composition-js/src/__tests__/connectors.test.ts b/composition-js/src/__tests__/connectors.test.ts new file mode 100644 index 000000000..353b95c01 --- /dev/null +++ b/composition-js/src/__tests__/connectors.test.ts @@ -0,0 +1,321 @@ +import { composeServices } from "../compose"; +import { printSchema } from "@apollo/federation-internals"; +import { parse } from "graphql/index"; + +describe("connect spec and join__directive", () => { + it("composes", () => { + const subgraphs = [ + { + name: "with-connectors", + typeDefs: parse(` + extend schema + @link( + url: "https://specs.apollo.dev/federation/v2.10" + import: ["@key"] + ) + @link( + url: "https://specs.apollo.dev/connect/v0.1" + import: ["@connect", "@source"] + ) + @source(name: "v1", http: { baseURL: "http://v1" }) + + type Query { + resources: [Resource!]! + @connect(source: "v1", http: { GET: "/resources" }, selection: "") + } + + type Resource @key(fields: "id") { + id: ID! + name: String! + } + `), + }, + ]; + + const result = composeServices(subgraphs); + expect(result.errors ?? []).toEqual([]); + const printed = printSchema(result.schema!); + expect(printed).toMatchInlineSnapshot(` + "schema + @link(url: \\"https://specs.apollo.dev/link/v1.0\\") + @link(url: \\"https://specs.apollo.dev/join/v0.5\\", for: EXECUTION) + @link(url: \\"https://specs.apollo.dev/connect/v0.1\\", 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, contextArguments: [join__ContextArgument!]) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION + + directive @join__implements(graph: join__Graph!, interface: String!) repeatable on OBJECT | INTERFACE + + directive @join__unionMember(graph: join__Graph!, member: String!) repeatable on UNION + + directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE + + directive @join__directive(graphs: [join__Graph!], name: String!, args: join__DirectiveArguments) repeatable on SCHEMA | OBJECT | INTERFACE | FIELD_DEFINITION + + enum link__Purpose { + \\"\\"\\" + \`SECURITY\` features provide metadata necessary to securely resolve fields. + \\"\\"\\" + SECURITY + + \\"\\"\\" + \`EXECUTION\` features provide metadata necessary for operation execution. + \\"\\"\\" + EXECUTION + } + + scalar link__Import + + enum join__Graph { + WITH_CONNECTORS @join__graph(name: \\"with-connectors\\", url: \\"\\") + } + + scalar join__FieldSet + + scalar join__DirectiveArguments + + scalar join__FieldValue + + input join__ContextArgument { + name: String! + type: String! + context: String! + selection: join__FieldValue! + } + + type Query + @join__type(graph: WITH_CONNECTORS) + { + resources: [Resource!]! @join__directive(graphs: [WITH_CONNECTORS], name: \\"connect\\", args: {source: \\"v1\\", http: {GET: \\"/resources\\"}, selection: \\"\\"}) + } + + type Resource + @join__type(graph: WITH_CONNECTORS, key: \\"id\\") + { + id: ID! + name: String! + }" + `); + + if (result.schema) { + expect(printSchema(result.schema.toAPISchema())).toMatchInlineSnapshot(` + "type Query { + resources: [Resource!]! + } + + type Resource { + id: ID! + name: String! + }" + `); + } + }); + + it("composes with renames", () => { + const subgraphs = [ + { + name: "with-connectors", + typeDefs: parse(` + extend schema + @link( + url: "https://specs.apollo.dev/federation/v2.10" + import: ["@key"] + ) + @link( + url: "https://specs.apollo.dev/connect/v0.1" + as: "http" + import: [ + { name: "@connect", as: "@http" } + { name: "@source", as: "@api" } + ] + ) + @api(name: "v1", http: { baseURL: "http://v1" }) + + type Query { + resources: [Resource!]! + @http(source: "v1", http: { GET: "/resources" }, selection: "") + } + + type Resource @key(fields: "id") { + id: ID! + name: String! + } + `), + }, + ]; + + const result = composeServices(subgraphs); + expect(result.errors ?? []).toEqual([]); + const printed = printSchema(result.schema!); + expect(printed).toMatchInlineSnapshot(` + "schema + @link(url: \\"https://specs.apollo.dev/link/v1.0\\") + @link(url: \\"https://specs.apollo.dev/join/v0.5\\", for: EXECUTION) + @link(url: \\"https://specs.apollo.dev/connect/v0.1\\", for: EXECUTION) + @join__directive(graphs: [WITH_CONNECTORS], name: \\"link\\", args: {url: \\"https://specs.apollo.dev/connect/v0.1\\", as: \\"http\\", import: [{name: \\"@connect\\", as: \\"@http\\"}, {name: \\"@source\\", as: \\"@api\\"}]}) + @join__directive(graphs: [WITH_CONNECTORS], name: \\"api\\", args: {name: \\"v1\\", http: {baseURL: \\"http://v1\\"}}) + { + query: Query + } + + directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA + + directive @join__graph(name: String!, url: String!) on ENUM_VALUE + + directive @join__type(graph: join__Graph!, key: join__FieldSet, extension: Boolean! = false, resolvable: Boolean! = true, isInterfaceObject: Boolean! = false) repeatable on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT | SCALAR + + directive @join__field(graph: join__Graph, requires: join__FieldSet, provides: join__FieldSet, type: String, external: Boolean, override: String, usedOverridden: Boolean, overrideLabel: String, contextArguments: [join__ContextArgument!]) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION + + directive @join__implements(graph: join__Graph!, interface: String!) repeatable on OBJECT | INTERFACE + + directive @join__unionMember(graph: join__Graph!, member: String!) repeatable on UNION + + directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE + + directive @join__directive(graphs: [join__Graph!], name: String!, args: join__DirectiveArguments) repeatable on SCHEMA | OBJECT | INTERFACE | FIELD_DEFINITION + + enum link__Purpose { + \\"\\"\\" + \`SECURITY\` features provide metadata necessary to securely resolve fields. + \\"\\"\\" + SECURITY + + \\"\\"\\" + \`EXECUTION\` features provide metadata necessary for operation execution. + \\"\\"\\" + EXECUTION + } + + scalar link__Import + + enum join__Graph { + WITH_CONNECTORS @join__graph(name: \\"with-connectors\\", url: \\"\\") + } + + scalar join__FieldSet + + scalar join__DirectiveArguments + + scalar join__FieldValue + + input join__ContextArgument { + name: String! + type: String! + context: String! + selection: join__FieldValue! + } + + type Query + @join__type(graph: WITH_CONNECTORS) + { + resources: [Resource!]! @join__directive(graphs: [WITH_CONNECTORS], name: \\"http\\", args: {source: \\"v1\\", http: {GET: \\"/resources\\"}, selection: \\"\\"}) + } + + type Resource + @join__type(graph: WITH_CONNECTORS, key: \\"id\\") + { + id: ID! + name: String! + }" + `); + + if (result.schema) { + expect(printSchema(result.schema.toAPISchema())).toMatchInlineSnapshot(` + "type Query { + resources: [Resource!]! + } + + type Resource { + id: ID! + name: String! + }" + `); + } + }); + + it("requires the http arg for @source", () => { + const subgraphs = [ + { + name: "with-connectors", + typeDefs: parse(` + extend schema + @link( + url: "https://specs.apollo.dev/federation/v2.10" + import: ["@key"] + ) + @link( + url: "https://specs.apollo.dev/connect/v0.1" + import: ["@connect", "@source"] + ) + @source(name: "v1") + + type Query { + resources: [Resource!]! + @connect(source: "v1", http: { GET: "/resources" }, selection: "") + } + + type Resource { + id: ID! + name: String! + } + `), + }, + ]; + + const result = composeServices(subgraphs); + expect(result.errors?.length).toBe(1); + const error = result.errors![0]; + expect(error.message).toEqual( + '[with-connectors] Directive "@source" argument "http" of type "connect__SourceHTTP!" is required, but it was not provided.' + ); + expect(error.extensions.code).toEqual("INVALID_GRAPHQL"); + }); + + it("requires the http arg for @connect", () => { + const subgraphs = [ + { + name: "with-connectors", + typeDefs: parse(` + extend schema + @link( + url: "https://specs.apollo.dev/federation/v2.10" + import: ["@key"] + ) + @link( + url: "https://specs.apollo.dev/connect/v0.1" + import: ["@connect", "@source"] + ) + @source(name: "v1", http: {baseURL: "http://127.0.0.1"}) + + type Query { + resources: [Resource!]! + @connect(source: "v1", selection: "") + } + + type Resource { + id: ID! + name: String! + } + `), + }, + ]; + + const result = composeServices(subgraphs); + expect(result.errors?.length).toBe(1); + const error = result.errors![0]; + expect(error.message).toEqual( + '[with-connectors] Directive "@connect" argument "http" of type "connect__ConnectHTTP!" is required, but it was not provided.' + ); + expect(error.extensions.code).toEqual("INVALID_GRAPHQL"); + }); +}); diff --git a/composition-js/src/__tests__/hints.test.ts b/composition-js/src/__tests__/hints.test.ts index 967efc06e..ddfa57e56 100644 --- a/composition-js/src/__tests__/hints.test.ts +++ b/composition-js/src/__tests__/hints.test.ts @@ -1303,31 +1303,21 @@ describe('when a directive causes an implicit federation version upgrade', () => const autoUpgradedSchema = gql` extend schema @link(url: "https://specs.apollo.dev/federation/v2.5", import: ["@key", "@shareable"]) - @link(url: "https://specs.apollo.dev/source/v0.1", import: [ - "@sourceAPI" - "@sourceType" - "@sourceField" - ]) - @sourceAPI( - name: "A" - http: { baseURL: "https://api.a.com/v1" } + @link( + url: "https://specs.apollo.dev/connect/v0.1" + import: ["@connect", "@source"] ) - { - query: Query - } + @source(name: "v1", http: { baseURL: "http://v1" }) type Query @shareable { - resources: [Resource!]! @sourceField( - api: "A" + resources: [Resource!]! @connect( + source: "v1" http: { GET: "/resources" } + selection: "" ) } - type Resource @shareable @key(fields: "id") @sourceType( - api: "A" - http: { GET: "/resources/{id}" } - selection: "id description" - ) { + type Resource @shareable @key(fields: "id") { id: ID! description: String! } @@ -1352,7 +1342,7 @@ describe('when a directive causes an implicit federation version upgrade', () => assertCompositionSuccess(result); expect(result).toRaiseHint( HINTS.IMPLICITLY_UPGRADED_FEDERATION_VERSION, - 'Subgraph upgraded has been implicitly upgraded from federation v2.5 to v2.7', + 'Subgraph upgraded has been implicitly upgraded from federation v2.5 to v2.10', '@link' ); }); @@ -1372,12 +1362,12 @@ describe('when a directive causes an implicit federation version upgrade', () => assertCompositionSuccess(result); expect(result).toRaiseHint( HINTS.IMPLICITLY_UPGRADED_FEDERATION_VERSION, - 'Subgraph upgraded-1 has been implicitly upgraded from federation v2.5 to v2.7', + 'Subgraph upgraded-1 has been implicitly upgraded from federation v2.5 to v2.10', '@link' ); expect(result).toRaiseHint( HINTS.IMPLICITLY_UPGRADED_FEDERATION_VERSION, - 'Subgraph upgraded-2 has been implicitly upgraded from federation v2.5 to v2.7', + 'Subgraph upgraded-2 has been implicitly upgraded from federation v2.5 to v2.10', '@link' ); }); diff --git a/composition-js/src/compose.ts b/composition-js/src/compose.ts index 8b17c4aea..3ec5711fc 100644 --- a/composition-js/src/compose.ts +++ b/composition-js/src/compose.ts @@ -50,7 +50,7 @@ function validateCompositionOptions(options: CompositionOptions) { /** * Used to compose a supergraph from subgraphs * `options.runSatisfiability` will default to `true` - * + * * @param subgraphs Subgraphs * @param options CompositionOptions */ @@ -94,7 +94,7 @@ export function compose(subgraphs: Subgraphs, options: CompositionOptions = {}): /** * Method to validate and compose services - * + * * @param services List of Service definitions * @param options CompositionOptions * @returns CompositionResult @@ -118,9 +118,9 @@ type SatisfiabilityArgs = { /** * Run satisfiability check for a supergraph - * + * * Can pass either the supergraph's Schema or SDL to validate - * @param args: SatisfiabilityArgs + * @param args: SatisfiabilityArgs * @returns { errors? : GraphQLError[], hints? : CompositionHint[] } */ export function validateSatisfiability({ supergraphSchema, supergraphSdl} : SatisfiabilityArgs) : { @@ -130,7 +130,7 @@ export function validateSatisfiability({ supergraphSchema, supergraphSdl} : Sati // We pass `null` for the `supportedFeatures` to disable the feature support validation. Validating feature support // is useful when executing/handling a supergraph, but here we're just validating the supergraph we've just created, // and there is no reason to error due to an unsupported feature. - const supergraph = supergraphSchema ? new Supergraph(supergraphSchema, null) : Supergraph.build(supergraphSdl); + const supergraph = supergraphSchema ? new Supergraph(supergraphSchema, null) : Supergraph.build(supergraphSdl, { supportedFeatures: null }); const supergraphQueryGraph = buildSupergraphAPIQueryGraph(supergraph); const federatedQueryGraph = buildFederatedQueryGraph(supergraph, false); return validateGraphComposition(supergraph.schema, supergraph.subgraphNameToGraphEnumValue(), supergraphQueryGraph, federatedQueryGraph); @@ -140,8 +140,8 @@ type ValidateSubgraphsAndMergeResult = MergeResult | { errors: GraphQLError[] }; /** * Upgrade subgraphs if necessary, then validates subgraphs before attempting to merge - * - * @param subgraphs + * + * @param subgraphs * @returns ValidateSubgraphsAndMergeResult */ function validateSubgraphsAndMerge(subgraphs: Subgraphs) : ValidateSubgraphsAndMergeResult { diff --git a/composition-js/src/merging/merge.ts b/composition-js/src/merging/merge.ts index b967065b7..3c397a9b2 100644 --- a/composition-js/src/merging/merge.ts +++ b/composition-js/src/merging/merge.ts @@ -69,7 +69,7 @@ import { FeatureVersion, FEDERATION_VERSIONS, LinkDirectiveArgs, - sourceIdentity, + connectIdentity, FeatureUrl, isFederationDirectiveDefinedInSchema, parseContext, @@ -79,8 +79,9 @@ import { isNullableType, isFieldDefinition, Post20FederationDirectiveDefinition, - DirectiveCompositionSpecification, + coreFeatureDefinitionIfKnown, FeatureDefinition, + DirectiveCompositionSpecification, CoreImport, inaccessibleIdentity, } from "@apollo/federation-internals"; @@ -253,7 +254,7 @@ function copyTypeReference(source: Type, dest: Schema): Type { } } -const NON_MERGED_CORE_FEATURES = [ federationIdentity, linkIdentity, coreIdentity ]; +const NON_MERGED_CORE_FEATURES = [ federationIdentity, linkIdentity, coreIdentity, connectIdentity ]; function isMergedType(type: NamedType): boolean { if (type.isIntrospectionType() || FEDERATION_OPERATION_TYPES.map((s) => s.name).includes(type.name)) { @@ -386,7 +387,7 @@ class Merger { this.linkSpec = LINK_VERSIONS.getMinimumRequiredVersion(this.latestFedVersionUsed); this.fieldsWithFromContext = this.getFieldsWithFromContextDirective(); this.fieldsWithOverride = this.getFieldsWithOverrideDirective(); - + this.names = subgraphs.names(); this.composeDirectiveManager = new ComposeDirectiveManager( this.subgraphs, @@ -414,7 +415,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)); } @@ -441,7 +442,7 @@ class Merger { } } const impliedFederationVersion = FeatureVersion.max(versionsFromFeatures); - if (!impliedFederationVersion?.satisfies(linkedFederationVersion) || linkedFederationVersion >= impliedFederationVersion) { + if (!impliedFederationVersion?.satisfies(linkedFederationVersion) || linkedFederationVersion.gte(impliedFederationVersion)) { return linkedFederationVersion; } @@ -2983,13 +2984,13 @@ class Merger { } const directiveInSupergraph = this.mergedFederationDirectiveInSupergraphByDirectiveName.get(name); - + if (dest.schema().directive(name)?.repeatable) { // For repeatable directives, we simply include each application found but with exact duplicates removed while (perSource.length > 0) { const directive = perSource[0].directives[0]; const subgraphIndex = perSource[0].subgraphIndex; - + const transformedArgs = directiveInSupergraph && directiveInSupergraph.staticArgumentTransform && directiveInSupergraph.staticArgumentTransform(this.subgraphs.values()[subgraphIndex], directive.arguments(false)); dest.applyDirective(directive.name, transformedArgs ?? directive.arguments(false)); // We remove every instances of this particular application. That is we remove any other applicaiton with @@ -3145,6 +3146,7 @@ class Merger { args: Record; }> } = Object.create(null); + const linksToPersist = new Set(); for (const [idx, source] of sources.entries()) { if (!source) continue; @@ -3165,7 +3167,15 @@ class Merger { if (typeof url === 'string' && parsedUrl) { shouldIncludeAsJoinDirective = this.shouldUseJoinDirectiveForURL(parsedUrl); + + if (shouldIncludeAsJoinDirective) { + const featureDefinition = coreFeatureDefinitionIfKnown(parsedUrl); + if (featureDefinition) { + linksToPersist.add(featureDefinition); + } + } } + } else { // To be consistent with other code accessing // linkImportIdentityURLMap, we ensure directive names start with a @@ -3197,6 +3207,15 @@ class Merger { } } + const linkDirective = this.linkSpec.coreDirective(this.merged); + for (const link of linksToPersist) { + dest.applyDirective(linkDirective, { + url: link.toString(), + for: link.defaultCorePurpose, + feature: undefined + }); + } + const joinDirective = this.joinSpec.directiveDirective(this.merged); Object.keys(joinsByDirectiveName).forEach(directiveName => { joinsByDirectiveName[directiveName].forEach(join => { @@ -3519,29 +3538,29 @@ class Merger { )); } } - + private getFieldsWithFromContextDirective(): Set { return this.getFieldsWithAppliedDirective( (subgraph: Subgraph) => subgraph.metadata().fromContextDirective(), (application: Directive>) => { const field = application.parent.parent; - assert(isFieldDefinition(field), () => `Expected ${application.parent} to be a field`); + assert(isFieldDefinition(field), () => `Expected ${application.parent} to be a field`); return field; }, ); } - + private getFieldsWithOverrideDirective(): Set { return this.getFieldsWithAppliedDirective( (subgraph: Subgraph) => subgraph.metadata().overrideDirective(), (application: Directive>) => { const field = application.parent; - assert(isFieldDefinition(field), () => `Expected ${application.parent} to be a field`); + assert(isFieldDefinition(field), () => `Expected ${application.parent} to be a field`); return field; } ); } - + private getFieldsWithAppliedDirective( getDirective: (subgraph: Subgraph) => Post20FederationDirectiveDefinition, getField: (application: Directive>) => FieldDefinition, diff --git a/federation-integration-testsuite-js/package.json b/federation-integration-testsuite-js/package.json index 75b817824..44b7b6c9b 100644 --- a/federation-integration-testsuite-js/package.json +++ b/federation-integration-testsuite-js/package.json @@ -1,7 +1,7 @@ { "name": "apollo-federation-integration-testsuite", "private": true, - "version": "2.9.3", + "version": "2.10.0-alpha.4", "description": "Apollo Federation Integrations / Test Fixtures", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/gateway-js/package.json b/gateway-js/package.json index 262209797..23848e502 100644 --- a/gateway-js/package.json +++ b/gateway-js/package.json @@ -1,6 +1,6 @@ { "name": "@apollo/gateway", - "version": "2.9.3", + "version": "2.10.0-alpha.4", "description": "Apollo Gateway", "author": "Apollo ", "main": "dist/index.js", @@ -25,9 +25,9 @@ "access": "public" }, "dependencies": { - "@apollo/composition": "2.9.3", - "@apollo/federation-internals": "2.9.3", - "@apollo/query-planner": "2.9.3", + "@apollo/composition": "2.10.0-alpha.4", + "@apollo/federation-internals": "2.10.0-alpha.4", + "@apollo/query-planner": "2.10.0-alpha.4", "@apollo/server-gateway-interface": "^1.1.0", "@apollo/usage-reporting-protobuf": "^4.1.0", "@apollo/utils.createhash": "^2.0.0", diff --git a/gateway-js/src/__generated__/graphqlTypes.ts b/gateway-js/src/__generated__/graphqlTypes.ts index b6055a227..6bcc9114d 100644 --- a/gateway-js/src/__generated__/graphqlTypes.ts +++ b/gateway-js/src/__generated__/graphqlTypes.ts @@ -10,6 +10,7 @@ export type Scalars = { Boolean: boolean; Int: number; Float: number; + Long: any; Timestamp: any; }; @@ -98,6 +99,12 @@ export type QueryRouterEntitlementsArgs = { ref: Scalars['String']; }; +export type RateLimit = { + __typename?: 'RateLimit'; + count: Scalars['Long']; + durationMs: Scalars['Long']; +}; + export type RouterConfigResponse = FetchError | RouterConfigResult | Unchanged; export type RouterConfigResult = { @@ -121,6 +128,8 @@ export type RouterEntitlement = { /** RFC 8037 Ed25519 JWT signed representation of sibling fields. */ jwt: Scalars['String']; subject: Scalars['String']; + /** Router should service requests only till the throughput limits specified in this map. */ + throughputLimit?: Maybe; /** Router should warn users after this time if commercial features are in use. */ warnAt?: Maybe; }; diff --git a/internals-js/package.json b/internals-js/package.json index 93e1b973a..20cff0326 100644 --- a/internals-js/package.json +++ b/internals-js/package.json @@ -1,6 +1,6 @@ { "name": "@apollo/federation-internals", - "version": "2.9.3", + "version": "2.10.0-alpha.4", "description": "Apollo Federation internal utilities", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/internals-js/src/__tests__/schemaUpgrader.test.ts b/internals-js/src/__tests__/schemaUpgrader.test.ts index 47bed9958..02bca30dc 100644 --- a/internals-js/src/__tests__/schemaUpgrader.test.ts +++ b/internals-js/src/__tests__/schemaUpgrader.test.ts @@ -310,14 +310,14 @@ test('handles the addition of @shareable when an @external is used on a type', ( // 1. the @external on type `T` in s2 should be removed, as @external on types were no-ops in fed1 (but not in fed2 anymore, hence the removal) // 2. field `T.x` in s1 must be marked @shareable since it is resolved by s2 (since again, it's @external annotation is ignored). - const s2Upgraded = res.subgraphs?.get('s2')!; - expect(s2Upgraded.schema.type('T')?.hasAppliedDirective('external')).toBe( + const s2Upgraded = res.subgraphs?.get('s2'); + expect(s2Upgraded?.schema.type('T')?.hasAppliedDirective('external')).toBe( false, ); - const s1Upgraded = res.subgraphs?.get('s1')!; + const s1Upgraded = res.subgraphs?.get('s1'); expect( - (s1Upgraded.schema.type('T') as ObjectType) + (s1Upgraded?.schema.type('T') as ObjectType) .field('x') ?.hasAppliedDirective('shareable'), ).toBe(true); @@ -372,8 +372,8 @@ test("don't add @shareable to subscriptions", () => { type Query { hello: String } - - type Subscription { + + type Subscription { update: String! } `, @@ -386,8 +386,8 @@ test("don't add @shareable to subscriptions", () => { type Query { hello: String } - - type Subscription { + + type Subscription { update: String! } `, diff --git a/internals-js/src/error.ts b/internals-js/src/error.ts index 79b6a1f53..f658b2acc 100644 --- a/internals-js/src/error.ts +++ b/internals-js/src/error.ts @@ -591,123 +591,10 @@ const INTERFACE_KEY_MISSING_IMPLEMENTATION_TYPE = makeCodeDefinition( { addedIn: '2.3.0' }, ) -const SOURCE_FEDERATION_VERSION_REQUIRED = makeCodeDefinition( - 'SOURCE_FEDERATION_VERSION_REQUIRED', - 'Schemas using `@source{API,Type,Field}` directives must @link-import v2.7 or later of federation', - { addedIn: '2.7.1' }, -); - -const SOURCE_API_NAME_INVALID = makeCodeDefinition( - 'SOURCE_API_NAME_INVALID', - 'Each `@sourceAPI` directive must take a unique and valid name as an argument', - { addedIn: '2.7.0' }, -); - -const SOURCE_API_PROTOCOL_INVALID = makeCodeDefinition( - 'SOURCE_API_PROTOCOL_INVALID', - 'Each `@sourceAPI` directive must specify exactly one of the known protocols', - { addedIn: '2.7.0' }, -); - -const SOURCE_API_HTTP_BASE_URL_INVALID = makeCodeDefinition( - 'SOURCE_API_HTTP_BASE_URL_INVALID', - 'The `@sourceAPI` directive must specify a valid http.baseURL', - { addedIn: '2.7.0' }, -); - -const SOURCE_HTTP_HEADERS_INVALID = makeCodeDefinition( - 'SOURCE_HTTP_HEADERS_INVALID', - 'The `http.headers` argument of `@source*` directives must specify valid HTTP headers', - { addedIn: '2.7.0' }, -); - -const SOURCE_TYPE_API_ERROR = makeCodeDefinition( - 'SOURCE_TYPE_API_ERROR', - 'The `api` argument of the `@sourceType` directive must match a valid `@sourceAPI` name', - { addedIn: '2.7.0' }, -); - -const SOURCE_TYPE_PROTOCOL_INVALID = makeCodeDefinition( - 'SOURCE_TYPE_PROTOCOL_INVALID', - 'The `@sourceType` directive must specify the same protocol as its corresponding `@sourceAPI`', - { addedIn: '2.7.0' }, -); - -const SOURCE_TYPE_HTTP_METHOD_INVALID = makeCodeDefinition( - 'SOURCE_TYPE_HTTP_METHOD_INVALID', - 'The `@sourceType` directive must specify exactly one of `http.GET` or `http.POST`', - { addedIn: '2.7.0' }, -); - -const SOURCE_TYPE_HTTP_PATH_INVALID = makeCodeDefinition( - 'SOURCE_TYPE_HTTP_PATH_INVALID', - 'The `@sourceType` directive must specify a valid URL template for `http.GET` or `http.POST`', - { addedIn: '2.7.0' }, -); - -const SOURCE_TYPE_HTTP_BODY_INVALID = makeCodeDefinition( - 'SOURCE_TYPE_HTTP_BODY_INVALID', - 'If the `@sourceType` specifies `http.body`, it must be a valid `JSONSelection`', - { addedIn: '2.7.0' }, -); - -const SOURCE_TYPE_ON_NON_OBJECT_OR_NON_ENTITY = makeCodeDefinition( - 'SOURCE_TYPE_ON_NON_OBJECT_OR_NON_ENTITY', - 'The `@sourceType` directive must be applied to an object or interface type that also has `@key`', - { addedIn: '2.7.0' }, -); - -const SOURCE_TYPE_SELECTION_INVALID = makeCodeDefinition( - 'SOURCE_TYPE_SELECTION_INVALID', - 'The `selection` argument of the `@sourceType` directive must be a valid `JSONSelection` that outputs fields of the GraphQL type', -); - -const SOURCE_FIELD_API_ERROR = makeCodeDefinition( - 'SOURCE_FIELD_API_ERROR', - 'The `api` argument of the `@sourceField` directive must match a valid `@sourceAPI` name', - { addedIn: '2.7.0' }, -); - -const SOURCE_FIELD_PROTOCOL_INVALID = makeCodeDefinition( - 'SOURCE_FIELD_PROTOCOL_INVALID', - 'If `@sourceField` specifies a protocol, it must match the corresponding `@sourceAPI` protocol', - { addedIn: '2.7.0' }, -); - -const SOURCE_FIELD_HTTP_METHOD_INVALID = makeCodeDefinition( - 'SOURCE_FIELD_HTTP_METHOD_INVALID', - 'The `@sourceField` directive must specify at most one of `http.{GET,POST,PUT,PATCH,DELETE}`', - { addedIn: '2.7.0' }, -); - -const SOURCE_FIELD_HTTP_PATH_INVALID = makeCodeDefinition( - 'SOURCE_FIELD_HTTP_PATH_INVALID', - 'The `@sourceField` directive must specify a valid URL template for `http.{GET,POST,PUT,PATCH,DELETE}`', - { addedIn: '2.7.0' }, -); - -const SOURCE_FIELD_HTTP_BODY_INVALID = makeCodeDefinition( - 'SOURCE_FIELD_HTTP_BODY_INVALID', - 'If `@sourceField` specifies http.body, it must be a valid `JSONSelection` matching available arguments and fields', - { addedIn: '2.7.0' }, -); - -const SOURCE_FIELD_SELECTION_INVALID = makeCodeDefinition( - 'SOURCE_FIELD_SELECTION_INVALID', - 'The `selection` argument of the `@sourceField` directive must be a valid `JSONSelection` that outputs fields of the GraphQL type', - { addedIn: '2.7.0' }, -); - -const SOURCE_FIELD_NOT_ON_ROOT_OR_ENTITY_FIELD = makeCodeDefinition( - 'SOURCE_FIELD_NOT_ON_ROOT_OR_ENTITY_FIELD', - 'The `@sourceField` directive must be applied to a field of the `Query` or `Mutation` types, or of an entity type', - { addedIn: '2.7.0' }, -); - const CONTEXTUAL_ARGUMENT_NOT_CONTEXTUAL_IN_ALL_SUBGRAPHS = makeCodeDefinition( - 'CONTEXTUAL_ARGUMENT_NOT_CONTEXTUAL_IN_ALL_SUBGRAPHS', - 'Argument on field is marked contextual in only some subgraphs', - { addedIn: '2.7.0' }, + 'CONTEXTUAL_ARGUMENT_NOT_CONTEXTUAL_IN_ALL_SUBGRAPHS', + 'Argument on field is marked contextual in only some subgraphs', + { addedIn: '2.7.0' }, ); const COST_APPLIED_TO_INTERFACE_FIELD = makeCodeDefinition( @@ -833,26 +720,6 @@ export const ERRORS = { INTERFACE_OBJECT_USAGE_ERROR, INTERFACE_KEY_NOT_ON_IMPLEMENTATION, INTERFACE_KEY_MISSING_IMPLEMENTATION_TYPE, - // Errors related to @sourceAPI, @sourceType, and/or @sourceField - SOURCE_FEDERATION_VERSION_REQUIRED, - SOURCE_API_NAME_INVALID, - SOURCE_API_PROTOCOL_INVALID, - SOURCE_API_HTTP_BASE_URL_INVALID, - SOURCE_HTTP_HEADERS_INVALID, - SOURCE_TYPE_API_ERROR, - SOURCE_TYPE_PROTOCOL_INVALID, - SOURCE_TYPE_HTTP_METHOD_INVALID, - SOURCE_TYPE_HTTP_PATH_INVALID, - SOURCE_TYPE_HTTP_BODY_INVALID, - SOURCE_TYPE_ON_NON_OBJECT_OR_NON_ENTITY, - SOURCE_TYPE_SELECTION_INVALID, - SOURCE_FIELD_API_ERROR, - SOURCE_FIELD_PROTOCOL_INVALID, - SOURCE_FIELD_HTTP_METHOD_INVALID, - SOURCE_FIELD_HTTP_PATH_INVALID, - SOURCE_FIELD_HTTP_BODY_INVALID, - SOURCE_FIELD_SELECTION_INVALID, - SOURCE_FIELD_NOT_ON_ROOT_OR_ENTITY_FIELD, CONTEXTUAL_ARGUMENT_NOT_CONTEXTUAL_IN_ALL_SUBGRAPHS, // Errors related to demand control COST_APPLIED_TO_INTERFACE_FIELD, diff --git a/internals-js/src/federation.ts b/internals-js/src/federation.ts index 8faa98082..3e18dd5c4 100644 --- a/internals-js/src/federation.ts +++ b/internals-js/src/federation.ts @@ -95,13 +95,8 @@ import { import { defaultPrintOptions, PrintOptions as PrintOptions, printSchema } from "./print"; import { createObjectTypeSpecification, createScalarTypeSpecification, createUnionTypeSpecification } from "./directiveAndTypeSpecification"; import { didYouMean, suggestionList } from "./suggestions"; -import { coreFeatureDefinitionIfKnown, validateKnownFeatures } from "./knownCoreFeatures"; +import { coreFeatureDefinitionIfKnown } from "./knownCoreFeatures"; import { joinIdentity } from "./specs/joinSpec"; -import { - SourceAPIDirectiveArgs, - SourceFieldDirectiveArgs, - SourceTypeDirectiveArgs, -} from "./specs/sourceSpec"; import { COST_VERSIONS, CostDirectiveArguments, ListSizeDirectiveArguments, costIdentity } from "./specs/costSpec"; const linkSpec = LINK_VERSIONS.latest(); @@ -400,10 +395,10 @@ const validateFieldValueType = ({ fromContextParent: ArgumentDefinition>, }): { resolvedType: InputType | undefined } => { const selections = selectionSet.selections(); - + // ensure that type is not an interfaceObject const interfaceObjectDirective = metadata.interfaceObjectDirective(); - if (currentType.kind === 'ObjectType' && isFederationDirectiveDefinedInSchema(interfaceObjectDirective) && (currentType.appliedDirectivesOf(interfaceObjectDirective).length > 0)) { + if (currentType.kind === 'ObjectType' && isFederationDirectiveDefinedInSchema(interfaceObjectDirective) && (currentType.appliedDirectivesOf(interfaceObjectDirective).length > 0)) { errorCollector.push(ERRORS.CONTEXT_INVALID_SELECTION.err( `Context "is used in "${fromContextParent.coordinate}" but the selection is invalid: One of the types in the selection is an interfaceObject: "${currentType.name}"`, { nodes: sourceASTs(fromContextParent) } @@ -597,7 +592,7 @@ function validateFieldValue({ if (selectionType === 'error') { return; } - + const usedTypeConditions = new Set; for (const location of setContextLocations) { // for each location, we need to validate that the selection will result in exactly one field being selected @@ -624,7 +619,7 @@ function validateFieldValue({ { nodes: sourceASTs(fromContextParent) } )); } - + if (selectionType === 'field') { const { resolvedType } = validateFieldValueType({ currentType: location, @@ -797,13 +792,13 @@ export function collectUsedFields(metadata: FederationMetadata): Set( metadata, usedFields, ); - + // Collects all fields used to satisfy an interface constraint for (const itfType of metadata.schema.interfaceTypes()) { const runtimeTypes = itfType.possibleRuntimeTypes(); @@ -826,12 +821,12 @@ function collectUsedFieldsForFromContext ) { const fromContextDirective = metadata.fromContextDirective(); const contextDirective = metadata.contextDirective(); - + // if one of the directives is not defined, there's nothing to validate if (!isFederationDirectiveDefinedInSchema(fromContextDirective) || !isFederationDirectiveDefinedInSchema(contextDirective)) { - return; + return; } - + // build the list of context entry points const entryPoints = new Map>(); for (const application of contextDirective.applications()) { @@ -844,9 +839,9 @@ function collectUsedFieldsForFromContext if (!entryPoints.has(context)) { entryPoints.set(context, new Set()); } - entryPoints.get(context)!.add(type as CompositeType); + entryPoints.get(context)!.add(type as CompositeType); } - + for (const application of fromContextDirective.applications()) { const type = application.parent as TParent; if (!type) { @@ -856,20 +851,20 @@ function collectUsedFieldsForFromContext const fieldValue = application.arguments().field; const { context, selection } = parseContext(fieldValue); - + if (!context) { continue; } - + // now we need to collect all the fields used for every type that they could be used for const contextTypes = entryPoints.get(context); if (!contextTypes) { continue; } - + for (const contextType of contextTypes) { try { - // helper function + // helper function const fieldAccessor = (t: CompositeType, f: string) => { const field = t.field(f); if (field) { @@ -885,7 +880,7 @@ function collectUsedFieldsForFromContext } return field; }; - + parseSelectionSet({ parentType: contextType, source: selection, fieldAccessor }); } catch (e) { // ignore the error, it will be caught later @@ -1091,7 +1086,7 @@ function validateAssumedSizeNotNegative( ) { const { assumedSize } = application.arguments(); // Validate assumed size, but we differ from https://ibm.github.io/graphql-specs/cost-spec.html#sec-Valid-Assumed-Size. - // Assumed size is used as a backup for slicing arguments in the event they are both specified. + // Assumed size is used as a backup for slicing arguments in the event they are both specified. // The spec aims to rule out cases when the assumed size will never be used because there is always // a slicing argument. Two applications which are compliant with that validation rule can be merged // into an application which is not compliant, thus we need to handle this case gracefully at runtime regardless. @@ -1375,18 +1370,6 @@ export class FederationMetadata { return this.getPost20FederationDirective(FederationDirectiveName.POLICY); } - sourceAPIDirective(): Post20FederationDirectiveDefinition { - return this.getPost20FederationDirective(FederationDirectiveName.SOURCE_API); - } - - sourceTypeDirective(): Post20FederationDirectiveDefinition { - return this.getPost20FederationDirective(FederationDirectiveName.SOURCE_TYPE); - } - - sourceFieldDirective(): Post20FederationDirectiveDefinition { - return this.getPost20FederationDirective(FederationDirectiveName.SOURCE_FIELD); - } - fromContextDirective(): Post20FederationDirectiveDefinition<{ field: string }> { return this.getPost20FederationDirective(FederationDirectiveName.FROM_CONTEXT); } @@ -1443,19 +1426,6 @@ export class FederationMetadata { baseDirectives.push(policyDirective); } - const sourceAPIDirective = this.sourceAPIDirective(); - if (isFederationDirectiveDefinedInSchema(sourceAPIDirective)) { - baseDirectives.push(sourceAPIDirective); - } - const sourceTypeDirective = this.sourceTypeDirective(); - if (isFederationDirectiveDefinedInSchema(sourceTypeDirective)) { - baseDirectives.push(sourceTypeDirective); - } - const sourceFieldDirective = this.sourceFieldDirective(); - if (isFederationDirectiveDefinedInSchema(sourceFieldDirective)) { - baseDirectives.push(sourceFieldDirective); - } - const contextDirective = this.contextDirective(); if (isFederationDirectiveDefinedInSchema(contextDirective)) { baseDirectives.push(contextDirective); @@ -1714,7 +1684,6 @@ export class FederationBlueprint extends SchemaBlueprint { for (const application of contextDirective.applications()) { const parent = application.parent; const name = application.arguments().name as string; - const match = name.match(/^([A-Za-z]\w*)$/); if (name.includes('_')) { errorCollector.push(ERRORS.CONTEXT_NAME_INVALID.err( @@ -1740,7 +1709,7 @@ export class FederationBlueprint extends SchemaBlueprint { for (const application of fromContextDirective.applications()) { const { field } = application.arguments(); const { context, selection } = parseContext(field); - + // error if parent's parent is a directive definition if (application.parent.parent.kind === 'DirectiveDefinition') { errorCollector.push(ERRORS.CONTEXT_NOT_SET.err( @@ -1772,14 +1741,14 @@ export class FederationBlueprint extends SchemaBlueprint { )); } } - + if (parent.defaultValue !== undefined) { errorCollector.push(ERRORS.CONTEXT_NOT_SET.err( `@fromContext arguments may not have a default value: "${parent.coordinate}".`, { nodes: sourceASTs(application) } - )); + )); } - + if (!context || !selection) { errorCollector.push(ERRORS.NO_CONTEXT_IN_SELECTION.err( `@fromContext argument does not reference a context "${field}".`, @@ -1802,7 +1771,7 @@ export class FederationBlueprint extends SchemaBlueprint { metadata, }); } - + // validate that there is at least one resolvable key on the type const keyDirective = metadata.keyDirective(); const keyApplications = objectType.appliedDirectivesOf(keyDirective); @@ -1820,10 +1789,6 @@ export class FederationBlueprint extends SchemaBlueprint { validateKeyOnInterfacesAreAlsoOnAllImplementations(metadata, errorCollector); validateInterfaceObjectsAreOnEntities(metadata, errorCollector); - // FeatureDefinition objects passed to registerKnownFeature can register - // validation functions for subgraph schemas by overriding the - // validateSubgraphSchema method. - validateKnownFeatures(schema, errorCollector); // If tag is redefined by the user, make sure the definition is compatible with what we expect const tagDirective = metadata.tagDirective(); if (tagDirective) { @@ -1989,9 +1954,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.9", import: ["@key", "@requires", "@provides", "@external", "@tag", "@extends", "@shareable", "@inaccessible", "@override", "@composeDirective", "@interfaceObject", "@authenticated", "@requiresScopes", "@policy", "@sourceAPI", "@sourceType", "@sourceField", "@context", "@fromContext", "@cost", "@listSize"])'; +export const FEDERATION2_LINK_WITH_FULL_IMPORTS = '@link(url: "https://specs.apollo.dev/federation/v2.10", import: ["@key", "@requires", "@provides", "@external", "@tag", "@extends", "@shareable", "@inaccessible", "@override", "@composeDirective", "@interfaceObject", "@authenticated", "@requiresScopes", "@policy", "@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"])'; +export const FEDERATION2_LINK_WITH_AUTO_EXPANDED_IMPORTS = '@link(url: "https://specs.apollo.dev/federation/v2.10", import: ["@key", "@requires", "@provides", "@external", "@tag", "@extends", "@shareable", "@inaccessible", "@override", "@composeDirective", "@interfaceObject"])'; // This is the federation @link for tests that go through the SchemaUpgrader. export const FEDERATION2_LINK_WITH_AUTO_EXPANDED_IMPORTS_UPGRADED = '@link(url: "https://specs.apollo.dev/federation/v2.4", import: ["@key", "@requires", "@provides", "@external", "@tag", "@extends", "@shareable", "@inaccessible", "@override", "@composeDirective", "@interfaceObject"])'; diff --git a/internals-js/src/index.ts b/internals-js/src/index.ts index 9400a73ab..d0f282966 100644 --- a/internals-js/src/index.ts +++ b/internals-js/src/index.ts @@ -24,5 +24,5 @@ export * from './argumentCompositionStrategies'; export * from './specs/authenticatedSpec'; export * from './specs/requiresScopesSpec'; export * from './specs/policySpec'; -export * from './specs/sourceSpec'; +export * from './specs/connectSpec'; export * from './specs/costSpec'; diff --git a/internals-js/src/knownCoreFeatures.ts b/internals-js/src/knownCoreFeatures.ts index 83bb3ceaf..aebb62263 100644 --- a/internals-js/src/knownCoreFeatures.ts +++ b/internals-js/src/knownCoreFeatures.ts @@ -1,5 +1,3 @@ -import { GraphQLError } from "graphql"; -import { Schema } from "./definitions"; import { FeatureDefinition, FeatureDefinitions, FeatureUrl } from "./specs/coreSpec"; const registeredFeatures = new Map(); @@ -14,19 +12,6 @@ export function coreFeatureDefinitionIfKnown(url: FeatureUrl): FeatureDefinition return registeredFeatures.get(url.identity)?.find(url.version); } -export function validateKnownFeatures( - schema: Schema, - errorCollector: GraphQLError[] = [], -): GraphQLError[] { - registeredFeatures.forEach(definitions => { - const feature = definitions.latest(); - if (feature.validateSubgraphSchema !== FeatureDefinition.prototype.validateSubgraphSchema) { - errorCollector.push(...feature.validateSubgraphSchema(schema)); - } - }); - return errorCollector; -} - /** * Removes a feature from the set of known features. * diff --git a/internals-js/src/specs/connectSpec.ts b/internals-js/src/specs/connectSpec.ts new file mode 100644 index 000000000..213325061 --- /dev/null +++ b/internals-js/src/specs/connectSpec.ts @@ -0,0 +1,148 @@ +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, createScalarTypeSpecification } 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(createScalarTypeSpecification({ name: URL_PATH_TEMPLATE })); + this.registerType(createScalarTypeSpecification({ name: JSON_SELECTION })); + 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! + entity: Boolean = false + ) 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! + from: 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('from')).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', new NonNullType(ConnectHTTP)); + + connect.addArgument('selection', new NonNullType(JSONSelection)); + connect.addArgument('entity', schema.booleanType(), false); + + /* + 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', new NonNullType(SourceHTTP)); + + return []; + } + + get defaultCorePurpose(): CorePurpose { + return 'EXECUTION'; + } +} + +export const CONNECT_VERSIONS = new FeatureDefinitions(connectIdentity) + .add(new ConnectSpecDefinition(new FeatureVersion(0, 1), new FeatureVersion(2, 10))); + +registerKnownFeature(CONNECT_VERSIONS); diff --git a/internals-js/src/specs/coreSpec.ts b/internals-js/src/specs/coreSpec.ts index aaeb155cb..12fd1989d 100644 --- a/internals-js/src/specs/coreSpec.ts +++ b/internals-js/src/specs/coreSpec.ts @@ -117,11 +117,6 @@ export abstract class FeatureDefinition { .concat(this.typeSpecs().map((spec) => spec.name)); } - // No-op implementation that can be overridden by subclasses. - validateSubgraphSchema(_schema: Schema): GraphQLError[] { - return []; - } - protected nameInSchema(schema: Schema): string | undefined { const feature = this.featureInSchema(schema); return feature?.nameInSchema; @@ -624,7 +619,7 @@ export class FeatureDefinitions // this._definitions is already sorted with the most recent first // get the first definition that is compatible with the federation version // if the minimum version is not present, assume that we won't look for an older version - const def = this._definitions.find(def => def.minimumFederationVersion ? fedVersion >= def.minimumFederationVersion : true); + const def = this._definitions.find(def => def.minimumFederationVersion ? fedVersion.gte(def.minimumFederationVersion) : true); assert(def, `No compatible definition exists for federation version ${fedVersion}`); // note that it's necessary that we can only get versions that have the same major version as the latest, @@ -676,7 +671,7 @@ export class FeatureVersion { let max: FeatureVersion | undefined; for (const version of versions) { - if (!max || version > max) { + if (!max || version.gt(max)) { max = version; } } diff --git a/internals-js/src/specs/federationSpec.ts b/internals-js/src/specs/federationSpec.ts index 0b8c52542..37084b057 100644 --- a/internals-js/src/specs/federationSpec.ts +++ b/internals-js/src/specs/federationSpec.ts @@ -18,7 +18,6 @@ import { INACCESSIBLE_VERSIONS } from "./inaccessibleSpec"; import { AUTHENTICATED_VERSIONS } from "./authenticatedSpec"; 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"; @@ -44,9 +43,6 @@ export enum FederationDirectiveName { AUTHENTICATED = 'authenticated', REQUIRES_SCOPES = 'requiresScopes', POLICY = 'policy', - SOURCE_API = 'sourceAPI', - SOURCE_TYPE = 'sourceType', - SOURCE_FIELD = 'sourceField', CONTEXT = 'context', FROM_CONTEXT = 'fromContext', COST = 'cost', @@ -135,7 +131,7 @@ export class FederationSpecDefinition extends FeatureDefinition { this.registerSubFeature(INACCESSIBLE_VERSIONS.getMinimumRequiredVersion(version)); - if (version >= (new FeatureVersion(2, 7))) { + if (version.gte(new FeatureVersion(2, 7))) { this.registerDirective(createDirectiveSpecification({ name: FederationDirectiveName.OVERRIDE, locations: [DirectiveLocation.FIELD_DEFINITION], @@ -178,10 +174,6 @@ export class FederationSpecDefinition extends FeatureDefinition { this.registerSubFeature(POLICY_VERSIONS.find(new FeatureVersion(0, 1))!); } - if (version.gte(new FeatureVersion(2, 7))) { - this.registerSubFeature(SOURCE_VERSIONS.find(new FeatureVersion(0, 1))!); - } - if (version.gte(new FeatureVersion(2, 8))) { this.registerSubFeature(CONTEXT_VERSIONS.find(new FeatureVersion(0, 1))!); } @@ -202,6 +194,7 @@ export const FEDERATION_VERSIONS = new FeatureDefinitions(schema, 'sourceAPI')!; - } - - sourceTypeDirective(schema: Schema) { - return this.directive(schema, 'sourceType')!; - } - - sourceFieldDirective(schema: Schema) { - return this.directive(schema, 'sourceField')!; - } - - private getSourceDirectives(schema: Schema) { - const result: { - sourceAPI?: DirectiveDefinition; - sourceType?: DirectiveDefinition; - sourceField?: DirectiveDefinition; - } = {}; - - schema.schemaDefinition.appliedDirectivesOf('link') - .forEach(linkDirective => { - const { url, import: imports } = linkDirective.arguments(); - const featureUrl = FeatureUrl.maybeParse(url); - if (imports && featureUrl && featureUrl.identity === sourceIdentity) { - imports.forEach(nameOrRename => { - const originalName = typeof nameOrRename === 'string' ? nameOrRename : nameOrRename.name; - const importedName = typeof nameOrRename === 'string' ? nameOrRename : nameOrRename.as || originalName; - const importedNameWithoutAt = importedName.replace(/^@/, ''); - - if (originalName === '@sourceAPI') { - result.sourceAPI = schema.directive(importedNameWithoutAt) as DirectiveDefinition; - } else if (originalName === '@sourceType') { - result.sourceType = schema.directive(importedNameWithoutAt) as DirectiveDefinition; - } else if (originalName === '@sourceField') { - result.sourceField = schema.directive(importedNameWithoutAt) as DirectiveDefinition; - } - }); - } - }); - - return result; - } - - override validateSubgraphSchema(schema: Schema): GraphQLError[] { - const errors = super.validateSubgraphSchema(schema); - const { - sourceAPI, - sourceType, - sourceField, - } = this.getSourceDirectives(schema); - - if (!(sourceAPI || sourceType || sourceField)) { - // If none of the @source* directives are present, nothing needs - // validating. - return []; - } - - const apiNameToProtocol = new Map(); - - if (sourceAPI) { - this.validateSourceAPI(sourceAPI, apiNameToProtocol, errors); - } - - if (sourceType) { - this.validateSourceType(sourceType, apiNameToProtocol, errors); - } - - if (sourceField) { - this.validateSourceField(sourceField, apiNameToProtocol, errors); - } - - return errors; - } - - private validateSourceAPI( - sourceAPI: DirectiveDefinition, - apiNameToProtocol: Map, - errors: GraphQLError[], - ) { - sourceAPI.applications().forEach(application => { - const { name, ...rest } = application.arguments(); - - if (!isValidSourceAPIName(name)) { - errors.push(ERRORS.SOURCE_API_NAME_INVALID.err( - `${sourceAPI}(name: ${ - JSON.stringify(name) - }) must specify name using only [a-zA-Z0-9-_] characters`, - { nodes: application.sourceAST }, - )); - } - - if (apiNameToProtocol.has(name)) { - errors.push(ERRORS.SOURCE_API_NAME_INVALID.err( - `${sourceAPI} must specify unique name (${JSON.stringify(name)} reused)`, - { nodes: application.sourceAST }, - )); - } - - let protocol: ProtocolName | undefined; - KNOWN_SOURCE_PROTOCOLS.forEach(knownProtocol => { - if (rest[knownProtocol]) { - if (protocol) { - errors.push(ERRORS.SOURCE_API_PROTOCOL_INVALID.err( - `${sourceAPI} must specify only one of ${ - KNOWN_SOURCE_PROTOCOLS.join(', ') - } but specified both ${protocol} and ${knownProtocol}`, - { nodes: application.sourceAST }, - )); - } - protocol = knownProtocol; - } - }); - - if (protocol) { - apiNameToProtocol.set(name, protocol); - - const protocolValue = rest[protocol]; - if (protocolValue && protocol === HTTP_PROTOCOL) { - const { baseURL, headers } = protocolValue as HTTPSourceAPI; - - try { - new URL(baseURL); - } catch (e) { - errors.push(ERRORS.SOURCE_API_HTTP_BASE_URL_INVALID.err( - `${sourceAPI} http.baseURL ${JSON.stringify(baseURL)} must be valid URL (error: ${e.message})`, - { nodes: application.sourceAST }, - )); - } - - validateHTTPHeaders(headers, errors, sourceAPI.name); - } - } else { - errors.push(ERRORS.SOURCE_API_PROTOCOL_INVALID.err( - `${sourceAPI} must specify one protocol from the set {${KNOWN_SOURCE_PROTOCOLS.join(',')}}`, - { nodes: application.sourceAST }, - )); - } - }); - } - - private validateSourceType( - sourceType: DirectiveDefinition, - apiNameToProtocol: Map, - errors: GraphQLError[], - ) { - sourceType.applications().forEach(application => { - const { api, selection, ...rest } = application.arguments(); - if (!api || !apiNameToProtocol.has(api)) { - errors.push(ERRORS.SOURCE_TYPE_API_ERROR.err( - `${sourceType} specifies unknown api ${api}`, - { nodes: application.sourceAST }, - )); - } - - const expectedProtocol = apiNameToProtocol.get(api) || HTTP_PROTOCOL; - const protocolValue = expectedProtocol && rest[expectedProtocol]; - if (expectedProtocol && !protocolValue) { - errors.push(ERRORS.SOURCE_TYPE_PROTOCOL_INVALID.err( - `${sourceType} must specify same ${ - expectedProtocol - } argument as corresponding @sourceAPI for api ${api}`, - { nodes: application.sourceAST }, - )); - } - - if (protocolValue && expectedProtocol === HTTP_PROTOCOL) { - const { GET, POST, headers, body } = protocolValue as HTTPSourceType; - - if ([GET, POST].filter(Boolean).length !== 1) { - errors.push(ERRORS.SOURCE_TYPE_HTTP_METHOD_INVALID.err( - `${sourceType} must specify exactly one of http.GET or http.POST`, - { nodes: application.sourceAST }, - )); - } else { - const urlPathTemplate = (GET || POST)!; - try { - // TODO Validate URL path template uses only available @key fields - // of the type. - parseURLPathTemplate(urlPathTemplate); - } catch (e) { - errors.push(ERRORS.SOURCE_TYPE_HTTP_PATH_INVALID.err( - `${sourceType} http.GET or http.POST must be valid URL path template (error: ${e.message})` - )); - } - } - - validateHTTPHeaders(headers, errors, sourceType.name); - - if (body) { - if (GET) { - errors.push(ERRORS.SOURCE_TYPE_HTTP_BODY_INVALID.err( - `${sourceType} http.GET cannot specify http.body`, - { nodes: application.sourceAST }, - )); - } - - try { - parseJSONSelection(body); - // TODO Validate body selection matches the available fields. - } catch (e) { - errors.push(ERRORS.SOURCE_TYPE_HTTP_BODY_INVALID.err( - `${sourceType} http.body not valid JSONSelection (error: ${e.message})`, - { nodes: application.sourceAST }, - )); - } - } - } - - const ast = application.parent.sourceAST; - switch (ast?.kind) { - case "ObjectTypeDefinition": - case "InterfaceTypeDefinition": - if (!ast.directives?.some(directive => directive.name.value === "key")) { - errors.push(ERRORS.SOURCE_TYPE_ON_NON_OBJECT_OR_NON_ENTITY.err( - `${sourceType} must be applied to an entity type that also has a @key directive`, - { nodes: application.sourceAST }, - )); - } - try { - parseJSONSelection(selection); - // TODO Validate selection is valid JSONSelection for type. - } catch (e) { - errors.push(ERRORS.SOURCE_TYPE_SELECTION_INVALID.err( - `${sourceType} selection not valid JSONSelection (error: ${e.message})`, - { nodes: application.sourceAST }, - )); - } - break; - default: - errors.push(ERRORS.SOURCE_TYPE_ON_NON_OBJECT_OR_NON_ENTITY.err( - `${sourceType} must be applied to object or interface type`, - { nodes: application.sourceAST }, - )); - } - }); - } - - private validateSourceField( - sourceField: DirectiveDefinition, - apiNameToProtocol: Map, - errors: GraphQLError[], - ) { - sourceField.applications().forEach(application => { - const { api, selection, ...rest } = application.arguments(); - if (!api || !apiNameToProtocol.has(api)) { - errors.push(ERRORS.SOURCE_FIELD_API_ERROR.err( - `${sourceField} specifies unknown api ${api}`, - { nodes: application.sourceAST }, - )); - } - - const expectedProtocol = apiNameToProtocol.get(api) || HTTP_PROTOCOL; - const protocolValue = expectedProtocol && rest[expectedProtocol]; - if (protocolValue && expectedProtocol === HTTP_PROTOCOL) { - const { - GET, POST, PUT, PATCH, DELETE, - headers, - body, - } = protocolValue as HTTPSourceField; - - const usedMethods = [GET, POST, PUT, PATCH, DELETE].filter(Boolean); - if (usedMethods.length > 1) { - errors.push(ERRORS.SOURCE_FIELD_HTTP_METHOD_INVALID.err( - `${sourceField} allows at most one of http.{GET,POST,PUT,PATCH,DELETE}`, - )); - } else if (usedMethods.length === 1) { - const urlPathTemplate = usedMethods[0]!; - try { - // TODO Validate URL path template uses only available fields of - // the type and/or argument names of the field. - parseURLPathTemplate(urlPathTemplate); - } catch (e) { - errors.push(ERRORS.SOURCE_FIELD_HTTP_PATH_INVALID.err( - `${sourceField} http.{GET,POST,PUT,PATCH,DELETE} must be valid URL path template (error: ${e.message})` - )); - } - } - - validateHTTPHeaders(headers, errors, sourceField.name); - - if (body) { - if (GET) { - errors.push(ERRORS.SOURCE_FIELD_HTTP_BODY_INVALID.err( - `${sourceField} http.GET cannot specify http.body`, - { nodes: application.sourceAST }, - )); - } else if (DELETE) { - errors.push(ERRORS.SOURCE_FIELD_HTTP_BODY_INVALID.err( - `${sourceField} http.DELETE cannot specify http.body`, - { nodes: application.sourceAST }, - )); - } - - try { - parseJSONSelection(body); - // TODO Validate body string matches the available fields of the - // parent type and/or argument names of the field. - } catch (e) { - errors.push(ERRORS.SOURCE_FIELD_HTTP_BODY_INVALID.err( - `${sourceField} http.body not valid JSONSelection (error: ${e.message})`, - { nodes: application.sourceAST }, - )); - } - } - } - - if (selection) { - try { - parseJSONSelection(selection); - // TODO Validate selection string matches the available fields of - // the parent type and/or argument names of the field. - } catch (e) { - errors.push(ERRORS.SOURCE_FIELD_SELECTION_INVALID.err( - `${sourceField} selection not valid JSONSelection (error: ${e.message})`, - { nodes: application.sourceAST }, - )); - } - } - - // @sourceField is allowed only on root Query and Mutation fields or - // fields of entity object types. - const fieldParent = application.parent; - if (fieldParent.sourceAST?.kind !== Kind.FIELD_DEFINITION) { - errors.push(ERRORS.SOURCE_FIELD_NOT_ON_ROOT_OR_ENTITY_FIELD.err( - `${sourceField} must be applied to field`, - { nodes: application.sourceAST }, - )); - } else { - const typeGrandparent = fieldParent.parent as SchemaElement; - if (typeGrandparent.sourceAST?.kind !== Kind.OBJECT_TYPE_DEFINITION) { - errors.push(ERRORS.SOURCE_FIELD_NOT_ON_ROOT_OR_ENTITY_FIELD.err( - `${sourceField} must be applied to field of object type`, - { nodes: application.sourceAST }, - )); - } else { - const typeGrandparentName = typeGrandparent.sourceAST?.name.value; - if ( - typeGrandparentName !== "Query" && - typeGrandparentName !== "Mutation" && - typeGrandparent.appliedDirectivesOf("key").length === 0 - ) { - errors.push(ERRORS.SOURCE_FIELD_NOT_ON_ROOT_OR_ENTITY_FIELD.err( - `${sourceField} must be applied to root Query or Mutation field or field of entity type`, - { nodes: application.sourceAST }, - )); - } - } - } - }); - } -} - -function isValidSourceAPIName(name: string): boolean { - return /^[a-z-_][a-z0-9-_]*$/i.test(name); -} - -function isValidHTTPHeaderName(name: string): boolean { - // https://developers.cloudflare.com/rules/transform/request-header-modification/reference/header-format/ - return /^[a-zA-Z0-9-_]+$/.test(name); -} - -function validateHTTPHeaders( - headers: HTTPHeaderMapping[] | undefined, - errors: GraphQLError[], - directiveName: string, -) { - if (!directiveName.startsWith('@')) { - directiveName = '@' + directiveName; - } - if (headers) { - headers.forEach(({ name, as, value }, i) => { - // Ensure name is a valid HTTP header name. - if (!isValidHTTPHeaderName(name)) { - errors.push(ERRORS.SOURCE_HTTP_HEADERS_INVALID.err( - `${directiveName} header ${JSON.stringify(headers[i])} specifies invalid name`, - )); - } - - if (as && !isValidHTTPHeaderName(as)) { - errors.push(ERRORS.SOURCE_HTTP_HEADERS_INVALID.err( - `${directiveName} header ${JSON.stringify(headers[i])} specifies invalid 'as' name`, - )); - } - - if (as && value) { - errors.push(ERRORS.SOURCE_HTTP_HEADERS_INVALID.err( - `${directiveName} header ${JSON.stringify(headers[i])} should specify at most one of 'as' or 'value'`, - )); - } - - // TODO Validate value is valid HTTP header value? - }); - } -} - -function parseJSONSelection(_selection: string): any { - // TODO -} - -function parseURLPathTemplate(_template: string): any { - // TODO -} - -const HTTP_PROTOCOL = "http"; -const KNOWN_SOURCE_PROTOCOLS = [ - HTTP_PROTOCOL, -] as const; -type ProtocolName = (typeof KNOWN_SOURCE_PROTOCOLS)[number]; - -export type SourceAPIDirectiveArgs = { - name: string; - http?: HTTPSourceAPI; -}; - -export type HTTPSourceAPI = { - baseURL: string; - headers?: HTTPHeaderMapping[]; -}; - -export type HTTPHeaderMapping = { - name: string; - as?: string; - value?: string; -}; - -export type SourceTypeDirectiveArgs = { - api: string; - http?: HTTPSourceType; - selection: JSONSelection; - keyTypeMap?: KeyTypeMap; -}; - -export type HTTPSourceType = { - GET?: URLPathTemplate; - POST?: URLPathTemplate; - headers?: HTTPHeaderMapping[]; - body?: JSONSelection; -}; - -type URLPathTemplate = string; -type JSONSelection = string; - -type KeyTypeMap = { - key: string; - typeMap: { - [__typename: string]: string; - }; -}; - -export type SourceFieldDirectiveArgs = { - api: string; - http?: HTTPSourceField; - selection?: JSONSelection; - keyTypeMap?: KeyTypeMap; -}; - -export type HTTPSourceField = { - GET?: URLPathTemplate; - POST?: URLPathTemplate; - PUT?: URLPathTemplate; - PATCH?: URLPathTemplate; - DELETE?: URLPathTemplate; - body?: JSONSelection; - headers?: HTTPHeaderMapping[]; -}; - -export const SOURCE_VERSIONS = new FeatureDefinitions(sourceIdentity) - .add(new SourceSpecDefinition(new FeatureVersion(0, 1), new FeatureVersion(2, 7))); - -registerKnownFeature(SOURCE_VERSIONS); diff --git a/internals-js/src/supergraphs.ts b/internals-js/src/supergraphs.ts index da4d52751..a4d637329 100644 --- a/internals-js/src/supergraphs.ts +++ b/internals-js/src/supergraphs.ts @@ -43,6 +43,7 @@ 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/connect/v0.1', ]); const coreVersionZeroDotOneUrl = FeatureUrl.parse('https://specs.apollo.dev/core/v0.1'); @@ -156,7 +157,7 @@ export class Supergraph { this.containedSubgraphs = extractSubgraphsNamesAndUrlsFromSupergraph(schema); } - static build(supergraphSdl: string | DocumentNode, options?: { supportedFeatures?: Set, validateSupergraph?: boolean }) { + static build(supergraphSdl: string | DocumentNode, options?: { supportedFeatures?: Set | null, validateSupergraph?: boolean }) { // We delay validation because `checkFeatureSupport` in the constructor gives slightly more useful errors if, say, 'for' is used with core v0.1. const schema = typeof supergraphSdl === 'string' ? buildSchema(supergraphSdl, { validate: false }) diff --git a/package-lock.json b/package-lock.json index 24d28b920..cfcbedb18 100644 --- a/package-lock.json +++ b/package-lock.json @@ -70,11 +70,11 @@ }, "composition-js": { "name": "@apollo/composition", - "version": "2.9.3", + "version": "2.10.0-alpha.4", "license": "Elastic-2.0", "dependencies": { - "@apollo/federation-internals": "2.9.3", - "@apollo/query-graphs": "2.9.3" + "@apollo/federation-internals": "2.10.0-alpha.4", + "@apollo/query-graphs": "2.10.0-alpha.4" }, "engines": { "node": ">=14.15.0" @@ -85,7 +85,7 @@ }, "federation-integration-testsuite-js": { "name": "apollo-federation-integration-testsuite", - "version": "2.9.3", + "version": "2.10.0-alpha.4", "license": "Elastic-2.0", "dependencies": { "graphql-tag": "^2.12.6", @@ -94,12 +94,12 @@ }, "gateway-js": { "name": "@apollo/gateway", - "version": "2.9.3", + "version": "2.10.0-alpha.4", "license": "Elastic-2.0", "dependencies": { - "@apollo/composition": "2.9.3", - "@apollo/federation-internals": "2.9.3", - "@apollo/query-planner": "2.9.3", + "@apollo/composition": "2.10.0-alpha.4", + "@apollo/federation-internals": "2.10.0-alpha.4", + "@apollo/query-planner": "2.10.0-alpha.4", "@apollo/server-gateway-interface": "^1.1.0", "@apollo/usage-reporting-protobuf": "^4.1.0", "@apollo/utils.createhash": "^2.0.0", @@ -125,7 +125,7 @@ }, "internals-js": { "name": "@apollo/federation-internals", - "version": "2.9.3", + "version": "2.10.0-alpha.4", "license": "Elastic-2.0", "dependencies": { "@types/uuid": "^9.0.0", @@ -9133,22 +9133,10 @@ "loose-envify": "^1.0.0" } }, - "node_modules/ip-address": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-9.0.5.tgz", - "integrity": "sha512-zHtQzGojZXTwZTHQqra+ETKd4Sn3vgi7uBmlPoXVWZqYvuKmtI0l/VZTjqGmJY9x88GGOaZ9+G9ES8hC4T4X8g==", - "dependencies": { - "jsbn": "1.1.0", - "sprintf-js": "^1.1.3" - }, - "engines": { - "node": ">= 12" - } - }, - "node_modules/ip-address/node_modules/sprintf-js": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", - "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==" + "node_modules/ip": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ip/-/ip-2.0.1.tgz", + "integrity": "sha512-lJUL9imLTNi1ZfXT+DU6rBBdbiKGBuay9B6xGSPVjUeQwaH1RIGqef8RZkUtHioLmSNpPR5M4HVKJGm1j8FWVQ==" }, "node_modules/ipaddr.js": { "version": "1.9.1", @@ -12604,11 +12592,6 @@ "js-yaml": "bin/js-yaml.js" } }, - "node_modules/jsbn": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-1.1.0.tgz", - "integrity": "sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A==" - }, "node_modules/jsdom": { "version": "16.7.0", "dev": true, @@ -15995,15 +15978,14 @@ } }, "node_modules/socks": { - "version": "2.8.3", - "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.3.tgz", - "integrity": "sha512-l5x7VUUWbjVFbafGLxPWkYsHIhEvmF85tbIeFZWc8ZPtoMyybuEhL7Jye/ooC4/d48FgOjSJXgsF/AJPYCW8Zw==", + "version": "2.7.1", + "license": "MIT", "dependencies": { - "ip-address": "^9.0.5", + "ip": "^2.0.0", "smart-buffer": "^4.2.0" }, "engines": { - "node": ">= 10.0.0", + "node": ">= 10.13.0", "npm": ">= 3.0.0" } }, @@ -17859,10 +17841,10 @@ }, "query-graphs-js": { "name": "@apollo/query-graphs", - "version": "2.9.3", + "version": "2.10.0-alpha.4", "license": "Elastic-2.0", "dependencies": { - "@apollo/federation-internals": "2.9.3", + "@apollo/federation-internals": "2.10.0-alpha.4", "deep-equal": "^2.0.5", "ts-graphviz": "^1.5.4", "uuid": "^9.0.0" @@ -17876,11 +17858,11 @@ }, "query-planner-js": { "name": "@apollo/query-planner", - "version": "2.9.3", + "version": "2.10.0-alpha.4", "license": "Elastic-2.0", "dependencies": { - "@apollo/federation-internals": "2.9.3", - "@apollo/query-graphs": "2.9.3", + "@apollo/federation-internals": "2.10.0-alpha.4", + "@apollo/query-graphs": "2.10.0-alpha.4", "@apollo/utils.keyvaluecache": "^2.1.0", "chalk": "^4.1.0", "deep-equal": "^2.0.5", @@ -17909,11 +17891,11 @@ }, "subgraph-js": { "name": "@apollo/subgraph", - "version": "2.9.3", + "version": "2.10.0-alpha.4", "license": "MIT", "dependencies": { "@apollo/cache-control-types": "^1.0.2", - "@apollo/federation-internals": "2.9.3" + "@apollo/federation-internals": "2.10.0-alpha.4" }, "engines": { "node": ">=14.15.0" diff --git a/query-graphs-js/package.json b/query-graphs-js/package.json index 4f92cceb7..8ce2cc082 100644 --- a/query-graphs-js/package.json +++ b/query-graphs-js/package.json @@ -1,6 +1,6 @@ { "name": "@apollo/query-graphs", - "version": "2.9.3", + "version": "2.10.0-alpha.4", "description": "Apollo Federation library to work with 'query graphs'", "main": "dist/index.js", "types": "dist/index.d.ts", @@ -23,7 +23,7 @@ "node": ">=14.15.0" }, "dependencies": { - "@apollo/federation-internals": "2.9.3", + "@apollo/federation-internals": "2.10.0-alpha.4", "deep-equal": "^2.0.5", "ts-graphviz": "^1.5.4", "uuid": "^9.0.0" diff --git a/query-planner-js/package.json b/query-planner-js/package.json index 22ca42be2..b1b474ff5 100644 --- a/query-planner-js/package.json +++ b/query-planner-js/package.json @@ -1,6 +1,6 @@ { "name": "@apollo/query-planner", - "version": "2.9.3", + "version": "2.10.0-alpha.4", "description": "Apollo Query Planner", "author": "Apollo ", "main": "dist/index.js", @@ -25,8 +25,8 @@ "access": "public" }, "dependencies": { - "@apollo/federation-internals": "2.9.3", - "@apollo/query-graphs": "2.9.3", + "@apollo/federation-internals": "2.10.0-alpha.4", + "@apollo/query-graphs": "2.10.0-alpha.4", "@apollo/utils.keyvaluecache": "^2.1.0", "chalk": "^4.1.0", "deep-equal": "^2.0.5", diff --git a/subgraph-js/package.json b/subgraph-js/package.json index 16aa69954..a91831c64 100644 --- a/subgraph-js/package.json +++ b/subgraph-js/package.json @@ -1,6 +1,6 @@ { "name": "@apollo/subgraph", - "version": "2.9.3", + "version": "2.10.0-alpha.4", "description": "Apollo Subgraph Utilities", "main": "dist/index.js", "types": "dist/index.d.ts", @@ -25,7 +25,7 @@ }, "dependencies": { "@apollo/cache-control-types": "^1.0.2", - "@apollo/federation-internals": "2.9.3" + "@apollo/federation-internals": "2.10.0-alpha.4" }, "peerDependencies": { "graphql": "^16.5.0" diff --git a/subgraph-js/src/__tests__/buildSubgraphSchema.test.ts b/subgraph-js/src/__tests__/buildSubgraphSchema.test.ts index 223253f3b..58243240c 100644 --- a/subgraph-js/src/__tests__/buildSubgraphSchema.test.ts +++ b/subgraph-js/src/__tests__/buildSubgraphSchema.test.ts @@ -439,6 +439,112 @@ describe('buildSubgraphSchema', () => { } `); }); + + it('skips `resolveType` if `resolveReference` returns null', async () => { + const query = `#graphql + query Product { + _entities(representations: [{ __typename: "Product", key: "unknown" }]) { + __typename + ... on Product { + type + key + } + } + } + `; + const schema = buildSubgraphSchema([ + { + typeDefs: gql` + extend schema + @link( + url: "https://specs.apollo.dev/federation/v2.8" + import: ["@key"] + ) + + enum ProductType { + A + B + } + + interface Product @key(fields: "key") { + type: ProductType! + key: String! + } + + type ProductA implements Product @key(fields: "key") { + type: ProductType! + key: String! + } + + type ProductB implements Product @key(fields: "key") { + type: ProductType! + key: String! + } + `, + resolvers: { + Product: { + __resolveReference: async ({ key }: { key: string }) => { + if (key === 'a') { + return { + type: 'A', + key, + }; + } else if (key === 'b') { + return { + type: 'B', + key, + }; + } else { + return null; + } + }, + __resolveType: async (entity: { type: 'A' | 'B' }) => { + switch (entity.type) { + case 'A': + return 'ProductA'; + case 'B': + return 'ProductB'; + default: + throw new Error('Unknown type'); + } + }, + }, + ProductA: { + __resolveReference: async ({ key }: { key: string }) => { + return { + type: 'ProductA', + key, + }; + }, + }, + ProductB: { + __resolveReference: async ({ key }: { key: string }) => { + return { + type: 'ProductB', + key, + }; + }, + }, + }, + }, + ]); + + const { data, errors } = await graphql({ + schema, + source: query, + rootValue: null, + contextValue: null, + variableValues: null, + }); + expect(errors).toBeUndefined(); + expect(data).toMatchInlineSnapshot(` + Object { + "_entities": Array [ + null, + ], + } + `); + }); }); describe('_service root field', () => { diff --git a/subgraph-js/src/types.ts b/subgraph-js/src/types.ts index 13333e234..ecd1faaa2 100644 --- a/subgraph-js/src/types.ts +++ b/subgraph-js/src/types.ts @@ -162,15 +162,20 @@ async function withResolvedType({ info: GraphQLResolveInfo, callback: (runtimeType: GraphQLObjectType) => PromiseOrValue, }): Promise { + const resolvedValue = await value; + if (resolvedValue === null) { + return resolvedValue; + } + const resolveTypeFn = type.resolveType ?? defaultTypeResolver; - const runtimeType = resolveTypeFn(await value, context, info, type); + const runtimeType = resolveTypeFn(resolvedValue, context, info, type); if (isPromise(runtimeType)) { return runtimeType.then((name) => ( - callback(ensureValidRuntimeType(name, info.schema, type, value)) + callback(ensureValidRuntimeType(name, info.schema, type, resolvedValue)) )); } - return callback(ensureValidRuntimeType(runtimeType, info.schema, type, value)); + return callback(ensureValidRuntimeType(runtimeType, info.schema, type, resolvedValue)); } function definedResolveReference(type: GraphQLObjectType | GraphQLInterfaceType): GraphQLReferenceResolver | undefined {