mirror of
https://github.com/graphql-hive/console
synced 2026-04-21 14:37:17 +00:00
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:
parent
3c457dfb86
commit
580918de79
14 changed files with 595 additions and 154 deletions
29
.changeset/large-oranges-suffer.md
Normal file
29
.changeset/large-oranges-suffer.md
Normal 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
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
261
integration-tests/tests/cli/introspect.spec.ts
Normal file
261
integration-tests/tests/cli/introspect.spec.ts
Normal 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
|
||||
`);
|
||||
});
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
110
packages/libraries/cli/src/helpers/graphql-request.ts
Normal file
110
packages/libraries/cli/src/helpers/graphql-request.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue