diff --git a/.changeset/large-oranges-suffer.md b/.changeset/large-oranges-suffer.md new file mode 100644 index 000000000..2cc8dc398 --- /dev/null +++ b/.changeset/large-oranges-suffer.md @@ -0,0 +1,29 @@ +--- +'@graphql-hive/cli': minor +--- + +Support introspection of federated subgraph's schema in the `$ hive introspect` command. + +This change allows developers to extract the schema of a subgraph (GraphQL Federation) +from a running service. It is useful if the GraphQL framework used in the subgraph +does not emit the schema as `.graphqls` file during build. + +--- + +The CLI attempts to automatically detect whether the endpoint is a a GraphQL Federation, by checking whether the `_Service` type is accessible via introspection. + +If you want to either force Apollo Subgraph or GraphQL introspection you can do that via the `--type` flag. + +```sh +# Force GraphQL Introspection +hive introspect --type graphql http://localhost:3000/graphql +``` + +```sh +# Force GraphQL Federation Introspection +hive introspect --type federation http://localhost:3000/graphql +``` + +The federation introspection requires the introspected GraphQL API is capable of resolving the following two queries: +- **`{ __type(name: "_Service") { name } }`** for looking up whether the GraphQL service is a Federation subgraph +- **`{ _service { sdl } }`** for retrieving the subgraph SDL diff --git a/integration-tests/package.json b/integration-tests/package.json index 5dff6af87..d1b919b77 100644 --- a/integration-tests/package.json +++ b/integration-tests/package.json @@ -13,6 +13,7 @@ "devDependencies": { "@apollo/gateway": "2.13.2", "@apollo/server": "5.4.0", + "@apollo/subgraph": "2.13.2", "@aws-sdk/client-s3": "3.723.0", "@esm2cjs/execa": "6.1.1-cjs.1", "@graphql-hive/apollo": "workspace:*", @@ -37,6 +38,7 @@ "dotenv": "16.4.7", "graphql": "16.9.0", "graphql-sse": "2.6.0", + "graphql-yoga": "5.13.3", "human-id": "4.1.1", "ioredis": "5.8.2", "set-cookie-parser": "2.7.1", diff --git a/integration-tests/testkit/cli.ts b/integration-tests/testkit/cli.ts index a53a60e2e..e7d4e3f47 100644 --- a/integration-tests/testkit/cli.ts +++ b/integration-tests/testkit/cli.ts @@ -45,6 +45,10 @@ export async function schemaPublish(args: string[]) { ); } +export async function introspect(args: string[]) { + return await exec(['introspect', ...args].join(' ')); +} + export async function appRetire(args: string[]) { const registryAddress = await getServiceHost('server', 8082); return await exec( diff --git a/integration-tests/tests/cli/introspect.spec.ts b/integration-tests/tests/cli/introspect.spec.ts new file mode 100644 index 000000000..fe892b108 --- /dev/null +++ b/integration-tests/tests/cli/introspect.spec.ts @@ -0,0 +1,261 @@ +import { AddressInfo } from 'node:net'; +import { parse } from 'graphql'; +import { createSchema, createYoga } from 'graphql-yoga'; +import { buildSubgraphSchema } from '@apollo/subgraph'; +import { createServer } from '@hive/service-common'; +import { introspect } from '../../testkit/cli'; + +async function createHTTPGraphQLServer() { + const server = await createServer({ + sentryErrorHandler: false, + log: { + requests: false, + level: 'silent', + }, + name: '', + }); + + const yoga = createYoga({ + logging: false, + schema: createSchema({ + typeDefs: /* GraphQL */ ` + type Query { + hello: String! + } + `, + }), + }); + + const yogaProtected = createYoga({ + graphqlEndpoint: '/graphql-protected', + logging: false, + schema: createSchema({ + typeDefs: /* GraphQL */ ` + type Query { + hello: String! + } + `, + }), + plugins: [ + { + onRequest(ctx) { + if (ctx.request.headers.get('x-auth') !== 'AUTH_AUTH_BABY') { + ctx.endResponse(new Response('Nah', { status: 403 })); + return; + } + }, + }, + ], + }); + + const yogaFederation = createYoga({ + graphqlEndpoint: '/graphql-federation', + logging: false, + schema: buildSubgraphSchema({ + typeDefs: parse(/* GraphQL */ ` + extend type Query { + me: User + user(id: ID!): User + users: [User] + } + + type User @key(fields: "id") { + id: ID! + name: String + username: String + } + `), + }), + }); + + const yogaFederationProtected = createYoga({ + graphqlEndpoint: '/graphql-federation-protected', + logging: false, + schema: buildSubgraphSchema({ + typeDefs: parse(/* GraphQL */ ` + extend type Query { + me: User + user(id: ID!): User + users: [User] + } + + type User @key(fields: "id") { + id: ID! + name: String + username: String + } + `), + }), + plugins: [ + { + onRequest(ctx) { + if (ctx.request.headers.get('x-auth') !== 'AUTH_AUTH_BABY') { + ctx.endResponse(new Response('Nah', { status: 403 })); + return; + } + }, + }, + ], + }); + + server.route({ + // Bind to the Yoga's endpoint to avoid rendering on any path + url: yoga.graphqlEndpoint, + method: ['GET', 'POST', 'OPTIONS'], + handler: (req, reply) => yoga.handleNodeRequestAndResponse(req, reply), + }); + + server.route({ + // Bind to the Yoga's endpoint to avoid rendering on any path + url: yogaProtected.graphqlEndpoint, + method: ['GET', 'POST', 'OPTIONS'], + handler: (req, reply) => yogaProtected.handleNodeRequestAndResponse(req, reply), + }); + + server.route({ + // Bind to the Yoga's endpoint to avoid rendering on any path + url: yogaFederation.graphqlEndpoint, + method: ['GET', 'POST', 'OPTIONS'], + handler: (req, reply) => yogaFederation.handleNodeRequestAndResponse(req, reply), + }); + + server.route({ + // Bind to the Yoga's endpoint to avoid rendering on any path + url: yogaFederationProtected.graphqlEndpoint, + method: ['GET', 'POST', 'OPTIONS'], + handler: (req, reply) => yogaFederationProtected.handleNodeRequestAndResponse(req, reply), + }); + + await server.listen({ + port: 0, + host: '0.0.0.0', + }); + + return { + url: 'http://localhost:' + (server.server.address() as AddressInfo).port, + [Symbol.asyncDispose]: () => { + server.close(); + }, + }; +} + +test.concurrent('can introspect monolith GraphQL service', async ({ expect }) => { + const server = await createHTTPGraphQLServer(); + await expect(introspect([server.url + '/graphql'])).resolves.toMatchInlineSnapshot(` + schema { + query: Query + } + + type Query { + hello: String! + } + `); +}); + +test.concurrent('can introspect federation GraphQL service', async ({ expect }) => { + const server = await createHTTPGraphQLServer(); + const url = server.url + '/graphql-federation'; + const result = await introspect([url]); + const forceResult = await introspect(['-t', 'federation', url]); + expect(result).toEqual(forceResult); + expect(result).toMatchInlineSnapshot(` + directive @key(fields: _FieldSet!, resolvable: Boolean = true) repeatable on OBJECT | INTERFACE + + directive @requires(fields: _FieldSet!) on FIELD_DEFINITION + + directive @provides(fields: _FieldSet!) on FIELD_DEFINITION + + directive @external(reason: String) on OBJECT | FIELD_DEFINITION + + directive @tag(name: String!) repeatable on FIELD_DEFINITION | OBJECT | INTERFACE | UNION | ARGUMENT_DEFINITION | SCALAR | ENUM | ENUM_VALUE | INPUT_OBJECT | INPUT_FIELD_DEFINITION + + directive @extends on OBJECT | INTERFACE + + type Query { + _entities(representations: [_Any!]!): [_Entity]! + _service: _Service! + } + + extend type Query { + me: User + user(id: ID!): User + users: [User] + } + + type User @key(fields: "id") { + id: ID! + name: String + username: String + } + + scalar _FieldSet + + scalar _Any + + type _Service { + sdl: String + } + + union _Entity = User + `); +}); + +test.concurrent('can introspect protected monolith with header', async ({ expect }) => { + const server = await createHTTPGraphQLServer(); + await expect(introspect([server.url + '/graphql-protected', '-H', 'x-auth:AUTH_AUTH_BABY'])) + .resolves.toMatchInlineSnapshot(` + schema { + query: Query + } + + type Query { + hello: String! + } + `); +}); + +test.concurrent('can introspect protected federation with header', async ({ expect }) => { + const server = await createHTTPGraphQLServer(); + await expect( + introspect([server.url + '/graphql-federation-protected', '-H', 'x-auth:AUTH_AUTH_BABY']), + ).resolves.toMatchInlineSnapshot(` + directive @key(fields: _FieldSet!, resolvable: Boolean = true) repeatable on OBJECT | INTERFACE + + directive @requires(fields: _FieldSet!) on FIELD_DEFINITION + + directive @provides(fields: _FieldSet!) on FIELD_DEFINITION + + directive @external(reason: String) on OBJECT | FIELD_DEFINITION + + directive @tag(name: String!) repeatable on FIELD_DEFINITION | OBJECT | INTERFACE | UNION | ARGUMENT_DEFINITION | SCALAR | ENUM | ENUM_VALUE | INPUT_OBJECT | INPUT_FIELD_DEFINITION + + directive @extends on OBJECT | INTERFACE + + type Query { + _entities(representations: [_Any!]!): [_Entity]! + _service: _Service! + } + + extend type Query { + me: User + user(id: ID!): User + users: [User] + } + + type User @key(fields: "id") { + id: ID! + name: String + username: String + } + + scalar _FieldSet + + scalar _Any + + type _Service { + sdl: String + } + + union _Entity = User + `); +}); diff --git a/packages/libraries/cli/README.md b/packages/libraries/cli/README.md index a4bba3b18..f62c58008 100644 --- a/packages/libraries/cli/README.md +++ b/packages/libraries/cli/README.md @@ -232,7 +232,7 @@ introspects a GraphQL Schema ``` USAGE - $ hive introspect LOCATION [--debug] [--write ] [--header ...] + $ hive introspect LOCATION [--debug] [--write ] [--header ...] [--type ] ARGUMENTS LOCATION GraphQL Schema location (URL or file path/glob) @@ -240,6 +240,8 @@ ARGUMENTS FLAGS --debug Whether debug output for HTTP calls and similar should be enabled. --header=... HTTP header to add to the introspection request (in key:value format) + --type= Type of the endpoint (possible types: 'federation', 'graphql'). If not provided federation + introspection followed by graphql introspection is attempted. --write= Write to a file (possible extensions: .graphql, .gql, .gqls, .graphqls, .json) DESCRIPTION diff --git a/packages/libraries/cli/src/base-command.ts b/packages/libraries/cli/src/base-command.ts index 3ac9329b7..815246b72 100644 --- a/packages/libraries/cli/src/base-command.ts +++ b/packages/libraries/cli/src/base-command.ts @@ -1,21 +1,14 @@ import { existsSync, readFileSync } from 'node:fs'; import { env } from 'node:process'; -import { print } from 'graphql'; -import type { ExecutionResult } from 'graphql'; -import { http } from '@graphql-hive/core'; -import type { TypedDocumentNode } from '@graphql-typed-document-node/core'; +import { Logger } from '@graphql-hive/core'; import { Command, Flags, Interfaces } from '@oclif/core'; import { Config, GetConfigurationValueType, ValidConfigurationKeys } from './helpers/config'; import { - APIError, FileMissingError, - HTTPError, InvalidFileContentsError, - InvalidRegistryTokenError, - isAggregateError, MissingArgumentsError, - NetworkError, } from './helpers/errors'; +import { graphqlRequest } from './helpers/graphql-request'; import { Texture } from './helpers/texture/texture'; export type Flags = Interfaces.InferredFlags< @@ -64,6 +57,16 @@ export default abstract class BaseCommand extends Comm this.args = args as Args; } + protected logger: Logger = { + info: (...args) => this.logInfo(...args), + error: (...args) => this.logFailure(...args), + debug: (...args) => { + if (this.flags.debug) { + this.logInfo(...args); + } + }, + }; + logSuccess(...args: any[]) { this.log(Texture.success(...args)); } @@ -167,10 +170,6 @@ export default abstract class BaseCommand extends Comm throw new MissingArgumentsError([String(key), description]); } - cleanRequestId(requestId?: string | null) { - return requestId ? requestId.split(',')[0].trim() : undefined; - } - registryApi(registry: string, token: string) { const requestHeaders = { Authorization: `Bearer ${token}`, @@ -178,112 +177,12 @@ export default abstract class BaseCommand extends Comm 'graphql-client-version': this.config.version, }; - return this.graphql(registry, requestHeaders); - } - - graphql(endpoint: string, additionalHeaders: Record = {}) { - const requestHeaders = { - 'Content-Type': 'application/json', - Accept: 'application/json', - 'User-Agent': `hive-cli/${this.config.version}`, - ...additionalHeaders, - }; - - const isDebug = this.flags.debug; - - return { - request: async ( - args: { - operation: TypedDocumentNode; - /** timeout in milliseconds */ - timeout?: number; - } & (TVariables extends Record - ? { - variables?: never; - } - : { - variables: TVariables; - }), - ): Promise => { - let response: Response; - try { - response = await http.post( - endpoint, - JSON.stringify({ - query: typeof args.operation === 'string' ? args.operation : print(args.operation), - variables: args.variables, - }), - { - logger: isDebug - ? { - info: (...args) => { - this.logInfo(...args); - }, - error: (...args) => { - this.logWarning(...args); - }, - debug: (...args) => { - this.logInfo(...args); - }, - } - : undefined, - headers: requestHeaders, - timeout: args.timeout, - }, - ); - } catch (e: any) { - const sourceError = e?.cause ?? e; - if (isAggregateError(sourceError)) { - throw new NetworkError(sourceError.errors[0]?.message); - } else { - throw new NetworkError(sourceError); - } - } - - if (!response.ok) { - throw new HTTPError( - endpoint, - response.status, - response.statusText ?? 'Invalid status code for HTTP call', - ); - } - - let jsonData; - try { - jsonData = (await response.json()) as ExecutionResult; - } catch (err) { - const contentType = response?.headers?.get('content-type'); - throw new APIError( - `Response from graphql was not valid JSON.${contentType ? ` Received "content-type": "${contentType}".` : ''}`, - this.cleanRequestId(response?.headers?.get('x-request-id')), - ); - } - - if (jsonData.errors && jsonData.errors.length > 0) { - if (jsonData.errors[0].extensions?.code === 'ERR_MISSING_TARGET') { - throw new MissingArgumentsError([ - 'target', - 'The target on which the action is performed.' + - ' This can either be a slug following the format "$organizationSlug/$projectSlug/$targetSlug" (e.g "the-guild/graphql-hive/staging")' + - ' or an UUID (e.g. "a0f4c605-6541-4350-8cfe-b31f21a4bf80").', - ]); - } - if (jsonData.errors[0].message === 'Invalid token provided') { - throw new InvalidRegistryTokenError(); - } - - if (isDebug) { - this.logFailure(jsonData.errors); - } - throw new APIError( - jsonData.errors.map(e => e.message).join('\n'), - this.cleanRequestId(response?.headers?.get('x-request-id')), - ); - } - - return jsonData.data!; - }, - }; + return graphqlRequest({ + endpoint: registry, + additionalHeaders: requestHeaders, + version: this.config.version, + logger: this.logger, + }); } async require< diff --git a/packages/libraries/cli/src/commands/dev.ts b/packages/libraries/cli/src/commands/dev.ts index f44294e92..060897623 100644 --- a/packages/libraries/cli/src/commands/dev.ts +++ b/packages/libraries/cli/src/commands/dev.ts @@ -1,7 +1,6 @@ import { writeFile } from 'node:fs/promises'; import { resolve } from 'node:path'; import { parse } from 'graphql'; -import type { TypedDocumentNode } from '@graphql-typed-document-node/core'; import { Flags } from '@oclif/core'; import { composeServices, @@ -49,22 +48,6 @@ const CLI_SchemaComposeMutation = graphql(/* GraphQL */ ` } `); -const ServiceIntrospectionQuery = /* GraphQL */ ` - query ServiceSdlQuery { - _service { - sdl - } - } -` as unknown as TypedDocumentNode< - { - __typename?: 'Query'; - _service: { sdl: string }; - }, - { - [key: string]: never; - } ->; - type ServiceName = string; type Sdl = string; @@ -514,16 +497,18 @@ export default class Dev extends Command { } private async resolveSdlFromPath(path: string) { - const sdl = await loadSchema(path); + const sdl = await loadSchema(null, path, { + logger: this.logger, + }); invariant(typeof sdl === 'string' && sdl.length > 0, `Read empty schema from ${path}`); return sdl; } private async resolveSdlFromUrl(url: string) { - const result = await this.graphql(url).request({ operation: ServiceIntrospectionQuery }); - - const sdl = result._service.sdl; + const sdl = await loadSchema('only-federation-introspection', url, { + logger: this.logger, + }); if (!sdl) { throw new IntrospectionError(); diff --git a/packages/libraries/cli/src/commands/introspect.ts b/packages/libraries/cli/src/commands/introspect.ts index 756ff5fb5..13c5e8274 100644 --- a/packages/libraries/cli/src/commands/introspect.ts +++ b/packages/libraries/cli/src/commands/introspect.ts @@ -3,7 +3,11 @@ import { extname, resolve } from 'node:path'; import { buildSchema, introspectionFromSchema } from 'graphql'; import { Args, Flags } from '@oclif/core'; import Command from '../base-command'; -import { APIError, UnexpectedError, UnsupportedFileExtensionError } from '../helpers/errors'; +import { + IntrospectionError, + UnexpectedError, + UnsupportedFileExtensionError, +} from '../helpers/errors'; import { loadSchema } from '../helpers/schema'; export default class Introspect extends Command { @@ -18,6 +22,12 @@ export default class Introspect extends Command { description: 'HTTP header to add to the introspection request (in key:value format)', multiple: true, }), + type: Flags.string({ + aliases: ['t'], + description: + "Type of the endpoint (possible types: 'federation', 'graphql')." + + ' If not provided federation introspection followed by graphql introspection is attempted.', + }), }; static args = { @@ -43,11 +53,20 @@ export default class Introspect extends Command { {} as Record, ); - const schema = await loadSchema(args.location, { - headers, - method: 'POST', - }).catch(err => { - throw new APIError(err); + let schema = await loadSchema( + !flags['type'] + ? 'first-federation-then-graphql-introspection' + : flags['type'] === 'federation' + ? 'only-federation-introspection' + : 'only-graphql-introspection', + args.location, + { + headers, + logger: this.logger, + }, + ).catch(err => { + this.logFailure(err); + throw new IntrospectionError(); }); if (!schema) { diff --git a/packages/libraries/cli/src/commands/schema/check.ts b/packages/libraries/cli/src/commands/schema/check.ts index 583be25f8..d200a4fae 100644 --- a/packages/libraries/cli/src/commands/schema/check.ts +++ b/packages/libraries/cli/src/commands/schema/check.ts @@ -225,7 +225,9 @@ export default class SchemaCheck extends Command { throw new MissingRegistryTokenError(); } - const sdl = await loadSchema(file).catch(e => { + const sdl = await loadSchema('first-federation-then-graphql-introspection', file, { + logger: this.logger, + }).catch(e => { throw new SchemaFileNotFoundError(file, e); }); const git = await gitInfo(() => { diff --git a/packages/libraries/cli/src/commands/schema/publish.ts b/packages/libraries/cli/src/commands/schema/publish.ts index ad9926263..ce209edc7 100644 --- a/packages/libraries/cli/src/commands/schema/publish.ts +++ b/packages/libraries/cli/src/commands/schema/publish.ts @@ -273,7 +273,9 @@ export default class SchemaPublish extends Command { let sdl: string; try { - const rawSdl = await loadSchema(file); + const rawSdl = await loadSchema('first-federation-then-graphql-introspection', file, { + logger: this.logger, + }); invariant(typeof rawSdl === 'string' && rawSdl.length > 0, 'Schema seems empty'); const transformedSDL = print(transformCommentsToDescriptions(rawSdl)); sdl = minifySchema(transformedSDL); diff --git a/packages/libraries/cli/src/helpers/graphql-request.ts b/packages/libraries/cli/src/helpers/graphql-request.ts new file mode 100644 index 000000000..ae6f17f4d --- /dev/null +++ b/packages/libraries/cli/src/helpers/graphql-request.ts @@ -0,0 +1,110 @@ +import { print, type ExecutionResult } from 'graphql'; +import { http } from '@graphql-hive/core'; +import { LegacyLogger } from '@graphql-hive/core/typings/client/types'; +import type { TypedDocumentNode } from '@graphql-typed-document-node/core'; +import { + APIError, + HTTPError, + InvalidRegistryTokenError, + isAggregateError, + MissingArgumentsError, + NetworkError, +} from './errors'; + +export function graphqlRequest(config: { + endpoint: string; + additionalHeaders?: Record; + version?: string; + logger?: LegacyLogger; +}) { + const requestHeaders = { + 'Content-Type': 'application/json', + Accept: 'application/json', + 'User-Agent': `hive-cli/${config.version}`, + ...config.additionalHeaders, + }; + + return { + request: async ( + args: { + operation: TypedDocumentNode; + /** timeout in milliseconds */ + timeout?: number; + } & (TVariables extends Record + ? { + variables?: never; + } + : { + variables: TVariables; + }), + ): Promise => { + let response: Response; + try { + response = await http.post( + config.endpoint, + JSON.stringify({ + query: typeof args.operation === 'string' ? args.operation : print(args.operation), + variables: args.variables, + }), + { + logger: config.logger, + headers: requestHeaders, + timeout: args.timeout, + }, + ); + } catch (e: any) { + const sourceError = e?.cause ?? e; + if (isAggregateError(sourceError)) { + throw new NetworkError(sourceError.errors[0]?.message); + } else { + throw new NetworkError(sourceError); + } + } + + if (!response.ok) { + throw new HTTPError( + config.endpoint, + response.status, + response.statusText ?? 'Invalid status code for HTTP call', + ); + } + + let jsonData; + try { + jsonData = (await response.json()) as ExecutionResult; + } catch (err) { + const contentType = response?.headers?.get('content-type'); + throw new APIError( + `Response from graphql was not valid JSON.${contentType ? ` Received "content-type": "${contentType}".` : ''}`, + cleanRequestId(response?.headers?.get('x-request-id')), + ); + } + + if (jsonData.errors && jsonData.errors.length > 0) { + if (jsonData.errors[0].extensions?.code === 'ERR_MISSING_TARGET') { + throw new MissingArgumentsError([ + 'target', + 'The target on which the action is performed.' + + ' This can either be a slug following the format "$organizationSlug/$projectSlug/$targetSlug" (e.g "the-guild/graphql-hive/staging")' + + ' or an UUID (e.g. "a0f4c605-6541-4350-8cfe-b31f21a4bf80").', + ]); + } + if (jsonData.errors[0].message === 'Invalid token provided') { + throw new InvalidRegistryTokenError(); + } + + config.logger?.debug?.(jsonData.errors.map(String).join('\n')); + throw new APIError( + jsonData.errors.map(e => e.message).join('\n'), + cleanRequestId(response?.headers?.get('x-request-id')), + ); + } + + return jsonData.data!; + }, + }; +} + +export function cleanRequestId(requestId?: string | null) { + return requestId ? requestId.split(',')[0].trim() : undefined; +} diff --git a/packages/libraries/cli/src/helpers/schema.ts b/packages/libraries/cli/src/helpers/schema.ts index 1736bfb5e..1a525ffb0 100644 --- a/packages/libraries/cli/src/helpers/schema.ts +++ b/packages/libraries/cli/src/helpers/schema.ts @@ -1,11 +1,15 @@ -import { concatAST, print, stripIgnoredCharacters } from 'graphql'; +import { concatAST, parse, print, stripIgnoredCharacters } from 'graphql'; +import { LegacyLogger } from '@graphql-hive/core/typings/client/types'; import { CodeFileLoader } from '@graphql-tools/code-file-loader'; import { GraphQLFileLoader } from '@graphql-tools/graphql-file-loader'; import { JsonFileLoader } from '@graphql-tools/json-file-loader'; import { loadTypedefs } from '@graphql-tools/load'; import { UrlLoader } from '@graphql-tools/url-loader'; +import type { BaseLoaderOptions, Loader, Source } from '@graphql-tools/utils'; +import type { TypedDocumentNode } from '@graphql-typed-document-node/core'; import { FragmentType, graphql, useFragment as unmaskFragment, useFragment } from '../gql'; import { SchemaWarningConnection, SeverityLevelType } from '../gql/graphql'; +import { graphqlRequest } from './graphql-request'; import { Texture } from './texture/texture'; const severityLevelMap: Record = { @@ -152,21 +156,127 @@ export const renderWarnings = (warnings: SchemaWarningConnection) => { }; export async function loadSchema( + /** + * Behaviour for loading the schema from a HTTP endpoint. + */ + httpLoadingIntent: + | 'first-federation-then-graphql-introspection' + | 'only-graphql-introspection' + | 'only-federation-introspection' + | null, file: string, - options?: { + options: { + logger: LegacyLogger; headers?: Record; method?: 'GET' | 'POST'; }, ) { + const logger = options?.logger; + const loaders: Loader[] = []; + + if (httpLoadingIntent === 'first-federation-then-graphql-introspection') { + loaders.unshift(new FederationSubgraphIntrospectionThenGraphQLIntrospectionUrlLoader(logger)); + } else if (httpLoadingIntent === 'only-federation-introspection') { + loaders.unshift(new FederationSubgraphUrlLoader(logger)); + } else if (httpLoadingIntent === 'only-graphql-introspection') { + loaders.unshift(new UrlLoader()); + } + + loaders.push(new CodeFileLoader(), new GraphQLFileLoader(), new JsonFileLoader()); + const sources = await loadTypedefs(file, { ...options, cwd: process.cwd(), - loaders: [new CodeFileLoader(), new GraphQLFileLoader(), new JsonFileLoader(), new UrlLoader()], + loaders, }); return print(concatAST(sources.map(s => s.document!))); } -export function minifySchema(schema: string) { +export function minifySchema(schema: string): string { return stripIgnoredCharacters(schema); } + +class FederationSubgraphUrlLoader implements Loader { + constructor(private logger?: LegacyLogger) {} + + async load( + pointer: string, + options?: BaseLoaderOptions & { headers?: Record }, + ): Promise> { + if (!pointer.startsWith('http://') && !pointer.startsWith('https://')) { + this.logger?.debug?.('Provided endpoint is not HTTP, skip introspection.'); + return []; + } + + const client = graphqlRequest({ + logger: this.logger, + endpoint: pointer, + additionalHeaders: { + ...options?.headers, + }, + }); + + this.logger?.debug?.('Attempt "_Service" type lookup via "Query.__type".'); + + // We can check if the schema is a subgraph by looking for the `_Service` type. + const isSubgraph = await client.request({ + operation: parse(/* GraphQL */ ` + query ${'LookupService'} { + __type(name: "_Service") ${' '}{ + name + } + } + `) as TypedDocumentNode<{ __type: null | { name: string } }, {}>, + }); + + if (isSubgraph.__type === null) { + this.logger?.debug?.('Type not found, this is not a Federation subgraph.'); + return []; + } + + this.logger?.debug?.( + 'Resolved "_Service" type. Federation subgraph detected.' + + 'Attempt Federation introspection via "Query._service" field.', + ); + + const response = await client.request({ + operation: parse(/* GraphQL */ ` + query ${'GetFederationSchema'} { + _service { + sdl + } + } + `) as TypedDocumentNode<{ _service: { sdl: string } }, {}>, + }); + + this.logger?.debug?.('Resolved subgraph SDL successfully.'); + + const sdl = minifySchema(response._service.sdl); + + return [ + { + document: parse(sdl), + rawSDL: sdl, + }, + ]; + } +} + +class FederationSubgraphIntrospectionThenGraphQLIntrospectionUrlLoader implements Loader { + private urlLoader = new UrlLoader(); + private federationLoader: FederationSubgraphUrlLoader; + constructor(private logger?: LegacyLogger) { + this.federationLoader = new FederationSubgraphUrlLoader(logger); + } + + async load(pointer: string, options: BaseLoaderOptions & { headers?: Record }) { + this.logger?.debug?.('Attempt federation introspection'); + let result = await this.federationLoader.load(pointer, options); + if (!result.length) { + this.logger?.debug?.('Attempt GraphQL introspection'); + result = await this.urlLoader.load(pointer, options); + } + return result; + } +} diff --git a/packages/web/docs/src/content/other-integrations/code-first.mdx b/packages/web/docs/src/content/other-integrations/code-first.mdx index ed874022b..9f7ffd46d 100644 --- a/packages/web/docs/src/content/other-integrations/code-first.mdx +++ b/packages/web/docs/src/content/other-integrations/code-first.mdx @@ -7,6 +7,16 @@ need to retrieve the schema SDL (in a `.graphql` file) before using it with the We've collected popular Code-First libraries and frameworks and created a quick guide for retrieving the GraphQL SDL before using it with the Hive CLI. +## Introspecting a running service + +If you're using a GraphQL framework that doesn't expose the schema as a `.graphql` file, you can use +the Hive CLI to introspect the schema from a running GraphQL API (GraphQL API (or a subgraph in case +of GraphQL Federation). + +```bash +hive introspect http://localhost:4000/graphql --write schema.graphql +``` + ## Pothos [Pothos](https://pothos-graphql.dev/) is a plugin based GraphQL schema builder for TypeScript. It @@ -99,7 +109,7 @@ GraphQL servers in Rust that are type-safe. The schema object of Juniper can be printted using the `as_schema_language` function: -```Rust +```rust struct Query; #[graphql_object] diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1d6b1011f..009935747 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -328,6 +328,9 @@ importers: '@apollo/server': specifier: 5.4.0 version: 5.4.0(graphql@16.9.0) + '@apollo/subgraph': + specifier: 2.13.2 + version: 2.13.2(graphql@16.9.0) '@aws-sdk/client-s3': specifier: 3.723.0 version: 3.723.0 @@ -400,6 +403,9 @@ importers: graphql-sse: specifier: 2.6.0 version: 2.6.0(graphql@16.9.0) + graphql-yoga: + specifier: 5.13.3 + version: 5.13.3(graphql@16.9.0) human-id: specifier: 4.1.1 version: 4.1.1