fix: use schema contracts federation library (#6964)

This commit is contained in:
Laurin Quast 2025-09-15 12:29:09 +02:00 committed by GitHub
parent 2d7e7be313
commit 147e86233c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 64 additions and 4151 deletions

View file

@ -21,7 +21,7 @@
"@hive/schema": "workspace:*",
"@hive/server": "workspace:*",
"@hive/storage": "workspace:*",
"@theguild/federation-composition": "0.19.1",
"@theguild/federation-composition": "0.20.0",
"@trpc/client": "10.45.2",
"@trpc/server": "10.45.2",
"@types/async-retry": "1.4.8",

View file

@ -73,6 +73,7 @@
"@sentry/cli": "2.40.0",
"@swc/core": "1.13.5",
"@theguild/eslint-config": "0.12.1",
"@theguild/federation-composition": "0.20.0",
"@theguild/prettier-config": "2.0.7",
"@types/node": "22.10.5",
"bob-the-bundler": "7.0.1",

View file

@ -59,7 +59,7 @@
"@oclif/core": "^3.26.6",
"@oclif/plugin-help": "6.0.22",
"@oclif/plugin-update": "4.2.13",
"@theguild/federation-composition": "0.19.1",
"@theguild/federation-composition": "0.20.0",
"colors": "1.4.0",
"env-ci": "7.3.0",
"graphql": "^16.8.1",

View file

@ -37,7 +37,7 @@
"@sentry/node": "7.120.2",
"@sentry/types": "7.120.2",
"@slack/web-api": "7.8.0",
"@theguild/federation-composition": "0.19.1",
"@theguild/federation-composition": "0.20.0",
"@trpc/client": "10.45.2",
"@trpc/server": "10.45.2",
"@types/bcryptjs": "2.4.6",

View file

@ -75,7 +75,7 @@ export default gql`
description: String @tag(name: "public")
"""
List of permissions that are assigned to the access token.
A list of available permissions can be retrieved via the \`Organization.availableOrganizationAccessTokenPermissionGroups\` field.
A list of available permissions can be retrieved via the 'Organization.availableOrganizationAccessTokenPermissionGroups' field.
"""
permissions: [String!]! @tag(name: "public")
"""
@ -521,7 +521,7 @@ export default gql`
"""
description: String! @tag(name: "public")
"""
A list of available permissions can be retrieved via the \`Organization.availableMemberPermissionGroups\` field.
A list of available permissions can be retrieved via the 'Organization.availableMemberPermissionGroups' field.
"""
selectedPermissions: [String!]! @tag(name: "public")
}

View file

@ -2,14 +2,14 @@ import { gql } from 'graphql-modules';
export default gql`
"""
A date-time string at UTC, such as \`2007-12-03T10:15:30Z\`, is compliant with the date-time format outlined
A date-time string at UTC, such as '2007-12-03T10:15:30Z', is compliant with the date-time format outlined
in section 5.6 of the RFC 3339 profile of the ISO 8601 standard for representation of dates and times using the Gregorian calendar.
This scalar is a description of an exact instant on the timeline such as the instant that a user account was created.
This scalar ignores leap seconds (thereby assuming that a minute constitutes 59 seconds). In this respect, it diverges from the RFC 3339 profile.
Where an RFC 3339 compliant date-time string has a time-zone other than UTC, it is shifted to UTC. For example, the date-time string \`2016-01-01T14:10:20+01:00\` is shifted to \`2016-01-01T13:10:20Z\`.
Where an RFC 3339 compliant date-time string has a time-zone other than UTC, it is shifted to UTC. For example, the date-time string '2016-01-01T14:10:20+01:00' is shifted to '2016-01-01T13:10:20Z'.
"""
scalar DateTime
@tag(name: "public")

View file

@ -15,7 +15,7 @@
"@graphql-tools/stitching-directives": "3.1.38",
"@hive/service-common": "workspace:*",
"@sentry/node": "7.120.2",
"@theguild/federation-composition": "0.19.1",
"@theguild/federation-composition": "0.20.0",
"@trpc/server": "10.45.2",
"@types/async-retry": "1.4.8",
"@types/ioredis-mock": "8.2.5",

View file

@ -1,596 +0,0 @@
import { createComposeFederation } from '../federation';
test('contract: mutation type is not part of the public schema if all fields are excluded', async () => {
const compose = createComposeFederation({
decrypt: () => '',
requestTimeoutMs: Infinity,
});
const sdl = /* GraphQL */ `
schema @link(url: "https://specs.apollo.dev/federation/v2.3", import: ["@tag"]) {
query: Query
mutation: Mutation
}
type Query {
field1: String!
}
type Mutation {
field1: ID! @tag(name: "exclude")
}
`;
const sdl2 = /* GraphQL */ `
schema @link(url: "https://specs.apollo.dev/federation/v2.3", import: ["@tag"]) {
query: Query
mutation: Mutation
}
type Query {
field2: String!
}
type Mutation {
field2: ID! @tag(name: "exclude")
}
`;
const result = await compose({
contracts: [
{
filter: {
include: null,
exclude: ['exclude'],
removeUnreachableTypesFromPublicApiSchema: true,
},
id: '1',
},
],
external: null,
native: true,
requestId: '1',
schemas: [
{
raw: sdl,
source: 'foo.graphql',
url: 'https://lol.de',
},
{
raw: sdl2,
source: 'foo2.graphql',
url: 'https://trolol.de',
},
],
});
expect(result.type).toEqual('success');
const contractResult = result.result.contracts?.at(0);
expect(contractResult?.id).toEqual('1');
expect(contractResult?.result.type).toEqual('success');
expect(contractResult?.result.result.sdl).toMatchInlineSnapshot(`
type Query {
field1: String!
field2: String!
}
`);
expect(contractResult?.result.result.supergraph).toMatchInlineSnapshot(`
schema @link(url: "https://specs.apollo.dev/link/v1.0") @link(url: "https://specs.apollo.dev/join/v0.3", for: EXECUTION) @link(url: "https://specs.apollo.dev/inaccessible/v0.2", for: SECURITY) {
query: Query
mutation: Mutation
}
directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE
directive @join__graph(name: String!, url: String!) on ENUM_VALUE
directive @join__field(graph: join__Graph, requires: join__FieldSet, provides: join__FieldSet, type: String, external: Boolean, override: String, usedOverridden: Boolean) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION
directive @join__implements(graph: join__Graph!, interface: String!) repeatable on OBJECT | INTERFACE
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__unionMember(graph: join__Graph!, member: String!) repeatable on UNION
scalar join__FieldSet
directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA
scalar link__Import
enum link__Purpose {
"""
\`SECURITY\` features provide metadata necessary to securely resolve fields.
"""
SECURITY
"""
\`EXECUTION\` features provide metadata necessary for operation execution.
"""
EXECUTION
}
directive @inaccessible on FIELD_DEFINITION | OBJECT | INTERFACE | UNION | ENUM | ENUM_VALUE | SCALAR | INPUT_OBJECT | INPUT_FIELD_DEFINITION | ARGUMENT_DEFINITION
enum join__Graph {
FOO_GRAPHQL @join__graph(name: "foo.graphql", url: "https://lol.de")
FOO2_GRAPHQL @join__graph(name: "foo2.graphql", url: "https://trolol.de")
}
type Query @join__type(graph: FOO_GRAPHQL) @join__type(graph: FOO2_GRAPHQL) {
field1: String! @join__field(graph: FOO_GRAPHQL)
field2: String! @join__field(graph: FOO2_GRAPHQL)
}
type Mutation @join__type(graph: FOO_GRAPHQL) @join__type(graph: FOO2_GRAPHQL) @inaccessible {
field1: ID! @join__field(graph: FOO_GRAPHQL) @inaccessible
field2: ID! @join__field(graph: FOO2_GRAPHQL) @inaccessible
}
`);
});
test('contract: mutation type is part of the public schema if not all fields are excluded', async () => {
const compose = createComposeFederation({
decrypt: () => '',
requestTimeoutMs: Infinity,
});
const sdl = /* GraphQL */ `
schema @link(url: "https://specs.apollo.dev/federation/v2.3", import: ["@tag"]) {
query: Query
mutation: Mutation
}
type Query {
field1: String!
}
type Mutation {
field1: ID! @tag(name: "exclude")
}
`;
const sdl2 = /* GraphQL */ `
schema @link(url: "https://specs.apollo.dev/federation/v2.3", import: ["@tag"]) {
query: Query
mutation: Mutation
}
type Query {
field2: String!
}
type Mutation {
field2: ID!
}
`;
const result = await compose({
contracts: [
{
filter: {
include: null,
exclude: ['exclude'],
removeUnreachableTypesFromPublicApiSchema: true,
},
id: '1',
},
],
external: null,
native: true,
requestId: '1',
schemas: [
{
raw: sdl,
source: 'foo.graphql',
url: 'https://lol.de',
},
{
raw: sdl2,
source: 'foo2.graphql',
url: 'https://trolol.de',
},
],
});
expect(result.type).toEqual('success');
const contractResult = result.result.contracts?.at(0);
expect(contractResult?.id).toEqual('1');
expect(contractResult?.result.type).toEqual('success');
expect(contractResult?.result.result.sdl).toMatchInlineSnapshot(`
type Query {
field1: String!
field2: String!
}
type Mutation {
field2: ID!
}
`);
expect(contractResult?.result.result.supergraph).toMatchInlineSnapshot(`
schema @link(url: "https://specs.apollo.dev/link/v1.0") @link(url: "https://specs.apollo.dev/join/v0.3", for: EXECUTION) @link(url: "https://specs.apollo.dev/inaccessible/v0.2", for: SECURITY) {
query: Query
mutation: Mutation
}
directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE
directive @join__graph(name: String!, url: String!) on ENUM_VALUE
directive @join__field(graph: join__Graph, requires: join__FieldSet, provides: join__FieldSet, type: String, external: Boolean, override: String, usedOverridden: Boolean) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION
directive @join__implements(graph: join__Graph!, interface: String!) repeatable on OBJECT | INTERFACE
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__unionMember(graph: join__Graph!, member: String!) repeatable on UNION
scalar join__FieldSet
directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA
scalar link__Import
enum link__Purpose {
"""
\`SECURITY\` features provide metadata necessary to securely resolve fields.
"""
SECURITY
"""
\`EXECUTION\` features provide metadata necessary for operation execution.
"""
EXECUTION
}
directive @inaccessible on FIELD_DEFINITION | OBJECT | INTERFACE | UNION | ENUM | ENUM_VALUE | SCALAR | INPUT_OBJECT | INPUT_FIELD_DEFINITION | ARGUMENT_DEFINITION
enum join__Graph {
FOO_GRAPHQL @join__graph(name: "foo.graphql", url: "https://lol.de")
FOO2_GRAPHQL @join__graph(name: "foo2.graphql", url: "https://trolol.de")
}
type Query @join__type(graph: FOO_GRAPHQL) @join__type(graph: FOO2_GRAPHQL) {
field1: String! @join__field(graph: FOO_GRAPHQL)
field2: String! @join__field(graph: FOO2_GRAPHQL)
}
type Mutation @join__type(graph: FOO_GRAPHQL) @join__type(graph: FOO2_GRAPHQL) {
field1: ID! @join__field(graph: FOO_GRAPHQL) @inaccessible
field2: ID! @join__field(graph: FOO2_GRAPHQL)
}
`);
});
test('contract: mutation type is not part of the public schema if no fields are included', async () => {
const compose = createComposeFederation({
decrypt: () => '',
requestTimeoutMs: Infinity,
});
const sdl = /* GraphQL */ `
schema @link(url: "https://specs.apollo.dev/federation/v2.3", import: ["@tag"]) {
query: Query
mutation: Mutation
}
type Query {
field1: String! @tag(name: "include")
}
type Mutation {
field1: ID!
}
`;
const sdl2 = /* GraphQL */ `
schema @link(url: "https://specs.apollo.dev/federation/v2.3", import: ["@tag"]) {
query: Query
mutation: Mutation
}
type Query {
field2: String! @tag(name: "include")
}
type Mutation {
field2: ID!
}
`;
const result = await compose({
contracts: [
{
filter: {
include: ['include'],
exclude: null,
removeUnreachableTypesFromPublicApiSchema: true,
},
id: '1',
},
],
external: null,
native: true,
requestId: '1',
schemas: [
{
raw: sdl,
source: 'foo.graphql',
url: 'https://lol.de',
},
{
raw: sdl2,
source: 'foo2.graphql',
url: 'https://trolol.de',
},
],
});
expect(result.type).toEqual('success');
const contractResult = result.result.contracts?.at(0);
expect(contractResult?.id).toEqual('1');
expect(contractResult?.result.type).toEqual('success');
expect(contractResult?.result.result.sdl).toMatchInlineSnapshot(`
type Query {
field1: String!
field2: String!
}
`);
expect(contractResult?.result.result.supergraph).toMatchInlineSnapshot(`
schema @link(url: "https://specs.apollo.dev/link/v1.0") @link(url: "https://specs.apollo.dev/join/v0.3", for: EXECUTION) @link(url: "https://specs.apollo.dev/inaccessible/v0.2", for: SECURITY) {
query: Query
mutation: Mutation
}
directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE
directive @join__graph(name: String!, url: String!) on ENUM_VALUE
directive @join__field(graph: join__Graph, requires: join__FieldSet, provides: join__FieldSet, type: String, external: Boolean, override: String, usedOverridden: Boolean) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION
directive @join__implements(graph: join__Graph!, interface: String!) repeatable on OBJECT | INTERFACE
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__unionMember(graph: join__Graph!, member: String!) repeatable on UNION
scalar join__FieldSet
directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA
scalar link__Import
enum link__Purpose {
"""
\`SECURITY\` features provide metadata necessary to securely resolve fields.
"""
SECURITY
"""
\`EXECUTION\` features provide metadata necessary for operation execution.
"""
EXECUTION
}
directive @inaccessible on FIELD_DEFINITION | OBJECT | INTERFACE | UNION | ENUM | ENUM_VALUE | SCALAR | INPUT_OBJECT | INPUT_FIELD_DEFINITION | ARGUMENT_DEFINITION
enum join__Graph {
FOO_GRAPHQL @join__graph(name: "foo.graphql", url: "https://lol.de")
FOO2_GRAPHQL @join__graph(name: "foo2.graphql", url: "https://trolol.de")
}
type Query @join__type(graph: FOO_GRAPHQL) @join__type(graph: FOO2_GRAPHQL) {
field1: String! @join__field(graph: FOO_GRAPHQL)
field2: String! @join__field(graph: FOO2_GRAPHQL)
}
type Mutation @join__type(graph: FOO_GRAPHQL) @join__type(graph: FOO2_GRAPHQL) @inaccessible {
field1: ID! @join__field(graph: FOO_GRAPHQL) @inaccessible
field2: ID! @join__field(graph: FOO2_GRAPHQL) @inaccessible
}
`);
});
test('contract: mutation type is part of the public schema if at least one field is included', async () => {
const compose = createComposeFederation({
decrypt: () => '',
requestTimeoutMs: Infinity,
});
const sdl = /* GraphQL */ `
schema @link(url: "https://specs.apollo.dev/federation/v2.3", import: ["@tag"]) {
query: Query
mutation: Mutation
}
type Query {
field1: String! @tag(name: "include")
}
type Mutation {
field1: ID!
}
`;
const sdl2 = /* GraphQL */ `
schema @link(url: "https://specs.apollo.dev/federation/v2.3", import: ["@tag"]) {
query: Query
mutation: Mutation
}
type Query {
field2: String! @tag(name: "include")
}
type Mutation {
field2: ID! @tag(name: "include")
}
`;
const result = await compose({
contracts: [
{
filter: {
include: ['include'],
exclude: null,
removeUnreachableTypesFromPublicApiSchema: true,
},
id: '1',
},
],
external: null,
native: true,
requestId: '1',
schemas: [
{
raw: sdl,
source: 'foo.graphql',
url: 'https://lol.de',
},
{
raw: sdl2,
source: 'foo2.graphql',
url: 'https://trolol.de',
},
],
});
expect(result.type).toEqual('success');
const contractResult = result.result.contracts?.at(0);
expect(contractResult?.id).toEqual('1');
expect(contractResult?.result.type).toEqual('success');
expect(contractResult?.result.result.sdl).toMatchInlineSnapshot(`
type Query {
field1: String!
field2: String!
}
type Mutation {
field2: ID!
}
`);
expect(contractResult?.result.result.supergraph).toMatchInlineSnapshot(`
schema @link(url: "https://specs.apollo.dev/link/v1.0") @link(url: "https://specs.apollo.dev/join/v0.3", for: EXECUTION) @link(url: "https://specs.apollo.dev/inaccessible/v0.2", for: SECURITY) {
query: Query
mutation: Mutation
}
directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE
directive @join__graph(name: String!, url: String!) on ENUM_VALUE
directive @join__field(graph: join__Graph, requires: join__FieldSet, provides: join__FieldSet, type: String, external: Boolean, override: String, usedOverridden: Boolean) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION
directive @join__implements(graph: join__Graph!, interface: String!) repeatable on OBJECT | INTERFACE
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__unionMember(graph: join__Graph!, member: String!) repeatable on UNION
scalar join__FieldSet
directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA
scalar link__Import
enum link__Purpose {
"""
\`SECURITY\` features provide metadata necessary to securely resolve fields.
"""
SECURITY
"""
\`EXECUTION\` features provide metadata necessary for operation execution.
"""
EXECUTION
}
directive @inaccessible on FIELD_DEFINITION | OBJECT | INTERFACE | UNION | ENUM | ENUM_VALUE | SCALAR | INPUT_OBJECT | INPUT_FIELD_DEFINITION | ARGUMENT_DEFINITION
enum join__Graph {
FOO_GRAPHQL @join__graph(name: "foo.graphql", url: "https://lol.de")
FOO2_GRAPHQL @join__graph(name: "foo2.graphql", url: "https://trolol.de")
}
type Query @join__type(graph: FOO_GRAPHQL) @join__type(graph: FOO2_GRAPHQL) {
field1: String! @join__field(graph: FOO_GRAPHQL)
field2: String! @join__field(graph: FOO2_GRAPHQL)
}
type Mutation @join__type(graph: FOO_GRAPHQL) @join__type(graph: FOO2_GRAPHQL) {
field1: ID! @join__field(graph: FOO_GRAPHQL) @inaccessible
field2: ID! @join__field(graph: FOO2_GRAPHQL)
}
`);
});
test('contract: scalar is inaccessible, despite being included in at least one subraph', async () => {
const compose = createComposeFederation({
decrypt: () => '',
requestTimeoutMs: Infinity,
});
const sdl2 = /* GraphQL */ `
schema @link(url: "https://specs.apollo.dev/federation/v2.3", import: ["@tag"]) {
query: Query
}
type Query {
field1: Foo!
}
type Foo {
field: Brr @tag(name: "include")
}
scalar Brr @tag(name: "include")
`;
const sdl = /* GraphQL */ `
schema @link(url: "https://specs.apollo.dev/federation/v2.3", import: ["@tag"]) {
query: Query
}
type Query {
c: Int @tag(name: "include")
}
scalar Brr
`;
const result = await compose({
contracts: [
{
filter: {
include: ['include'],
exclude: null,
removeUnreachableTypesFromPublicApiSchema: false,
},
id: '1',
},
],
external: null,
native: true,
requestId: '1',
schemas: [
{
raw: sdl,
source: 'foo.graphql',
url: 'https://lol.de',
},
{
raw: sdl2,
source: 'foo2.graphql',
url: 'https://trolol.de',
},
],
});
expect(result.type).toEqual('success');
const contractResult = result.result.contracts?.at(0);
expect(contractResult?.id).toEqual('1');
expect(contractResult?.result.type).toEqual('success');
const line = contractResult?.result.result.supergraph
?.split('\n')
.find(line => line.includes('scalar Brr'));
expect(line).toEqual(
`scalar Brr @join__type(graph: FOO_GRAPHQL) @join__type(graph: FOO2_GRAPHQL) `,
);
});

View file

@ -7,9 +7,12 @@ import {
type NameNode,
} from 'graphql';
import type { ServiceLogger } from '@hive/service-common';
import { extractLinkImplementations } from '@theguild/federation-composition';
import {
addInaccessibleToUnreachableTypes,
applyTagFilterOnSubgraphs,
extractLinkImplementations,
} from '@theguild/federation-composition';
import type { ContractsInputType } from '../api';
import { addInaccessibleToUnreachableTypes } from '../lib/add-inaccessible-to-unreachable-types';
import {
composeExternalFederation,
composeFederationV1,
@ -18,7 +21,6 @@ import {
SubgraphInput,
} from '../lib/compose';
import {
applyTagFilterOnSubgraphs,
createTagDirectiveNameExtractionStrategy,
extractTagsFromDocument,
} from '../lib/federation-tag-extraction';
@ -260,38 +262,21 @@ export const createComposeFederation = (deps: ComposeFederationDeps) =>
) {
let supergraphSDL = parse(compositionResult.result.supergraph);
const { resolveImportName } = extractLinkImplementations(supergraphSDL);
const result = addInaccessibleToUnreachableTypes(
resolveImportName,
compositionResult,
supergraphSDL,
);
if (result.type === 'success') {
return {
id: contract.id,
result: {
type: 'success',
result: {
supergraph: result.result.supergraph,
sdl: result.result.sdl,
},
},
} satisfies ContractResultSuccess;
}
const result = addInaccessibleToUnreachableTypes(resolveImportName, {
supergraphSdl: compositionResult.result.supergraph,
publicSdl: compositionResult.result.sdl,
});
return {
id: contract.id,
result: {
type: 'failure',
type: 'success',
result: {
supergraph: null,
sdl: null,
errors: result.result.errors,
includesNetworkError: false,
includesException: false,
supergraph: result.supergraphSdl,
sdl: result.publicSdl,
},
},
} satisfies ContractResultFailure;
} satisfies ContractResultSuccess;
}
if (compositionResult.type === 'success') {

View file

@ -1,101 +0,0 @@
import { parse } from 'graphql';
import { extractLinkImplementations } from '@theguild/federation-composition';
import { addInaccessibleToUnreachableTypes } from '../add-inaccessible-to-unreachable-types';
import { composeFederationV2 } from '../compose';
test('Filters based on tags', async () => {
let compositionResult = composeFederationV2(
[
{
name: 'user',
url: 'https://user-example.graphql-hive.com',
typeDefs: parse(`
type Query {
user: User
}
type User {
id: ID!
name: String
}
type Unused {
foo: String
}
`),
},
],
console as any,
);
expect(compositionResult.type).toBe('success');
if (compositionResult.type === 'success') {
let supergraphSDL = parse(compositionResult.result.supergraph);
const { resolveImportName } = extractLinkImplementations(supergraphSDL);
compositionResult = addInaccessibleToUnreachableTypes(
resolveImportName,
compositionResult,
supergraphSDL,
);
expect(compositionResult.result.supergraph).toMatchInlineSnapshot(`
schema @link(url: "https://specs.apollo.dev/link/v1.0") @link(url: "https://specs.apollo.dev/join/v0.3", for: EXECUTION) {
query: Query
}
directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE
directive @join__graph(name: String!, url: String!) on ENUM_VALUE
directive @join__field(graph: join__Graph, requires: join__FieldSet, provides: join__FieldSet, type: String, external: Boolean, override: String, usedOverridden: Boolean) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION
directive @join__implements(graph: join__Graph!, interface: String!) repeatable on OBJECT | INTERFACE
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__unionMember(graph: join__Graph!, member: String!) repeatable on UNION
scalar join__FieldSet
directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA
scalar link__Import
enum link__Purpose {
"""
\`SECURITY\` features provide metadata necessary to securely resolve fields.
"""
SECURITY
"""
\`EXECUTION\` features provide metadata necessary for operation execution.
"""
EXECUTION
}
enum join__Graph {
USER @join__graph(name: "user", url: "https://user-example.graphql-hive.com")
}
type Query @join__type(graph: USER) {
user: User
}
type User @join__type(graph: USER) {
id: ID!
name: String
}
type Unused @join__type(graph: USER) @inaccessible {
foo: String
}
`);
expect(compositionResult.result.sdl).toMatchInlineSnapshot(`
type Query {
user: User
}
type User {
id: ID!
name: String
}
`);
}
});

View file

@ -1,320 +0,0 @@
import { parse, print } from 'graphql';
import { addDirectiveOnTypes, getReachableTypes } from '../reachable-type-filter';
describe('getReachableTypes', () => {
it('includes the query type', () => {
const documentNode = parse(/* GraphQL */ `
type Query {
hello: String
}
`);
const reachableTypes = getReachableTypes(documentNode);
expect(reachableTypes.size).toEqual(1);
expect(reachableTypes.has('Query')).toEqual(true);
});
it('includes the mutation type', () => {
const documentNode = parse(/* GraphQL */ `
type Mutation {
hello: String
}
`);
const reachableTypes = getReachableTypes(documentNode);
expect(reachableTypes.size).toEqual(1);
expect(reachableTypes.has('Mutation')).toEqual(true);
});
it('includes the subscription type', () => {
const documentNode = parse(/* GraphQL */ `
type Subscription {
hello: String
}
`);
const reachableTypes = getReachableTypes(documentNode);
expect(reachableTypes.size).toEqual(1);
expect(reachableTypes.has('Subscription')).toEqual(true);
});
it('excludes unused root type', () => {
const documentNode = parse(/* GraphQL */ `
type Query {
hello: String
}
type Mutation {
hello: String
}
schema {
query: Query
}
`);
const reachableTypes = getReachableTypes(documentNode);
expect(reachableTypes.size).toEqual(1);
expect(reachableTypes.has('Query')).toEqual(true);
expect(reachableTypes.has('Mutation')).toEqual(false);
});
it('includes object types referenced by root type', () => {
const documentNode = parse(/* GraphQL */ `
type Query {
hello: Hello
}
type Hello {
world: String
}
`);
const reachableTypes = getReachableTypes(documentNode);
expect(reachableTypes.size).toEqual(2);
expect(reachableTypes.has('Query')).toEqual(true);
expect(reachableTypes.has('Hello')).toEqual(true);
});
it('includes scalar types referenced by root type', () => {
const documentNode = parse(/* GraphQL */ `
type Query {
hello: Hello
}
scalar Hello
`);
const reachableTypes = getReachableTypes(documentNode);
expect(reachableTypes.size).toEqual(2);
expect(reachableTypes.has('Query')).toEqual(true);
expect(reachableTypes.has('Hello')).toEqual(true);
});
it('includes input types referenced by root type', () => {
const documentNode = parse(/* GraphQL */ `
type Query {
hello(input: Hello): String
}
input Hello {
world: String
}
`);
const reachableTypes = getReachableTypes(documentNode);
expect(reachableTypes.size).toEqual(2);
expect(reachableTypes.has('Query')).toEqual(true);
expect(reachableTypes.has('Hello')).toEqual(true);
});
it('includes enum types referenced by root type', () => {
const documentNode = parse(/* GraphQL */ `
type Query {
hello: Hello
}
enum Hello {
WORLD
}
`);
const reachableTypes = getReachableTypes(documentNode);
expect(reachableTypes.size).toEqual(2);
expect(reachableTypes.has('Query')).toEqual(true);
expect(reachableTypes.has('Hello')).toEqual(true);
});
it('includes union type and union members referenced by root type', () => {
const documentNode = parse(/* GraphQL */ `
type Query {
hello: Hello
}
union Hello = World
union Gang = World
type World {
world: String
}
`);
const reachableTypes = getReachableTypes(documentNode);
expect(reachableTypes.size).toEqual(3);
expect(reachableTypes.has('Query')).toEqual(true);
expect(reachableTypes.has('Hello')).toEqual(true);
expect(reachableTypes.has('World')).toEqual(true);
});
it('includes interface type and interface members referenced by root type', () => {
const documentNode = parse(/* GraphQL */ `
type Query {
hello: Hello
}
interface Hello {
world: String
}
type World implements Hello {
world: String
}
`);
const reachableTypes = getReachableTypes(documentNode);
expect(reachableTypes.size).toEqual(3);
expect(reachableTypes.has('Query')).toEqual(true);
expect(reachableTypes.has('Hello')).toEqual(true);
expect(reachableTypes.has('World')).toEqual(true);
});
it('includes input type referenced by input type', () => {
const documentNode = parse(/* GraphQL */ `
input Hello {
world: World
}
input World {
world: String
}
type Query {
hello(world: Hello): String
}
`);
const reachableTypes = getReachableTypes(documentNode);
expect(reachableTypes.size).toEqual(3);
expect(reachableTypes.has('Query')).toEqual(true);
expect(reachableTypes.has('Hello')).toEqual(true);
expect(reachableTypes.has('World')).toEqual(true);
});
});
describe('addDirectiveOnTypes', () => {
it('add directive on unused root type', () => {
const documentNode = parse(/* GraphQL */ `
type Query {
hello: String
}
type Mutation {
hello: String
}
schema {
query: Query
}
`);
const document = addDirectiveOnTypes({
documentNode,
excludedTypeNames: getReachableTypes(documentNode),
directiveName: 'inaccessible',
});
expect(print(document)).toMatchInlineSnapshot(`
type Query {
hello: String
}
type Mutation @inaccessible {
hello: String
}
schema {
query: Query
}
`);
});
it('does not re-apply directive', () => {
const documentNode = parse(/* GraphQL */ `
type Query {
hello: String
}
type Mutation @inaccessible {
hello: String
}
schema {
query: Query
}
directive @inaccessible on OBJECT
`);
const document = addDirectiveOnTypes({
documentNode,
excludedTypeNames: getReachableTypes(documentNode),
directiveName: 'inaccessible',
});
expect(print(document)).toMatchInlineSnapshot(`
type Query {
hello: String
}
type Mutation @inaccessible {
hello: String
}
schema {
query: Query
}
directive @inaccessible on OBJECT
`);
});
it('runs on supergraph', () => {
// This is technically not a fully valid supergraph document node.
// It only includes the minimum required types and directives to test the functionality.
const documentNode = parse(/* GraphQL */ `
type Query @join__type(graph: BAR_GRAPHQL) {
bar: Car @inaccessible
barHidden: String
}
type Bar @join__type(graph: BAR_GRAPHQL) {
hello: String
helloHidden: String
}
type Car @join__type(graph: BAR_GRAPHQL) {
hello: String @inaccessible
helloHidden: String
}
schema {
query: Query
}
####
# Note: all the directives and types below are part of a supergraph schema
####
directive @inaccessible on FIELD_DEFINITION | OBJECT | INTERFACE | UNION | ENUM | ENUM_VALUE | SCALAR | INPUT_OBJECT | INPUT_FIELD_DEFINITION | ARGUMENT_DEFINITION
scalar join__FieldSet
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__graph(name: String!, url: String!) on ENUM_VALUE
enum join__Graph {
BAR_GRAPHQL @join__graph(name: "bar.graphql", url: "")
}
`);
const excludedTypeNames = getReachableTypes(documentNode);
excludedTypeNames.add('join__Graph');
excludedTypeNames.add('join__FieldSet');
const document = addDirectiveOnTypes({
documentNode,
excludedTypeNames,
directiveName: 'inaccessible',
});
expect(print(document)).toMatchInlineSnapshot(`
type Query @join__type(graph: BAR_GRAPHQL) {
bar: Car @inaccessible
barHidden: String
}
type Bar @join__type(graph: BAR_GRAPHQL) @inaccessible {
hello: String
helloHidden: String
}
type Car @join__type(graph: BAR_GRAPHQL) {
hello: String @inaccessible
helloHidden: String
}
schema {
query: Query
}
directive @inaccessible on FIELD_DEFINITION | OBJECT | INTERFACE | UNION | ENUM | ENUM_VALUE | SCALAR | INPUT_OBJECT | INPUT_FIELD_DEFINITION | ARGUMENT_DEFINITION
scalar join__FieldSet
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__graph(name: String!, url: String!) on ENUM_VALUE
enum join__Graph {
BAR_GRAPHQL @join__graph(name: "bar.graphql", url: "")
}
`);
});
});

View file

@ -1,60 +0,0 @@
import { DocumentNode, parse, print } from 'graphql';
import { transformSupergraphToPublicSchema } from '@theguild/federation-composition';
import { ComposerMethodResult } from './compose';
import { addDirectiveOnTypes, getReachableTypes } from './reachable-type-filter';
/**
* Adds inaccessible directive to unreachable types
*
* @param resolveName
* @param compositionResult
* @param supergraphSDL
* @returns
*/
export const addInaccessibleToUnreachableTypes = (
resolveName: (identity: string, name: string) => string,
compositionResult: ComposerMethodResult,
supergraphSDL: DocumentNode,
): ComposerMethodResult => {
const inaccessibleDirectiveName = resolveName(
'https://specs.apollo.dev/inaccessible',
'@inaccessible',
);
const federationTypes = new Set([
resolveName('https://specs.apollo.dev/join', 'FieldSet'),
resolveName('https://specs.apollo.dev/join', 'Graph'),
resolveName('https://specs.apollo.dev/link', 'Import'),
resolveName('https://specs.apollo.dev/link', 'Purpose'),
resolveName('https://specs.apollo.dev/federation', 'Policy'),
resolveName('https://specs.apollo.dev/federation', 'Scope'),
resolveName('https://specs.apollo.dev/join', 'DirectiveArguments'),
]);
if (compositionResult.type === 'failure' || inaccessibleDirectiveName === undefined) {
// In case there is no inaccessible directive, we can't remove types from the public api schema as everything is reachable.
return compositionResult;
}
// we retrieve the list of reachable types from the public api sdl
const reachableTypeNames = getReachableTypes(parse(compositionResult.result.sdl!));
// apollo router does not like @inaccessible on federation types...
for (const federationType of federationTypes) {
reachableTypeNames.add(federationType);
}
// then we apply the filter to the supergraph SDL (which is the source for the public api sdl)
supergraphSDL = addDirectiveOnTypes({
documentNode: supergraphSDL,
excludedTypeNames: reachableTypeNames,
directiveName: inaccessibleDirectiveName,
});
return {
...compositionResult,
result: {
...compositionResult.result,
supergraph: print(supergraphSDL),
sdl: print(transformSupergraphToPublicSchema(supergraphSDL)),
},
};
};

View file

@ -1,418 +1,18 @@
import {
EnumTypeExtensionNode,
EnumValueDefinitionNode,
InputObjectTypeExtensionNode,
InterfaceTypeExtensionNode,
Kind,
NameNode,
ObjectTypeExtensionNode,
visit,
type ConstDirectiveNode,
type DirectiveNode,
type DocumentNode,
type EnumTypeDefinitionNode,
type EnumValueDefinitionNode,
type FieldDefinitionNode,
type InputObjectTypeDefinitionNode,
type InputValueDefinitionNode,
type InterfaceTypeDefinitionNode,
type ObjectTypeDefinitionNode,
type ScalarTypeDefinitionNode,
type UnionTypeDefinitionNode,
type NameNode,
} from 'graphql';
import { extractLinkImplementations } from '@theguild/federation-composition';
type TagExtractionStrategy = (directiveNode: DirectiveNode) => string | null;
function createTransformTagDirectives(tagDirectiveName: string, inaccessibleDirectiveName: string) {
return function transformTagDirectives(
node: { directives?: readonly ConstDirectiveNode[] },
/** if non-null, will add the inaccessible directive to the nodes directive if not already present. */
includeInaccessibleDirective: boolean = false,
): readonly ConstDirectiveNode[] {
let hasInaccessibleDirective = false;
const directives =
node.directives?.filter(directive => {
if (directive.name.value === inaccessibleDirectiveName) {
hasInaccessibleDirective = true;
}
return directive.name.value !== tagDirectiveName;
}) ?? [];
if (hasInaccessibleDirective === false && includeInaccessibleDirective) {
directives.push({
kind: Kind.DIRECTIVE,
name: {
kind: Kind.NAME,
value: inaccessibleDirectiveName,
},
});
}
return directives;
};
}
/** check whether two sets have an intersection with each other. */
function hasIntersection<T>(a: Set<T>, b: Set<T>): boolean {
if (a.size === 0 || b.size === 0) {
return false;
}
for (const item of a) {
if (b.has(item)) {
return true;
}
}
return false;
}
function getRootQueryTypeNameFromDocumentNode(document: DocumentNode) {
let queryName: string | null = 'Query';
for (const definition of document.definitions) {
if (definition.kind === Kind.SCHEMA_DEFINITION || definition.kind === Kind.SCHEMA_EXTENSION) {
for (const operationTypeDefinition of definition.operationTypes ?? []) {
if (operationTypeDefinition.operation === 'query') {
queryName = operationTypeDefinition.type.name.value;
}
}
}
}
return queryName;
}
type ObjectLikeNode =
| ObjectTypeExtensionNode
| ObjectTypeDefinitionNode
| InterfaceTypeDefinitionNode
| InterfaceTypeExtensionNode
| InputObjectTypeDefinitionNode
| InputObjectTypeExtensionNode;
/**
* Takes a subgraph document node and a set of tag filters and transforms the document node to contain `@inaccessible` directives on all fields not included by the applied filter.
* Note: you probably want to use `filterSubgraphs` instead, as it also applies the correct post step required after applying this.
*/
export function applyTagFilterToInaccessibleTransformOnSubgraphSchema(
documentNode: DocumentNode,
tagRegister: SchemaCoordinateToTagsRegistry,
filter: Federation2SubgraphDocumentNodeByTagsFilter,
): {
typeDefs: DocumentNode;
/** Types within THIS subgraph where all fields are inaccessible */
typesWithAllFieldsInaccessible: Map<string, boolean>;
transformTagDirectives: ReturnType<typeof createTransformTagDirectives>;
/** Types in this subgraph that have the inaccessible directive applied */
typesWithInaccessibleApplied: Set<string>;
} {
const { resolveImportName } = extractLinkImplementations(documentNode);
const inaccessibleDirectiveName = resolveImportName(
'https://specs.apollo.dev/federation',
'@inaccessible',
);
const tagDirectiveName = resolveImportName('https://specs.apollo.dev/federation', '@tag');
const externalDirectiveName = resolveImportName(
'https://specs.apollo.dev/federation',
'@external',
);
function getTagsForSchemaCoordinate(coordinate: string) {
return tagRegister.get(coordinate) ?? new Set();
}
const transformTagDirectives = createTransformTagDirectives(
tagDirectiveName,
inaccessibleDirectiveName,
);
const rootQueryTypeName = getRootQueryTypeNameFromDocumentNode(documentNode);
const typesWithAllFieldsInaccessibleTracker = new Map<string, boolean>();
function onAllFieldsInaccessible(name: string) {
const current = typesWithAllFieldsInaccessibleTracker.get(name);
if (current === undefined) {
typesWithAllFieldsInaccessibleTracker.set(name, true);
}
}
function onSomeFieldsAccessible(name: string) {
typesWithAllFieldsInaccessibleTracker.set(name, false);
}
function fieldArgumentHandler(
objectLikeNode: ObjectLikeNode,
fieldLikeNode: InputValueDefinitionNode | FieldDefinitionNode,
node: InputValueDefinitionNode,
) {
// Check for external tag because we cannot contribute directives to external fields.
if (node.directives?.find(d => d.name.value === externalDirectiveName)) {
return node;
}
const tagsOnNode = getTagsForSchemaCoordinate(
`${objectLikeNode.name.value}.${fieldLikeNode.name.value}(${node.name.value}:)`,
);
if (
(filter.include.size && !hasIntersection(tagsOnNode, filter.include)) ||
(filter.exclude.size && hasIntersection(tagsOnNode, filter.exclude))
) {
return {
...node,
directives: transformTagDirectives(node, true),
};
}
return {
...node,
directives: transformTagDirectives(node),
};
}
const definitionsBySchemaCoordinate = new Map<string, Array<ObjectLikeNode>>();
//
// A type can be defined multiple times within a subgraph and we need to find all implementations
// for determining whether the full type, or only some fields are part of the public contract schema
//
for (const definition of documentNode.definitions) {
switch (definition.kind) {
case Kind.OBJECT_TYPE_DEFINITION:
case Kind.OBJECT_TYPE_EXTENSION:
case Kind.INTERFACE_TYPE_DEFINITION:
case Kind.INTERFACE_TYPE_EXTENSION:
case Kind.INPUT_OBJECT_TYPE_DEFINITION:
case Kind.INPUT_OBJECT_TYPE_EXTENSION: {
let items = definitionsBySchemaCoordinate.get(definition.name.value);
if (!items) {
items = [];
definitionsBySchemaCoordinate.set(definition.name.value, items);
}
items.push(definition);
}
}
}
// Tracking for which type already has `@inaccessible` applied (can only occur once)
const typesWithInaccessibleApplied = new Set<string>();
// These are later used within the visitor to actually replace the nodes.
const replacementTypeNodes = new Map<ObjectLikeNode, ObjectLikeNode>();
for (const [typeName, nodes] of definitionsBySchemaCoordinate) {
/** After processing all nodes implementing a type, we know whether all or only some fields are inaccessible */
let isSomeFieldsAccessible = false;
for (const node of nodes) {
const tagsOnNode = getTagsForSchemaCoordinate(node.name.value);
let newNode = {
...node,
fields: node.fields?.map(fieldNode => {
const tagsOnNode = getTagsForSchemaCoordinate(
`${node.name.value}.${fieldNode.name.value}`,
);
if (fieldNode.kind === Kind.FIELD_DEFINITION) {
fieldNode = {
...fieldNode,
arguments: fieldNode.arguments?.map(argumentNode =>
fieldArgumentHandler(node, fieldNode, argumentNode),
),
} as FieldDefinitionNode;
}
// Check for external tag because we cannot contribute directives to external fields.
if (fieldNode.directives?.find(d => d.name.value === externalDirectiveName)) {
return fieldNode;
}
if (
(filter.include.size && !hasIntersection(tagsOnNode, filter.include)) ||
(filter.exclude.size && hasIntersection(tagsOnNode, filter.exclude))
) {
return {
...fieldNode,
directives: transformTagDirectives(fieldNode, true),
};
}
isSomeFieldsAccessible = true;
return {
...fieldNode,
directives: transformTagDirectives(fieldNode),
};
}),
} as ObjectLikeNode;
if (filter.exclude.size && hasIntersection(tagsOnNode, filter.exclude)) {
newNode = {
...newNode,
directives: transformTagDirectives(
node,
typesWithInaccessibleApplied.has(typeName) ? false : true,
),
} as ObjectLikeNode;
typesWithInaccessibleApplied.add(typeName);
} else {
newNode = {
...newNode,
directives: transformTagDirectives(node),
};
}
replacementTypeNodes.set(node, newNode);
}
// If some fields are accessible, we continue with the next type
if (isSomeFieldsAccessible) {
onSomeFieldsAccessible(typeName);
continue;
}
onAllFieldsInaccessible(typeName);
}
function fieldLikeObjectHandler(node: ObjectLikeNode) {
const newNode = replacementTypeNodes.get(node);
if (!newNode) {
throw new Error(
`Found type without transformation mapping. ${node.name.value} ${node.name.kind}`,
);
}
return newNode;
}
function enumHandler(node: EnumTypeDefinitionNode | EnumTypeExtensionNode) {
const tagsOnNode = getTagsForSchemaCoordinate(node.name.value);
let isAllFieldsInaccessible = true;
const newNode = {
...node,
values: node.values?.map(valueNode => {
const tagsOnNode = getTagsForSchemaCoordinate(`${node.name.value}.${valueNode.name.value}`);
if (
(filter.include.size && !hasIntersection(tagsOnNode, filter.include)) ||
(filter.exclude.size && hasIntersection(tagsOnNode, filter.exclude))
) {
return {
...valueNode,
directives: transformTagDirectives(valueNode, true),
};
}
isAllFieldsInaccessible = false;
return {
...valueNode,
directives: transformTagDirectives(valueNode),
};
}),
};
if (filter.exclude.size && hasIntersection(tagsOnNode, filter.exclude)) {
return {
...newNode,
directives: transformTagDirectives(node, true),
};
}
if (isAllFieldsInaccessible) {
onAllFieldsInaccessible(node.name.value);
} else {
onSomeFieldsAccessible(node.name.value);
}
return {
...newNode,
directives: transformTagDirectives(node),
};
}
function scalarAndUnionHandler(node: ScalarTypeDefinitionNode | UnionTypeDefinitionNode) {
const tagsOnNode = getTagsForSchemaCoordinate(node.name.value);
if (
(filter.include.size && !hasIntersection(tagsOnNode, filter.include)) ||
(filter.exclude.size && hasIntersection(tagsOnNode, filter.exclude))
) {
return {
...node,
directives: transformTagDirectives(node, true),
};
}
return {
...node,
directives: transformTagDirectives(node),
};
}
const typeDefs = visit(documentNode, {
[Kind.OBJECT_TYPE_DEFINITION]: fieldLikeObjectHandler,
[Kind.OBJECT_TYPE_EXTENSION]: fieldLikeObjectHandler,
[Kind.INTERFACE_TYPE_DEFINITION]: fieldLikeObjectHandler,
[Kind.INTERFACE_TYPE_EXTENSION]: fieldLikeObjectHandler,
[Kind.INPUT_OBJECT_TYPE_DEFINITION]: fieldLikeObjectHandler,
[Kind.INPUT_OBJECT_TYPE_EXTENSION]: fieldLikeObjectHandler,
[Kind.ENUM_TYPE_DEFINITION]: enumHandler,
[Kind.ENUM_TYPE_EXTENSION]: enumHandler,
[Kind.SCALAR_TYPE_DEFINITION]: scalarAndUnionHandler,
[Kind.UNION_TYPE_DEFINITION]: scalarAndUnionHandler,
});
typesWithAllFieldsInaccessibleTracker.delete(rootQueryTypeName);
return {
typeDefs,
typesWithAllFieldsInaccessible: typesWithAllFieldsInaccessibleTracker,
transformTagDirectives,
typesWithInaccessibleApplied,
};
}
function makeTypesFromSetInaccessible(
documentNode: DocumentNode,
types: Set<string>,
transformTagDirectives: ReturnType<typeof createTransformTagDirectives>,
) {
/** We can only apply `@inaccessible` once on each unique typename, otherwise we get a composition error */
const alreadyAppliedOnTypeNames = new Set<string>();
function typeHandler(
node:
| ObjectTypeExtensionNode
| ObjectTypeDefinitionNode
| InterfaceTypeDefinitionNode
| InterfaceTypeExtensionNode
| InputObjectTypeDefinitionNode
| InputObjectTypeExtensionNode
| EnumTypeDefinitionNode
| EnumTypeExtensionNode,
) {
if (types.has(node.name.value) === false || alreadyAppliedOnTypeNames.has(node.name.value)) {
return;
}
alreadyAppliedOnTypeNames.add(node.name.value);
return {
...node,
directives: transformTagDirectives(node, true),
};
}
return visit(documentNode, {
[Kind.OBJECT_TYPE_DEFINITION]: typeHandler,
[Kind.OBJECT_TYPE_EXTENSION]: typeHandler,
[Kind.INTERFACE_TYPE_DEFINITION]: typeHandler,
[Kind.INTERFACE_TYPE_EXTENSION]: typeHandler,
[Kind.INPUT_OBJECT_TYPE_DEFINITION]: typeHandler,
[Kind.INPUT_OBJECT_TYPE_EXTENSION]: typeHandler,
[Kind.ENUM_TYPE_DEFINITION]: typeHandler,
[Kind.ENUM_TYPE_EXTENSION]: typeHandler,
});
}
function collectTagsBySchemaCoordinateFromSubgraph(
documentNode: DocumentNode,
/** This map will be populated with values. */
@ -573,81 +173,6 @@ export function buildSchemaCoordinateTagRegister(
return schemaCoordinatesToTags;
}
/**
* Apply a tag filter to a set of subgraphs.
*/
export function applyTagFilterOnSubgraphs<
TType extends {
typeDefs: DocumentNode;
name: string;
},
>(subgraphs: Array<TType>, filter: Federation2SubgraphDocumentNodeByTagsFilter): Array<TType> {
// All combined @tag directive in all subgraphs per schema coordinate
const tagRegister = buildSchemaCoordinateTagRegister(subgraphs.map(s => s.typeDefs));
let filteredSubgraphs = subgraphs.map(subgraph => {
return {
...subgraph,
...applyTagFilterToInaccessibleTransformOnSubgraphSchema(
subgraph.typeDefs,
tagRegister,
filter,
),
};
});
const intersectionOfTypesWhereAllFieldsAreInaccessible = new Set<string>();
// We need to traverse all subgraphs to find the intersection of types where all fields are inaccessible.
// If a type is not present in any other subgraph, we can safely mark it as inaccessible.
filteredSubgraphs.forEach(subgraph => {
const otherSubgraphs = filteredSubgraphs.filter(sub => sub !== subgraph);
for (const [type, allFieldsInaccessible] of subgraph.typesWithAllFieldsInaccessible) {
if (
allFieldsInaccessible &&
otherSubgraphs.every(
sub =>
!sub.typesWithAllFieldsInaccessible.has(type) ||
sub.typesWithAllFieldsInaccessible.get(type) === true,
)
) {
intersectionOfTypesWhereAllFieldsAreInaccessible.add(type);
}
// let's not visit this type a second time...
otherSubgraphs.forEach(sub => {
sub.typesWithAllFieldsInaccessible.delete(type);
});
}
});
if (!intersectionOfTypesWhereAllFieldsAreInaccessible.size) {
return filteredSubgraphs;
}
return filteredSubgraphs.map(subgraph => ({
...subgraph,
typeDefs: makeTypesFromSetInaccessible(
subgraph.typeDefs,
/** We exclude the types that are already marked as inaccessible within the subgraph as we want to avoid `@inaccessible` applied more than once. */
difference(
intersectionOfTypesWhereAllFieldsAreInaccessible,
subgraph.typesWithInaccessibleApplied,
),
subgraph.transformTagDirectives,
),
}));
}
function difference<$Type>(set1: Set<$Type>, set2: Set<$Type>): Set<$Type> {
const result = new Set<$Type>();
set1.forEach(item => {
if (!set2.has(item)) {
result.add(item);
}
});
return result;
}
export const extractTagsFromDocument = (
documentNode: DocumentNode,
tagStrategy: TagExtractionStrategy,
@ -684,8 +209,3 @@ export function createTagDirectiveNameExtractionStrategy(
return null;
};
}
export type Federation2SubgraphDocumentNodeByTagsFilter = {
include: Set<string>;
exclude: Set<string>;
};

View file

@ -1,160 +0,0 @@
import {
buildASTSchema,
getNamedType,
isEnumType,
isInputObjectType,
isInterfaceType,
isObjectType,
isScalarType,
isUnionType,
Kind,
specifiedScalarTypes,
visit,
type ConstDirectiveNode,
type DocumentNode,
type EnumTypeDefinitionNode,
type GraphQLNamedType,
type GraphQLType,
type InputObjectTypeDefinitionNode,
type InterfaceTypeDefinitionNode,
type ObjectTypeDefinitionNode,
type ScalarTypeDefinitionNode,
type UnionTypeDefinitionNode,
} from 'graphql';
const specifiedScalarNames = new Set(specifiedScalarTypes.map(t => t.name));
/**
* Retrieve a named list of all types that are reachable from the root types.
*/
export function getReachableTypes(documentNode: DocumentNode): Set<string> {
const reachableTypeNames = new Set<string>();
const schema = buildASTSchema(documentNode);
const didVisitType = new Set<GraphQLType>();
const typeQueue: Array<GraphQLNamedType> = [];
const queryType = schema.getQueryType();
const mutationType = schema.getMutationType();
const subscriptionType = schema.getSubscriptionType();
if (queryType) {
processNamedType(queryType);
}
if (mutationType) {
processNamedType(mutationType);
}
if (subscriptionType) {
processNamedType(subscriptionType);
}
function processNamedType(tType: GraphQLNamedType) {
if (didVisitType.has(tType) || specifiedScalarNames.has(tType.name)) {
return;
}
didVisitType.add(tType);
typeQueue.push(tType);
reachableTypeNames.add(tType.name);
}
let currentType: GraphQLNamedType | undefined;
while ((currentType = typeQueue.shift())) {
if (isObjectType(currentType)) {
for (const field of Object.values(currentType.getFields())) {
const fieldType = getNamedType(field.type);
processNamedType(fieldType);
for (const arg of field.args) {
const argType = getNamedType(arg.type);
processNamedType(argType);
}
}
currentType.getInterfaces().forEach(processNamedType);
} else if (isInputObjectType(currentType)) {
for (const field of Object.values(currentType.getFields())) {
const fieldType = getNamedType(field.type);
processNamedType(fieldType);
}
} else if (isScalarType(currentType) || isEnumType(currentType)) {
reachableTypeNames.add(currentType.name);
} else if (isInterfaceType(currentType)) {
for (const field of Object.values(currentType.getFields())) {
const fieldType = getNamedType(field.type);
processNamedType(fieldType);
}
currentType.getInterfaces().forEach(processNamedType);
schema.getPossibleTypes(currentType).forEach(processNamedType);
} else if (isUnionType(currentType)) {
currentType.getTypes().forEach(processNamedType);
}
}
return reachableTypeNames;
}
/**
* Creates a function that adds a directive with a given name to a list of directives if it does not exist yet.
*/
function createAddDirectiveIfNotExists(directiveName: string) {
return function addDirectiveIfNotExists(
directives?: readonly ConstDirectiveNode[],
): readonly ConstDirectiveNode[] | void {
const hasInaccessibleDirective = !!directives?.some(
directive => directive.name.value === directiveName,
);
if (!hasInaccessibleDirective) {
return [
...(directives ?? []),
{
kind: Kind.DIRECTIVE,
name: {
kind: Kind.NAME,
value: directiveName,
},
},
];
}
return directives;
};
}
/**
* For a given GraphQL schema document node, add a directive with a given name to all types not in the provided set.
*/
export function addDirectiveOnTypes(args: {
documentNode: DocumentNode;
/** a set of excluded types. e.g. as retrieved from the `getReachableTypes` function. */
excludedTypeNames: Set<string>;
/** name of the directive that should be added on the types. */
directiveName: string;
}): DocumentNode {
const addDirectiveIfNotExists = createAddDirectiveIfNotExists(args.directiveName);
function onNamedTypeDefinitionNode(
node:
| ObjectTypeDefinitionNode
| UnionTypeDefinitionNode
| InterfaceTypeDefinitionNode
| ScalarTypeDefinitionNode
| EnumTypeDefinitionNode
| InputObjectTypeDefinitionNode,
) {
if (args.excludedTypeNames.has(node.name.value)) {
return;
}
return {
...node,
directives: addDirectiveIfNotExists(node.directives),
};
}
return visit(args.documentNode, {
[Kind.OBJECT_TYPE_DEFINITION]: onNamedTypeDefinitionNode,
[Kind.INTERFACE_TYPE_DEFINITION]: onNamedTypeDefinitionNode,
[Kind.UNION_TYPE_DEFINITION]: onNamedTypeDefinitionNode,
[Kind.ENUM_TYPE_DEFINITION]: onNamedTypeDefinitionNode,
[Kind.INPUT_OBJECT_TYPE_DEFINITION]: onNamedTypeDefinitionNode,
[Kind.SCALAR_TYPE_DEFINITION]: onNamedTypeDefinitionNode,
});
}

View file

@ -33,6 +33,7 @@
"@sentry/integrations": "7.114.0",
"@sentry/node": "7.120.2",
"@swc/core": "1.13.5",
"@theguild/federation-composition": "0.20.0",
"@trpc/client": "10.45.2",
"@trpc/server": "10.45.2",
"@whatwg-node/server": "0.10.5",

View file

@ -2,8 +2,7 @@ import { parse, type DocumentNode } from 'graphql';
import { createSchema } from 'graphql-yoga';
import { mergeTypeDefs } from '@graphql-tools/merge';
import { type Registry } from '@hive/api';
import { composeFederationV2 } from '@hive/schema/src/lib/compose';
import { applyTagFilterOnSubgraphs } from '@hive/schema/src/lib/federation-tag-extraction';
import { composeSchemaContract } from '@theguild/federation-composition';
/**
* Creates the public GraphQL schema from the private GraphQL Schema Registry definition.
@ -14,8 +13,7 @@ export function createPublicGraphQLSchema<TContext>(registry: Registry) {
throwOnConflict: true,
});
// Use our tag filter logic for marking everything not tagged with `@tag(name: "public")` as @inaccessible
const [filteredSubgraph] = applyTagFilterOnSubgraphs(
const compositionResult = composeSchemaContract(
[
{
name: 'public',
@ -26,26 +24,18 @@ export function createPublicGraphQLSchema<TContext>(registry: Registry) {
include: new Set(['public']),
exclude: new Set(),
},
true,
);
// Compose the filtered subgraph in order to receive the public schema SDL
const compositionResult = composeFederationV2([
{
typeDefs: filteredSubgraph.typeDefs,
name: 'server',
url: undefined,
},
]);
if (compositionResult.type === 'failure') {
if (compositionResult.errors) {
throw new Error(
'Could not create public GraphQL schema.\nEncountered the following composition errors:\n' +
compositionResult.result.errors.map(error => `- ${error.message}`).join('\n'),
compositionResult.errors.map(error => `- ${error.message}`).join('\n'),
);
}
return createSchema<TContext>({
typeDefs: parse(compositionResult.result.sdl),
typeDefs: parse(compositionResult.publicSdl),
resolvers: registry.resolvers,
resolverValidationOptions: {
// The resolvers still contain the ones of the public schema

View file

@ -140,6 +140,9 @@ importers:
'@theguild/eslint-config':
specifier: 0.12.1
version: 0.12.1(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3)
'@theguild/federation-composition':
specifier: 0.20.0
version: 0.20.0(graphql@16.9.0)
'@theguild/prettier-config':
specifier: 2.0.7
version: 2.0.7(prettier@3.4.2)
@ -316,8 +319,8 @@ importers:
specifier: workspace:*
version: link:../packages/services/storage
'@theguild/federation-composition':
specifier: 0.19.1
version: 0.19.1(graphql@16.9.0)
specifier: 0.20.0
version: 0.20.0(graphql@16.9.0)
'@trpc/client':
specifier: 10.45.2
version: 10.45.2(@trpc/server@10.45.2)
@ -453,8 +456,8 @@ importers:
specifier: 4.2.13
version: 4.2.13
'@theguild/federation-composition':
specifier: 0.19.1
version: 0.19.1(graphql@16.9.0)
specifier: 0.20.0
version: 0.20.0(graphql@16.9.0)
colors:
specifier: 1.4.0
version: 1.4.0
@ -764,8 +767,8 @@ importers:
specifier: 7.8.0
version: 7.8.0
'@theguild/federation-composition':
specifier: 0.19.1
version: 0.19.1(graphql@16.9.0)
specifier: 0.20.0
version: 0.20.0(graphql@16.9.0)
'@trpc/client':
specifier: 10.45.2
version: 10.45.2(@trpc/server@10.45.2)
@ -1161,8 +1164,8 @@ importers:
specifier: 7.120.2
version: 7.120.2
'@theguild/federation-composition':
specifier: 0.19.1
version: 0.19.1(graphql@16.9.0)
specifier: 0.20.0
version: 0.20.0(graphql@16.9.0)
'@trpc/server':
specifier: 10.45.2
version: 10.45.2
@ -1283,6 +1286,9 @@ importers:
'@swc/core':
specifier: 1.13.5
version: 1.13.5
'@theguild/federation-composition':
specifier: 0.20.0
version: 0.20.0(graphql@16.9.0)
'@trpc/client':
specifier: 10.45.2
version: 10.45.2(@trpc/server@10.45.2)
@ -3617,6 +3623,7 @@ packages:
'@fastify/vite@6.0.7':
resolution: {integrity: sha512-+dRo9KUkvmbqdmBskG02SwigWl06Mwkw8SBDK1zTNH6vd4DyXbRvI7RmJEmBkLouSU81KTzy1+OzwHSffqSD6w==}
bundledDependencies: []
'@floating-ui/core@1.2.6':
resolution: {integrity: sha512-EvYTiXet5XqweYGClEmpu3BoxmsQ4hkj3QaYA6qEnigCWffTP3vNRwBReTdrwDwo7OoJ3wM8Uoe9Uk4n+d4hfg==}
@ -7786,6 +7793,12 @@ packages:
peerDependencies:
graphql: ^16.0.0
'@theguild/federation-composition@0.20.0':
resolution: {integrity: sha512-vokfiXP3L0Iba5a4qFYVHXxxi/v1AiIVCGE9xJFbWAFCtUGrRUKz49vFsDqRLdLmFqa3gLcMjVXVN7RsOYTeXg==}
engines: {node: '>=18'}
peerDependencies:
graphql: ^16.0.0
'@theguild/prettier-config@2.0.7':
resolution: {integrity: sha512-FqpgGAaAFbYHFQmkWEZjIhqmk+Oow82/t+0k408qoBd9RsB4QTwSQSDDbNSgFa/K7c8Dcwau5z3XbHUR/ksKqw==}
peerDependencies:
@ -8845,11 +8858,6 @@ packages:
browser-tabs-lock@1.3.0:
resolution: {integrity: sha512-g6nHaobTiT0eMZ7jh16YpD2kcjAp+PInbiVq3M1x6KKaEIVhT4v9oURNIpZLOZ3LQbQ3XYfNhMAb/9hzNLIWrw==}
browserslist@4.24.3:
resolution: {integrity: sha512-1CPmv8iobE2fyRMV97dAcMVegvvWKxmq94hkLiAkUGwKVTyDLw33K+ZxiFrREKmmps4rIw6grcCFCnTMSZ/YiA==}
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
hasBin: true
browserslist@4.26.0:
resolution: {integrity: sha512-P9go2WrP9FiPwLv3zqRD/Uoxo0RSHjzFCiQz7d4vbmwNqQFo9T9WCeP/Qn5EbcKQY6DBbkxEXNcpJOmncNrb7A==}
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
@ -9960,9 +9968,6 @@ packages:
electron-to-chromium@1.5.218:
resolution: {integrity: sha512-uwwdN0TUHs8u6iRgN8vKeWZMRll4gBkz+QMqdS7DDe49uiK68/UX92lFb61oiFPrpYZNeZIqa4bA7O6Aiasnzg==}
electron-to-chromium@1.5.76:
resolution: {integrity: sha512-CjVQyG7n7Sr+eBXE86HIulnL5N8xZY1sgmOPGuq/F0Rr0FJq63lg0kEtOIDfZBk44FnDLf6FUJ+dsJcuiUDdDQ==}
emoji-regex@10.3.0:
resolution: {integrity: sha512-QpLs9D9v9kArv4lfDEgg1X/gN5XLnf/A6l9cs8SPZLRZR3ZkY9+kwIQTxm+fsSej5UMYGE8fdoaZVIBlqG0XTw==}
@ -13080,9 +13085,6 @@ packages:
node-int64@0.4.0:
resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==}
node-releases@2.0.19:
resolution: {integrity: sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==}
node-releases@2.0.21:
resolution: {integrity: sha512-5b0pgg78U3hwXkCM8Z9b2FJdPZlr9Psr9V2gQPESdGHqbntyFJKFW4r5TeWGFzafGY3hzs1JC62VEQMbl1JFkw==}
@ -15776,12 +15778,6 @@ packages:
resolution: {integrity: sha512-aZwGpamFO61g3OlfT7OQCHqhGnW43ieH9WZeP7QxN/G/jS4jfqUkZxoryvJgVPEcrl5NL/ggHsSmLMHuH64Lhg==}
engines: {node: '>=4'}
update-browserslist-db@1.1.1:
resolution: {integrity: sha512-R8UzCaa9Az+38REPiJ1tXlImTJXlVfgHZsglwBD/k6nj76ctsH1E3q4doGrukiLQd3sGQYu56r5+lo5r94l29A==}
hasBin: true
peerDependencies:
browserslist: '>= 4.21.0'
update-browserslist-db@1.1.3:
resolution: {integrity: sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==}
hasBin: true
@ -17709,7 +17705,7 @@ snapshots:
dependencies:
'@babel/compat-data': 7.26.3
'@babel/helper-validator-option': 7.25.9
browserslist: 4.24.3
browserslist: 4.26.0
lru-cache: 5.1.1
semver: 6.3.1
@ -24768,6 +24764,16 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@theguild/federation-composition@0.20.0(graphql@16.9.0)':
dependencies:
constant-case: 3.0.4
debug: 4.4.1(supports-color@8.1.1)
graphql: 16.9.0
json5: 2.2.3
lodash.sortby: 4.7.0
transitivePeerDependencies:
- supports-color
'@theguild/prettier-config@2.0.7(prettier@3.4.2)':
dependencies:
'@ianvs/prettier-plugin-sort-imports': 4.3.1(prettier@3.4.2)
@ -25991,13 +25997,6 @@ snapshots:
dependencies:
lodash: 4.17.21
browserslist@4.24.3:
dependencies:
caniuse-lite: 1.0.30001690
electron-to-chromium: 1.5.76
node-releases: 2.0.19
update-browserslist-db: 1.1.1(browserslist@4.24.3)
browserslist@4.26.0:
dependencies:
baseline-browser-mapping: 2.8.3
@ -26561,7 +26560,7 @@ snapshots:
core-js-compat@3.37.1:
dependencies:
browserslist: 4.24.3
browserslist: 4.26.0
core-js-pure@3.37.1: {}
@ -27233,8 +27232,6 @@ snapshots:
electron-to-chromium@1.5.218: {}
electron-to-chromium@1.5.76: {}
emoji-regex@10.3.0: {}
emoji-regex@8.0.0: {}
@ -31382,8 +31379,6 @@ snapshots:
node-int64@0.4.0: {}
node-releases@2.0.19: {}
node-releases@2.0.21: {}
node-sql-parser@4.12.0:
@ -34401,12 +34396,6 @@ snapshots:
upath@1.2.0: {}
update-browserslist-db@1.1.1(browserslist@4.24.3):
dependencies:
browserslist: 4.24.3
escalade: 3.2.0
picocolors: 1.1.1
update-browserslist-db@1.1.3(browserslist@4.26.0):
dependencies:
browserslist: 4.26.0