Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Support globs and arrays of paths/globs for the schema option and generate-schema command arguments #287

Draft
wants to merge 7 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/angry-dancers-unite.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@gql.tada/cli-utils": minor
---

Allow `generate-schema` to accept globs or multiple paths/globs. The command will therefore now be able to merge separate SDL files into a single one.
5 changes: 5 additions & 0 deletions .changeset/two-parents-tie.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@gql.tada/internal": minor
---

Support globs and arrays of paths/globs to be passed to the `schema` option. This allows the local schema to be extended or a schema with split SDL files to be configured with `gql.tada` without any extra tooling.
4 changes: 2 additions & 2 deletions packages/cli-utils/src/commands/generate-schema/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,9 @@ const parseHeaders = (
export class GenerateSchema extends Command {
static paths = [['generate-schema'], ['generate', 'schema']];

input = Option.String({
input = Option.Rest({
name: 'schema',
required: true,
required: 1,
});

tsconfig = Option.String('--tsconfig,-c', {
Expand Down
9 changes: 6 additions & 3 deletions packages/cli-utils/src/commands/generate-schema/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import * as logger from './logger';

export interface SchemaOptions {
/** The filename to a `.graphql` SDL file, introspection JSON, or URL to a GraphQL API to introspect. */
input: string;
input: string | string[];
/** Object of headers to send when introspection a GraphQL API. */
headers: Record<string, string> | undefined;
/** The filename to write the GraphQL SDL file to.
Expand All @@ -23,7 +23,9 @@ export interface SchemaOptions {
}

export async function* run(tty: TTY, opts: SchemaOptions): AsyncIterable<ComposeInput> {
const origin = opts.headers ? { url: opts.input, headers: opts.headers } : opts.input;
const origin = opts.headers
? { url: Array.isArray(opts.input) ? opts.input[0] : opts.input, headers: opts.headers }
: opts.input;
const loader = load({ rootPath: process.cwd(), origin });

let schema: GraphQLSchema;
Expand Down Expand Up @@ -52,7 +54,8 @@ export async function* run(tty: TTY, opts: SchemaOptions): AsyncIterable<Compose
if (
'schema' in pluginConfig &&
typeof pluginConfig.schema === 'string' &&
path.extname(pluginConfig.schema) === '.graphql'
(path.extname(pluginConfig.schema) === '.graphql' ||
path.extname(pluginConfig.schema) === '.gql')
) {
destination = path.resolve(path.dirname(configResult.configPath), pluginConfig.schema);
} else if (!('schema' in pluginConfig)) {
Expand Down
5 changes: 5 additions & 0 deletions packages/internal/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,11 @@ const parseSchemaConfig = (input: unknown, rootPath: string): SchemaConfig => {
throw new TadaError(`Schema is not configured properly (Received: ${input})`);
}

if ('schema' in input && input.schema && Array.isArray(input.schema)) {
if (input.schema.some((include) => typeof include !== 'string')) {
throw new TadaError('All entries in `schema` array must be file paths');
}
}
if ('schema' in input && input.schema && typeof input.schema === 'object') {
const { schema } = input;
if (!('url' in schema)) {
Expand Down
14 changes: 6 additions & 8 deletions packages/internal/src/loaders/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
export type * from './types';

import path from 'node:path';
import { loadFromSDL } from './sdl';
import { loadFromURL } from './url';

Expand All @@ -18,7 +17,7 @@ export { loadFromSDL, loadFromURL };

export const getURLConfig = (origin: SchemaOrigin | null) => {
try {
return origin
return origin && !Array.isArray(origin)
? {
url: new URL(typeof origin === 'object' ? origin.url : origin),
headers: typeof origin === 'object' ? origin.headers : undefined,
Expand All @@ -42,13 +41,12 @@ export function load(config: LoadConfig): SchemaLoader {
interval: config.fetchInterval,
name: config.name,
});
} else if (typeof config.origin === 'string') {
const file = config.rootPath ? path.resolve(config.rootPath, config.origin) : config.origin;
const assumeValid = config.assumeValid != null ? config.assumeValid : true;
} else if (typeof config.origin === 'string' || Array.isArray(config.origin)) {
return loadFromSDL({
file,
assumeValid,
assumeValid: config.assumeValid != null ? config.assumeValid : true,
name: config.name,
rootPath: config.rootPath,
include: config.origin,
});
} else {
throw new Error(`Configuration contains an invalid "schema" option`);
Expand All @@ -61,7 +59,7 @@ export function loadRef(
const teardowns: (() => void)[] = [];

let _loaders: { input: SingleSchemaInput; loader: SchemaLoader }[] | undefined;
const getLoaders = (config?: BaseLoadConfig) => {
const getLoaders = (config: BaseLoadConfig) => {
if (!_loaders) {
_loaders = (('schemas' in input && input.schemas) || []).map((input) => ({
input,
Expand Down
66 changes: 49 additions & 17 deletions packages/internal/src/loaders/sdl.ts
Original file line number Diff line number Diff line change
@@ -1,29 +1,53 @@
import type { IntrospectionQuery } from 'graphql';
import { buildSchema, buildClientSchema, executeSync } from 'graphql';
import { CombinedError } from '@urql/core';
import ts from 'typescript';
import fs from 'node:fs/promises';
import path from 'node:path';

import { makeIntrospectionQuery, getPeerSupportedFeatures } from './introspection';

import type { SchemaLoader, SchemaLoaderResult, OnSchemaUpdate } from './types';

const EXTENSIONS = ['.graphql', '.gql', '.json'] as const;
const EXCLUDE = ['**/node_modules'] as const;

const readInclude = (rootPath: string, include: string[] | string) => {
const files = ts.sys.readDirectory(
rootPath,
EXTENSIONS,
EXCLUDE,
typeof include === 'string' ? [include] : include
);
if (files.length === 0) {
throw new Error(`No schema input was found at "${rootPath}".`);
} else if (files.length > 1 && files.some((file) => path.extname(file) === '.json')) {
throw new Error(
'Multiple schema inputs were passed, but at least one is a JSON file.\n' +
'A JSON introspection schema cannot be combined with other schemas.'
);
}
return files;
};

interface LoadFromSDLConfig {
rootPath: string;
include: string | string[];
name?: string;
assumeValid?: boolean;
file: string;
}

export function loadFromSDL(config: LoadFromSDLConfig): SchemaLoader {
const subscriptions = new Set<OnSchemaUpdate>();

let files: string[] | undefined;
let controller: AbortController | null = null;
let result: SchemaLoaderResult | null = null;

const load = async (): Promise<SchemaLoaderResult> => {
const ext = path.extname(config.file);
const data = await fs.readFile(config.file, { encoding: 'utf8' });
if (ext === '.json') {
files = readInclude(config.rootPath, config.include);
if (path.extname(files[0]) === '.json') {
const data = await fs.readFile(files[0], { encoding: 'utf8' });
const introspection = JSON.parse(data) as IntrospectionQuery | null;
if (!introspection || !introspection.__schema) {
throw new Error(
Expand All @@ -39,6 +63,9 @@ export function loadFromSDL(config: LoadFromSDLConfig): SchemaLoader {
schema: buildClientSchema(introspection, { assumeValid: !!config.assumeValid }),
};
} else {
const data = (await Promise.all(files.map((file) => fs.readFile(file, 'utf-8')))).join(
'\n\n'
);
const schema = buildSchema(data, { assumeValidSDL: !!config.assumeValid });
const query = makeIntrospectionQuery(getPeerSupportedFeatures());
const queryResult = executeSync({ schema, document: query });
Expand All @@ -60,21 +87,26 @@ export function loadFromSDL(config: LoadFromSDLConfig): SchemaLoader {
};

const watch = async () => {
if (!files) return;
controller = new AbortController();
const watcher = fs.watch(config.file, {
signal: controller.signal,
persistent: false,
});
try {
for await (const _event of watcher) {
if ((result = await load())) {
for (const subscriber of subscriptions) subscriber(result);
for (const file of files) {
const watcher = fs.watch(file, {
signal: controller!.signal,
persistent: false,
});
(async () => {
try {
for await (const _event of watcher) {
if ((result = await load())) {
for (const subscriber of subscriptions) subscriber(result);
}
}
} catch (error: any) {
if (error.name !== 'AbortError') throw error;
} finally {
controller = null;
}
}
} catch (error: any) {
if (error.name !== 'AbortError') throw error;
} finally {
controller = null;
})();
}
};

Expand Down
3 changes: 2 additions & 1 deletion packages/internal/src/loaders/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ export interface SchemaLoader {
}

export interface BaseLoadConfig {
rootPath?: string;
rootPath: string;
fetchInterval?: number;
assumeValid?: boolean;
}
Expand Down Expand Up @@ -56,6 +56,7 @@ export interface SchemaRef<Result = SchemaLoaderResult | null> {

export type SchemaOrigin =
| string
| string[]
| {
url: string;
headers?: HeadersInit;
Expand Down
6 changes: 5 additions & 1 deletion website/reference/config-format.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ the main plugin config or inside the `schemas[]` array items.
The `schema` option specifies how to load your GraphQL schema and currently allows
for three different schema formats. It accepts either:

- a path to a `.graphql` file containing a schema definition (in GraphQL SDL format)
- a path or glob to a `.graphql` file containing a schema definition (in GraphQL SDL format)
- a path to a `.json` file containing a schema’s introspection query data
- a URL to a GraphQL API that can be introspected

Expand Down Expand Up @@ -149,6 +149,10 @@ for three different schema formats. It accepts either:
```
:::

If your schema consists of multiple `.graphql` files, you may pass
an array of paths, a glob, or an array of mixed paths and globs
to your SDL files.

This option is used by both the `gql.tada` CLI and `@0no-co/graphqlsp`
and is required for all diagnostics and in-editor support to work.

Expand Down
52 changes: 26 additions & 26 deletions website/reference/gql-tada-cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,12 +68,12 @@ When this command is run inside a GitHub Action, [workflow commands](https://doc

### `generate-schema`

| Option | Description |
| --------------- | ---------------------------------------------------------------------------------------------------- |
| `schema` | URL to a GraphQL API or a path to a `.graphql` SDL file or introspection JSON. |
| `--tsconfig,-c` | Optionally, a `tsconfig.json` file to use instead of an automatically discovered one. |
| `--output,-o` | An output location to write the `.graphql` SDL file to. (Default: The `schema` configuration option) |
| `--header,` | A `key:value` header entry to use when retrieving the introspection from a GraphQL API. |
| Option | Description |
| --------------- | ----------------------------------------------------------------------------------------------------- |
| `schema` | URL to a GraphQL API or a path/glob to `.graphql` SDL files, or a path to an introspection JSON file. |
| `--tsconfig,-c` | Optionally, a `tsconfig.json` file to use instead of an automatically discovered one. |
| `--output,-o` | An output location to write the `.graphql` SDL file to. (Default: The `schema` configuration option) |
| `--header,` | A `key:value` header entry to use when retrieving the introspection from a GraphQL API. |

Oftentimes, an API may not be running in development, is maintained in a separate repository, or requires authorization headers, and specifying a URL in the `schema` configuration can slow down development.

Expand Down Expand Up @@ -121,12 +121,12 @@ When this command is run inside a GitHub Action, [workflow commands](https://doc

### `generate-persisted`

| Option | Description |
| ------------------- | ------------------------------------------------------------------------------------------------ |
| `--disable-normalization` | Whether to disable normalizing the GraphQL document. (Default: false) |
| `--tsconfig,-c` | Optionally, a `tsconfig.json` file to use instead of an automatically discovered one. |
| `--fail-on-warn,-w` | Triggers an error and a non-zero exit code if any warnings have been reported. |
| `--output,-o` | Specify where to output the file to. (Default: The `tadaPersistedLocation` configuration option) |
| Option | Description |
| ------------------------- | ------------------------------------------------------------------------------------------------ |
| `--disable-normalization` | Whether to disable normalizing the GraphQL document. (Default: false) |
| `--tsconfig,-c` | Optionally, a `tsconfig.json` file to use instead of an automatically discovered one. |
| `--fail-on-warn,-w` | Triggers an error and a non-zero exit code if any warnings have been reported. |
| `--output,-o` | Specify where to output the file to. (Default: The `tadaPersistedLocation` configuration option) |

The `gql.tada generate-persisted` command will scan your code for `graphql.persisted()` calls and generate
a JSON manifest file containing a mapping of document IDs to the GraphQL document strings.
Expand Down Expand Up @@ -171,13 +171,13 @@ await generateOutput({

### `generatePersisted()`

| | Description |
| ------------------- | --------------------------------------------------------------------------------------------------------------------- |
| `disableNormalization` | Disables normalizing the GraphQL document |
| `output` option | The filename to write the persisted JSON manifest file to (Default: the `tadaPersistedLocation` configuration option) |
| `tsconfig` option | The `tsconfig.json` to use instead of an automatically discovered one. |
| `failOnWarn` option | Whether to throw an error instead of logging warnings. |
| returns | A `Promise` that resolves when the task completes. |
| | Description |
| ---------------------- | --------------------------------------------------------------------------------------------------------------------- |
| `disableNormalization` | Disables normalizing the GraphQL document |
| `output` option | The filename to write the persisted JSON manifest file to (Default: the `tadaPersistedLocation` configuration option) |
| `tsconfig` option | The `tsconfig.json` to use instead of an automatically discovered one. |
| `failOnWarn` option | Whether to throw an error instead of logging warnings. |
| returns | A `Promise` that resolves when the task completes. |

The `generatePersisted()` function will scan your code for `graphql.persisted()` calls and generate
a JSON manifest file containing a mapping of document IDs to the GraphQL document strings.
Expand All @@ -200,13 +200,13 @@ await generatePersisted({

### `generateSchema()`

| | Description |
| ----------------- | ------------------------------------------------------------------------------------------------------ |
| `input` option | The filename to a `.graphql` SDL file, introspection JSON, or URL to a GraphQL API to introspect. |
| `headers` option | Optionally, an object of headers to send when introspecting a GraphQL API. |
| `output` option | The filename to write the persisted JSON manifest file to (Default: the `schema` configuration option) |
| `tsconfig` option | The `tsconfig.json` to use instead of an automatically discovered one. |
| returns | A `Promise` that resolves when the task completes. |
| | Description |
| ----------------- | ------------------------------------------------------------------------------------------------------------------- |
| `input` option | The path/glob to `.graphql` SDL files, a path to an introspection JSON file, or URL to a GraphQL API to introspect. |
| `headers` option | Optionally, an object of headers to send when introspecting a GraphQL API. |
| `output` option | The filename to write the persisted JSON manifest file to (Default: the `schema` configuration option) |
| `tsconfig` option | The `tsconfig.json` to use instead of an automatically discovered one. |
| returns | A `Promise` that resolves when the task completes. |

The `generateSchema()` function introspects a targeted GraphQL API by URL, a `.graphql` SDL
or introspection JSON file, and outputs a `.graphql` SDL file. Generating a `.graphql` SDL file is
Expand Down
Loading