From 6d041157396021d735550f8a9c60bbdf79e8e5ef Mon Sep 17 00:00:00 2001 From: Phil Pluckthun Date: Thu, 28 Mar 2024 20:32:09 +0000 Subject: [PATCH] fix(internal): Improve loader by adding a merged loader and incorporating `SchemaOrigin` parsing (#165) --- .changeset/twelve-tips-rush.md | 6 ++ packages/cli-utils/src/index.ts | 7 +- packages/cli-utils/src/lsp.ts | 8 +- packages/cli-utils/src/tada.ts | 44 +--------- packages/internal/src/loaders/index.ts | 44 +++++++++- packages/internal/src/loaders/sdl.ts | 98 +++++++++++---------- packages/internal/src/loaders/types.ts | 20 ++++- packages/internal/src/loaders/url.ts | 117 +++++++++++++------------ 8 files changed, 185 insertions(+), 159 deletions(-) create mode 100644 .changeset/twelve-tips-rush.md diff --git a/.changeset/twelve-tips-rush.md b/.changeset/twelve-tips-rush.md new file mode 100644 index 00000000..94c6d859 --- /dev/null +++ b/.changeset/twelve-tips-rush.md @@ -0,0 +1,6 @@ +--- +"@gql.tada/cli-utils": patch +"@gql.tada/internal": patch +--- + +Update internal loader to merge them into one and incorporate `SchemaOrigin` parsing diff --git a/packages/cli-utils/src/index.ts b/packages/cli-utils/src/index.ts index 20bb6937..bd434d05 100644 --- a/packages/cli-utils/src/index.ts +++ b/packages/cli-utils/src/index.ts @@ -6,10 +6,10 @@ import { printSchema } from 'graphql'; import type { GraphQLSchema } from 'graphql'; import type { TsConfigJson } from 'type-fest'; -import { resolveTypeScriptRootDir } from '@gql.tada/internal'; +import { resolveTypeScriptRootDir, load } from '@gql.tada/internal'; import { getGraphQLSPConfig } from './lsp'; -import { ensureTadaIntrospection, makeLoader } from './tada'; +import { ensureTadaIntrospection } from './tada'; interface GenerateSchemaOptions { headers?: Record; @@ -21,7 +21,8 @@ export async function generateSchema( target: string, { headers, output, cwd = process.cwd() }: GenerateSchemaOptions ) { - const loader = makeLoader(cwd, headers ? { url: target, headers } : target); + const origin = headers ? { url: target, headers } : target; + const loader = load({ origin, rootPath: cwd }); let schema: GraphQLSchema | null; try { diff --git a/packages/cli-utils/src/lsp.ts b/packages/cli-utils/src/lsp.ts index bfb4ab2e..f51f6cf6 100644 --- a/packages/cli-utils/src/lsp.ts +++ b/packages/cli-utils/src/lsp.ts @@ -1,11 +1,5 @@ import type { TsConfigJson } from 'type-fest'; - -export type SchemaOrigin = - | string - | { - url: string; - headers: HeadersInit; - }; +import type { SchemaOrigin } from '@gql.tada/internal'; export type GraphQLSPConfig = { name: string; diff --git a/packages/cli-utils/src/tada.ts b/packages/cli-utils/src/tada.ts index 114b08c4..e63781ff 100644 --- a/packages/cli-utils/src/tada.ts +++ b/packages/cli-utils/src/tada.ts @@ -1,17 +1,14 @@ import { promises as fs } from 'node:fs'; import path from 'node:path'; import type { IntrospectionQuery } from 'graphql'; -import type { SchemaLoader } from '@gql.tada/internal'; import { + type SchemaOrigin, minifyIntrospection, outputIntrospectionFile, - loadFromSDL, - loadFromURL, + load, } from '@gql.tada/internal'; -import type { SchemaOrigin } from './lsp'; - /** * This function mimics the behavior of the LSP, this so we can ensure * that gql.tada will work in any environment. The JetBrains IDE's do not @@ -20,12 +17,12 @@ import type { SchemaOrigin } from './lsp'; * this function. */ export async function ensureTadaIntrospection( - schemaLocation: SchemaOrigin, + origin: SchemaOrigin, outputLocation: string, base: string = process.cwd(), shouldPreprocess = true ) { - const loader = makeLoader(base, schemaLocation); + const loader = load({ origin, rootPath: base }); let introspection: IntrospectionQuery | null; try { @@ -52,36 +49,3 @@ export async function ensureTadaIntrospection( console.error('Something went wrong while writing the introspection file', error); } } - -const getURLConfig = (origin: SchemaOrigin) => { - if (typeof origin === 'string') { - try { - return { url: new URL(origin) }; - } catch (_error) { - return null; - } - } else if (typeof origin.url === 'string') { - try { - return { - url: new URL(origin.url), - headers: origin.headers, - }; - } catch (error) { - throw new Error(`Input URL "${origin.url}" is invalid`); - } - } else { - return null; - } -}; - -export function makeLoader(root: string, origin: SchemaOrigin): SchemaLoader { - const urlOrigin = getURLConfig(origin); - if (urlOrigin) { - return loadFromURL(urlOrigin); - } else if (typeof origin === 'string') { - const file = path.resolve(root, origin); - return loadFromSDL({ file, assumeValid: true }); - } else { - throw new Error(`Configuration contains an invalid "schema" option`); - } -} diff --git a/packages/internal/src/loaders/index.ts b/packages/internal/src/loaders/index.ts index d19d799f..0ca7e352 100644 --- a/packages/internal/src/loaders/index.ts +++ b/packages/internal/src/loaders/index.ts @@ -1,3 +1,41 @@ -export type { SchemaLoader } from './types'; -export { loadFromSDL } from './sdl'; -export { loadFromURL } from './url'; +export type * from './types'; + +import path from 'node:path'; +import type { SchemaLoader, SchemaOrigin } from './types'; +import { loadFromSDL } from './sdl'; +import { loadFromURL } from './url'; + +export { loadFromSDL, loadFromURL }; + +const getURLConfig = (origin: SchemaOrigin | null) => { + try { + return ( + origin && { + url: new URL(typeof origin === 'object' ? origin.url : origin), + headers: typeof origin === 'object' ? origin.headers : undefined, + } + ); + } catch (_error) { + throw new Error(`Configuration contains an invalid "schema" option`); + } +}; + +export interface LoadConfig { + origin: SchemaOrigin; + rootPath?: string; + fetchInterval?: number; + assumeValid?: boolean; +} + +export function load(config: LoadConfig): SchemaLoader { + const urlOrigin = getURLConfig(origin); + if (urlOrigin) { + return loadFromURL({ ...urlOrigin, interval: config.fetchInterval }); + } else if (typeof origin === 'string') { + const file = config.rootPath ? path.resolve(config.rootPath, origin) : origin; + const assumeValid = config.assumeValid != null ? config.assumeValid : true; + return loadFromSDL({ file, assumeValid }); + } else { + throw new Error(`Configuration contains an invalid "schema" option`); + } +} diff --git a/packages/internal/src/loaders/sdl.ts b/packages/internal/src/loaders/sdl.ts index 81dd0b27..32eecac8 100644 --- a/packages/internal/src/loaders/sdl.ts +++ b/packages/internal/src/loaders/sdl.ts @@ -1,13 +1,13 @@ -import type { IntrospectionQuery, GraphQLSchema } from 'graphql'; +import type { IntrospectionQuery } from 'graphql'; import { buildSchema, buildClientSchema, executeSync } from 'graphql'; import { CombinedError } from '@urql/core'; import fs from 'node:fs/promises'; import path from 'node:path'; -import type { SupportedFeatures } from './query'; import { makeIntrospectionQuery } from './query'; +import type { SupportedFeatures } from './query'; -import type { SchemaLoader } from './types'; +import type { SchemaLoader, SchemaLoaderResult, OnSchemaUpdate } from './types'; interface LoadFromSDLConfig { assumeValid?: boolean; @@ -21,68 +21,62 @@ const ALL_SUPPORTED_FEATURES: SupportedFeatures = { }; export function loadFromSDL(config: LoadFromSDLConfig): SchemaLoader { - const subscriptions = new Set<() => void>(); + const subscriptions = new Set(); let controller: AbortController | null = null; - let introspection: IntrospectionQuery | null = null; - let schema: GraphQLSchema | null = null; + let result: SchemaLoaderResult | null = null; + + const load = async (): Promise => { + const ext = path.extname(config.file); + const data = await fs.readFile(config.file, { encoding: 'utf8' }); + if (ext === '.json') { + let introspection: IntrospectionQuery | null = null; + try { + introspection = JSON.parse(data); + } catch (_error) {} + return ( + introspection && { + introspection, + schema: buildClientSchema(introspection, { assumeValid: !!config.assumeValid }), + } + ); + } else { + const schema = buildSchema(data, { assumeValidSDL: !!config.assumeValid }); + const query = makeIntrospectionQuery(ALL_SUPPORTED_FEATURES); + const queryResult = executeSync({ schema, document: query }); + if (queryResult.errors) { + throw new CombinedError({ graphQLErrors: queryResult.errors as any[] }); + } else if (queryResult.data) { + const introspection = queryResult.data as unknown as IntrospectionQuery; + return { introspection, schema }; + } else { + return null; + } + } + }; const watch = async () => { controller = new AbortController(); const watcher = fs.watch(config.file, { - persistent: false, signal: controller.signal, + persistent: false, }); - try { - for await (const event of watcher) { - if (event.eventType === 'rename' || !subscriptions.size) break; - for (const subscriber of subscriptions) subscriber(); + for await (const _event of watcher) { + if ((result = await load())) { + for (const subscriber of subscriptions) subscriber(result); + } } } catch (error: any) { - if (error.name === 'AbortError') { - return; - } else { - throw error; - } + if (error.name !== 'AbortError') throw error; } finally { controller = null; } }; - const introspect = async () => { - const ext = path.extname(config.file); - const data = await fs.readFile(config.file, { encoding: 'utf8' }); - if (ext === '.json') { - introspection = JSON.parse(data) || null; - schema = - introspection && buildClientSchema(introspection, { assumeValid: !!config.assumeValid }); - return introspection; - } else { - schema = buildSchema(data, { assumeValidSDL: !!config.assumeValid }); - const query = makeIntrospectionQuery(ALL_SUPPORTED_FEATURES); - const result = executeSync({ schema, document: query }); - if (result.errors) { - throw new CombinedError({ graphQLErrors: result.errors as any[] }); - } else if (result.data) { - return (introspection = (result.data as any) || null); - } else { - return (introspection = null); - } - } - }; - return { - async loadIntrospection() { - return introspect(); - }, - async loadSchema() { - if (schema) { - return schema; - } else { - await this.loadIntrospection(); - return schema; - } + async load(reload?: boolean) { + return reload || !result ? (result = await load()) : result; }, notifyOnUpdate(onUpdate) { if (!subscriptions.size) watch(); @@ -94,5 +88,13 @@ export function loadFromSDL(config: LoadFromSDLConfig): SchemaLoader { } }; }, + async loadIntrospection() { + const result = await this.load(); + return result && result.introspection; + }, + async loadSchema() { + const result = await this.load(); + return result && result.schema; + }, }; } diff --git a/packages/internal/src/loaders/types.ts b/packages/internal/src/loaders/types.ts index 12c50e5a..3d464f09 100644 --- a/packages/internal/src/loaders/types.ts +++ b/packages/internal/src/loaders/types.ts @@ -1,7 +1,25 @@ import type { IntrospectionQuery, GraphQLSchema } from 'graphql'; +export interface SchemaLoaderResult { + introspection: IntrospectionQuery; + schema: GraphQLSchema; +} + +export type OnSchemaUpdate = (result: SchemaLoaderResult) => void; + export interface SchemaLoader { + load(reload?: boolean): Promise; + notifyOnUpdate(onUpdate: OnSchemaUpdate): () => void; + + /** @internal */ loadIntrospection(): Promise; + /** @internal */ loadSchema(): Promise; - notifyOnUpdate(onUpdate: () => void): () => void; } + +export type SchemaOrigin = + | string + | { + url: string; + headers?: HeadersInit; + }; diff --git a/packages/internal/src/loaders/url.ts b/packages/internal/src/loaders/url.ts index 9bb539ad..61327dce 100644 --- a/packages/internal/src/loaders/url.ts +++ b/packages/internal/src/loaders/url.ts @@ -1,13 +1,12 @@ -import type { IntrospectionQuery, GraphQLSchema } from 'graphql'; +import type { IntrospectionQuery } from 'graphql'; import { buildClientSchema } from 'graphql'; import { Client, fetchExchange } from '@urql/core'; import { retryExchange } from '@urql/exchange-retry'; -import type { SupportedFeatures, IntrospectSupportQueryData } from './query'; - import { makeIntrospectionQuery, makeIntrospectSupportQuery, toSupportedFeatures } from './query'; +import type { SupportedFeatures, IntrospectSupportQueryData } from './query'; -import type { SchemaLoader } from './types'; +import type { SchemaLoader, SchemaLoaderResult, OnSchemaUpdate } from './types'; interface LoadFromURLConfig { url: URL | string; @@ -29,12 +28,11 @@ const NO_SUPPORTED_FEATURES: SupportedFeatures = { export function loadFromURL(config: LoadFromURLConfig): SchemaLoader { const interval = config.interval || 60_000; - const subscriptions = new Set<() => void>(); + const subscriptions = new Set(); let timeoutID: NodeJS.Timeout | null = null; let supportedFeatures: SupportedFeatures | null = null; - let introspection: IntrospectionQuery | null = null; - let schema: GraphQLSchema | null = null; + let result: SchemaLoaderResult | null = null; const client = new Client({ url: `${config.url}`, @@ -44,76 +42,73 @@ export function loadFromURL(config: LoadFromURLConfig): SchemaLoader { const scheduleUpdate = () => { if (subscriptions.size && !timeoutID) { - timeoutID = setTimeout(() => { - for (const subscriber of subscriptions) subscriber(); + timeoutID = setTimeout(async () => { timeoutID = null; + try { + result = await load(); + } catch (_error) { + result = null; + } + if (result) for (const subscriber of subscriptions) subscriber(result); }, interval); } }; - const introspect = async (support: SupportedFeatures): Promise => { + const introspect = async (support: SupportedFeatures): Promise => { const query = makeIntrospectionQuery(support); - const result = await client.query(query, {}); - + const introspectionResult = await client.query(query, {}); try { - if (result.error && result.error.graphQLErrors.length > 0) { - schema = null; - return (introspection = null); - } else if (result.error) { - schema = null; - throw result.error; + if (introspectionResult.error) { + throw introspectionResult.error; + } else if (introspectionResult.data) { + const introspection = introspectionResult.data; + return { + introspection, + schema: buildClientSchema(introspection, { assumeValid: true }), + }; } else { - schema = null; - return (introspection = result.data || null); + return null; } } finally { scheduleUpdate(); } }; - return { - async loadIntrospection() { - if (!supportedFeatures) { - const query = makeIntrospectSupportQuery(); - const result = await client.query(query, {}); - if (result.error && result.error.graphQLErrors.length > 0) { - // If we failed to determine support, we try to activate all introspection features - introspection = await introspect(ALL_SUPPORTED_FEATURES); - if (introspection) { - // If we succeed, we can return the introspection and enable all introspection features - supportedFeatures = ALL_SUPPORTED_FEATURES; - return introspection; - } else { - // Otherwise, we assume no extra introspection features are supported, - // since all introspection spec additions were made in a single spec revision - supportedFeatures = NO_SUPPORTED_FEATURES; - } - } else if (result.data && !result.error) { - // Succeeding the support query, we get the supported features - supportedFeatures = toSupportedFeatures(result.data); - } else if (result.error) { - // On misc. error, we rethrow and reset supported features - supportedFeatures = null; - throw result.error; + const load = async (): Promise => { + if (!supportedFeatures) { + const query = makeIntrospectSupportQuery(); + const supportResult = await client.query(query, {}); + if (supportResult.error && supportResult.error.graphQLErrors.length > 0) { + // If we failed to determine support, we try to activate all introspection features + const _result = await introspect(ALL_SUPPORTED_FEATURES); + if (_result) { + // If we succeed, we can return the introspection and enable all introspection features + supportedFeatures = ALL_SUPPORTED_FEATURES; + return _result; } else { - // Otherwise we assume no features are supported + // Otherwise, we assume no extra introspection features are supported, + // since all introspection spec additions were made in a single spec revision supportedFeatures = NO_SUPPORTED_FEATURES; } - } - - return introspect(supportedFeatures); - }, - - async loadSchema() { - if (schema) { - return schema; + } else if (supportResult.data && !supportResult.error) { + // Succeeding the support query, we get the supported features + supportedFeatures = toSupportedFeatures(supportResult.data); + } else if (supportResult.error) { + // On misc. error, we rethrow and reset supported features + supportedFeatures = null; + throw supportResult.error; } else { - const introspection = await this.loadIntrospection(); - schema = introspection && buildClientSchema(introspection, { assumeValid: true }); - return schema; + // Otherwise we assume no features are supported + supportedFeatures = NO_SUPPORTED_FEATURES; } - }, + } + return introspect(supportedFeatures); + }; + return { + async load(reload?: boolean) { + return reload || !result ? (result = await load()) : result; + }, notifyOnUpdate(onUpdate) { subscriptions.add(onUpdate); return () => { @@ -124,5 +119,13 @@ export function loadFromURL(config: LoadFromURLConfig): SchemaLoader { } }; }, + async loadIntrospection() { + const result = await this.load(); + return result && result.introspection; + }, + async loadSchema() { + const result = await this.load(); + return result && result.schema; + }, }; }