Skip to content

Commit

Permalink
introspection of subgraph schema in Hive CLI
Browse files Browse the repository at this point in the history
  • Loading branch information
kamilkisiela committed Oct 30, 2024
1 parent 1c6e315 commit be1c01b
Show file tree
Hide file tree
Showing 7 changed files with 176 additions and 86 deletions.
16 changes: 16 additions & 0 deletions .changeset/large-oranges-suffer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
'@graphql-hive/cli': minor
---

Support introspection of 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 expose the schema as `.graphql` file.

---

This change slightly changes the previous behavior of the `$ hive introspect` command. If the introspected GraphQL API is capable of resolving `{ _service { sdl } }` query, the command will use it to fetch the schema. Otherwise, it will use the regular introspection query.

This change is backward-compatible for most, if not all users and should not affect the existing workflows.

In case it does, you can use the `$ hive introspect --ignore-federation` flag to revert to the previous behavior.
147 changes: 78 additions & 69 deletions packages/libraries/cli/src/base-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,75 +181,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 {
async request<TResult, TVariables>(
args: {
operation: TypedDocumentNode<TResult, TVariables>;
/** timeout in milliseconds */
timeout?: number;
} & (TVariables extends Record<string, never>
? {
variables?: never;
}
: {
variables: TVariables;
}),
): Promise<TResult> {
const response = await http.post(
endpoint,
JSON.stringify({
query: typeof args.operation === 'string' ? args.operation : print(args.operation),
variables: args.variables,
}),
{
logger: {
info: (...args) => {
if (isDebug) {
console.info(...args);
}
},
error: (...args) => {
console.error(...args);
},
},
headers: requestHeaders,
timeout: args.timeout,
},
);

if (!response.ok) {
throw new Error(`Invalid status code for HTTP call: ${response.status}`);
}
const jsonData = (await response.json()) as ExecutionResult<TResult>;

if (jsonData.errors && jsonData.errors.length > 0) {
throw new ClientError(
`Failed to execute GraphQL operation: ${jsonData.errors
.map(e => e.message)
.join('\n')}`,
{
errors: jsonData.errors,
headers: response.headers,
},
);
}

return jsonData.data!;
},
};
return graphqlRequest({
endpoint: registry,
additionalHeaders: requestHeaders,
version: this.config.version,
debug: this.flags.debug,
});
}

handleFetchError(error: unknown): never {
Expand Down Expand Up @@ -307,3 +244,75 @@ class ClientError extends Error {
function isClientError(error: Error): error is ClientError {
return error instanceof ClientError;
}

export function graphqlRequest(config: {
endpoint: string;
additionalHeaders?: Record<string, string>;
version?: string;
debug?: boolean;
}) {
const requestHeaders = {
'Content-Type': 'application/json',
Accept: 'application/json',
...(config.version ? { 'User-Agent': `hive-cli/${config.version}` } : {}),
...(config.additionalHeaders ?? {}),
};

// const isDebug = this.flags.debug;
const isDebug = config.debug === true;

return {
async request<TResult, TVariables>(
args: {
operation: TypedDocumentNode<TResult, TVariables>;
/** timeout in milliseconds */
timeout?: number;
} & (TVariables extends Record<string, never>
? {
variables?: never;
}
: {
variables: TVariables;
}),
): Promise<TResult> {
const response = await http.post(
config.endpoint,
JSON.stringify({
query: typeof args.operation === 'string' ? args.operation : print(args.operation),
variables: args.variables,
}),
{
logger: {
info: (...args) => {
if (isDebug) {
console.info(...args);
}
},
error: (...args) => {
console.error(...args);
},
},
headers: requestHeaders,
timeout: args.timeout,
},
);

if (!response.ok) {
throw new Error(`Invalid status code for HTTP call: ${response.status}`);
}
const jsonData = (await response.json()) as ExecutionResult<TResult>;

if (jsonData.errors && jsonData.errors.length > 0) {
throw new ClientError(
`Failed to execute GraphQL operation: ${jsonData.errors.map(e => e.message).join('\n')}`,
{
errors: jsonData.errors,
headers: response.headers,
},
);
}

return jsonData.data!;
},
};
}
12 changes: 4 additions & 8 deletions packages/libraries/cli/src/commands/dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -471,20 +471,16 @@ export default class Dev extends Command<typeof Dev> {
}

private async resolveSdlFromPath(path: string) {
const sdl = await loadSchema(path);
const sdl = await loadSchema('introspection', path);
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 })
.catch(error => {
this.handleFetchError(error);
});

const sdl = result._service.sdl;
const sdl = await loadSchema('federation-subgraph', url).catch(error => {
this.handleFetchError(error);
});

if (!sdl) {
throw new Error('Failed to introspect service');
Expand Down
16 changes: 12 additions & 4 deletions packages/libraries/cli/src/commands/introspect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ export default class Introspect extends Command<typeof Introspect> {
description: 'HTTP header to add to the introspection request (in key:value format)',
multiple: true,
}),
['ignore-federation']: Flags.boolean({
description: `Ignore Federation's subgraph schema addition (Query._service field)`,
default: false,
}),
};

