feat: introspection of subgraph schema in Hive CLI (#5853)

Co-authored-by: jdolle <1841898+jdolle@users.noreply.github.com>
Co-authored-by: Laurin Quast <laurinquast@googlemail.com>
This commit is contained in:
Kamil Kisiela 2026-03-17 14:18:25 +01:00 committed by GitHub
parent 3c457dfb86
commit 580918de79
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 595 additions and 154 deletions

View file

@ -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

View file

@ -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",

View file

@ -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(

View file

@ -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
`);
});

View file

@ -232,7 +232,7 @@ introspects a GraphQL Schema
```
USAGE
$ hive introspect LOCATION [--debug] [--write <value>] [--header <value>...]
$ hive introspect LOCATION [--debug] [--write <value>] [--header <value>...] [--type <value>]
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=<value>... HTTP header to add to the introspection request (in key:value format)
--type=<value> Type of the endpoint (possible types: 'federation', 'graphql'). If not provided federation
introspection followed by graphql introspection is attempted.
--write=<value> Write to a file (possible extensions: .graphql, .gql, .gqls, .graphqls, .json)
DESCRIPTION

View file

@ -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<T extends typeof Command> = Interfaces.InferredFlags<
@ -64,6 +57,16 @@ export default abstract class BaseCommand<T extends typeof Command> extends Comm
this.args = args as Args<T>;
}
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<T extends typeof Command> 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<T extends typeof Command> extends Comm
'graphql-client-version': this.config.version,
};
return this.graphql(registry, requestHeaders);
}
graphql(endpoint: string, additionalHeaders: Record<string, string> = {}) {
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 <TResult, TVariables>(
args: {
operation: TypedDocumentNode<TResult, TVariables>;
/** timeout in milliseconds */
timeout?: number;
} & (TVariables extends Record<string, never>
? {
variables?: never;
}
: {
variables: TVariables;
}),
): Promise<TResult> => {
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<TResult>;
} 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<

View file

@ -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<typeof Dev> {
}
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();

View file

@ -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<typeof Introspect> {
@ -18,6 +22,12 @@ export default class Introspect extends Command<typeof Introspect> {
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<typeof Introspect> {
{} as Record<string, string>,
);
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) {

View file

@ -225,7 +225,9 @@ export default class SchemaCheck extends Command<typeof SchemaCheck> {
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(() => {

View file

@ -273,7 +273,9 @@ export default class SchemaPublish extends Command<typeof SchemaPublish> {
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);

View file

@ -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<string, string>;
version?: string;
logger?: LegacyLogger;
}) {
const requestHeaders = {
'Content-Type': 'application/json',
Accept: 'application/json',
'User-Agent': `hive-cli/${config.version}`,
...config.additionalHeaders,
};
return {
request: async <TResult, TVariables>(
args: {
operation: TypedDocumentNode<TResult, TVariables>;
/** timeout in milliseconds */
timeout?: number;
} & (TVariables extends Record<string, never>
? {
variables?: never;
}
: {
variables: TVariables;
}),
): Promise<TResult> => {
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<TResult>;
} 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;
}

View file

@ -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<SeverityLevelType, string> = {
@ -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<string, string>;
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<string, string> },
): Promise<Array<Source>> {
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<string, string> }) {
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;
}
}

View file

@ -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]

View file

@ -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