static args = {
Expand All @@ -42,10 +46,14 @@ export default class Introspect extends Command<typeof Introspect> {
{} as Record<string, string>,
);

const schema = await loadSchema(args.location, {
headers,
method: 'POST',
}).catch(err => {
const schema = await loadSchema(
flags['ignore-federation'] === true ? 'introspection' : 'federation-subgraph-introspection',
args.location,
{
headers,
method: 'POST',
},
).catch(err => {
if (err instanceof GraphQLError) {
this.fail(err.message);
this.exit(1);
Expand Down
2 changes: 1 addition & 1 deletion packages/libraries/cli/src/commands/schema/publish.ts
Original file line number Diff line number Diff line change
Expand Up @@ -254,7 +254,7 @@ export default class SchemaPublish extends Command<typeof SchemaPublish> {

let sdl: string;
try {
const rawSdl = await loadSchema(file);
const rawSdl = await loadSchema('introspection', file);
invariant(typeof rawSdl === 'string' && rawSdl.length > 0, 'Schema seems empty');
const transformedSDL = print(transformCommentsToDescriptions(rawSdl));
sdl = minifySchema(transformedSDL);
Expand Down
57 changes: 54 additions & 3 deletions packages/libraries/cli/src/helpers/schema.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import colors from 'colors';
import { concatAST, print } from 'graphql';
import { concatAST, parse, print } from 'graphql';
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 BaseCommand from '../base-command';
import type { Loader } from '@graphql-tools/utils';
import type { TypedDocumentNode } from '@graphql-typed-document-node/core';
import BaseCommand, { graphqlRequest } from '../base-command';
import { FragmentType, graphql, useFragment as unmaskFragment } from '../gql';
import { CriticalityLevel, SchemaErrorConnection, SchemaWarningConnection } from '../gql/graphql';

Expand Down Expand Up @@ -106,16 +108,33 @@ export function renderWarnings(this: BaseCommand<any>, warnings: SchemaWarningCo
}

export async function loadSchema(
/**
* This is used to determine the correct loader to use.
*
* If a user is simply introspecting a schema, the 'introspection' should be used.
* In case of federation, we should skip the UrlLoader,
* because it will try to introspect the schema
* instead of fetching the SDL with directives.
*/
intent: 'introspection' | 'federation-subgraph-introspection',
file: string,
options?: {
headers?: Record<string, string>;
method?: 'GET' | 'POST';
},
) {
const loaders: Loader[] = [new CodeFileLoader(), new GraphQLFileLoader(), new JsonFileLoader()];

if (intent === 'federation-subgraph-introspection') {
loaders.push(new FederationSubgraphUrlLoader());
} else {
loaders.push(new UrlLoader());
}

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!)));
Expand All @@ -124,3 +143,35 @@ export async function loadSchema(
export function minifySchema(schema: string): string {
return schema.replace(/\s+/g, ' ').trim();
}

class FederationSubgraphUrlLoader implements Loader {
async load(pointer: string) {
if (!pointer.startsWith('http://') && !pointer.startsWith('https://')) {
return null;
}

const response = await graphqlRequest({
endpoint: pointer,
}).request({
operation: parse(`
query GetFederationSchema {
_service {
sdl
}
}
`) as TypedDocumentNode<{ _service: { sdl: string } }, {}>,
variables: undefined,
});

const sdl = minifySchema(response._service.sdl);

return [
{
get document() {
return parse(sdl);
},
rawSDL: sdl,
},
];
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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]
Expand Down

0 comments on commit be1c01b

Please sign in to comment.