🐘 graphql consolidation, validators and cleanup
This commit is contained in:
302
utils/graphql.schema.utils.ts
Normal file
302
utils/graphql.schema.utils.ts
Normal file
@@ -0,0 +1,302 @@
|
||||
/**
|
||||
* @fileoverview GraphQL schema utilities for remote schema fetching and validation
|
||||
* @module utils/graphql.schema.utils
|
||||
* @description Provides utilities for fetching remote GraphQL schemas via introspection,
|
||||
* creating remote executors for schema stitching, and validating GraphQL schemas.
|
||||
* Includes retry logic, timeout handling, and comprehensive error management.
|
||||
*/
|
||||
|
||||
import { GraphQLSchema, getIntrospectionQuery, buildClientSchema, IntrospectionQuery } from "graphql";
|
||||
import { print } from "graphql";
|
||||
import { fetch } from "undici";
|
||||
|
||||
/**
|
||||
* Configuration for remote schema fetching
|
||||
* @interface RemoteSchemaConfig
|
||||
* @property {string} url - The URL of the remote GraphQL endpoint
|
||||
* @property {number} [timeout=10000] - Request timeout in milliseconds
|
||||
* @property {number} [retries=3] - Number of retry attempts for failed requests
|
||||
* @property {number} [retryDelay=2000] - Base delay between retries in milliseconds (uses exponential backoff)
|
||||
*/
|
||||
export interface RemoteSchemaConfig {
|
||||
url: string;
|
||||
timeout?: number;
|
||||
retries?: number;
|
||||
retryDelay?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of a schema fetch operation
|
||||
* @interface SchemaFetchResult
|
||||
* @property {boolean} success - Whether the fetch operation succeeded
|
||||
* @property {GraphQLSchema} [schema] - The fetched GraphQL schema (present if success is true)
|
||||
* @property {Error} [error] - Error object if the fetch failed
|
||||
* @property {number} attempts - Number of attempts made before success or final failure
|
||||
*/
|
||||
export interface SchemaFetchResult {
|
||||
success: boolean;
|
||||
schema?: GraphQLSchema;
|
||||
error?: Error;
|
||||
attempts: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch remote GraphQL schema via introspection with retry logic
|
||||
* @async
|
||||
* @function fetchRemoteSchema
|
||||
* @param {RemoteSchemaConfig} config - Configuration for the remote schema fetch
|
||||
* @returns {Promise<SchemaFetchResult>} Result object containing schema or error
|
||||
* @description Fetches a GraphQL schema from a remote endpoint using introspection.
|
||||
* Implements exponential backoff retry logic and timeout handling. The function will
|
||||
* retry failed requests up to the specified number of times with increasing delays.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const result = await fetchRemoteSchema({
|
||||
* url: 'http://localhost:3080/graphql',
|
||||
* timeout: 5000,
|
||||
* retries: 3,
|
||||
* retryDelay: 1000
|
||||
* });
|
||||
*
|
||||
* if (result.success) {
|
||||
* console.log('Schema fetched:', result.schema);
|
||||
* } else {
|
||||
* console.error('Failed after', result.attempts, 'attempts:', result.error);
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export async function fetchRemoteSchema(
|
||||
config: RemoteSchemaConfig
|
||||
): Promise<SchemaFetchResult> {
|
||||
const {
|
||||
url,
|
||||
timeout = 10000,
|
||||
retries = 3,
|
||||
retryDelay = 2000,
|
||||
} = config;
|
||||
|
||||
let lastError: Error | undefined;
|
||||
let attempts = 0;
|
||||
|
||||
for (let attempt = 1; attempt <= retries; attempt++) {
|
||||
attempts = attempt;
|
||||
|
||||
try {
|
||||
const introspectionQuery = getIntrospectionQuery();
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ query: introspectionQuery }),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`HTTP ${response.status}: ${response.statusText}`
|
||||
);
|
||||
}
|
||||
|
||||
const result = await response.json() as {
|
||||
data?: IntrospectionQuery;
|
||||
errors?: any[];
|
||||
};
|
||||
|
||||
if (result.errors && result.errors.length > 0) {
|
||||
throw new Error(
|
||||
`Introspection errors: ${JSON.stringify(result.errors)}`
|
||||
);
|
||||
}
|
||||
|
||||
if (!result.data) {
|
||||
throw new Error("No data returned from introspection query");
|
||||
}
|
||||
|
||||
const schema = buildClientSchema(result.data);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
schema,
|
||||
attempts,
|
||||
};
|
||||
} catch (fetchError: any) {
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (fetchError.name === "AbortError") {
|
||||
throw new Error(`Request timeout after ${timeout}ms`);
|
||||
}
|
||||
throw fetchError;
|
||||
}
|
||||
} catch (error: any) {
|
||||
lastError = error;
|
||||
|
||||
// Don't retry on the last attempt
|
||||
if (attempt < retries) {
|
||||
await sleep(retryDelay * attempt); // Exponential backoff
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: lastError || new Error("Unknown error during schema fetch"),
|
||||
attempts,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an executor function for remote GraphQL endpoint with error handling
|
||||
* @function createRemoteExecutor
|
||||
* @param {string} url - The URL of the remote GraphQL endpoint
|
||||
* @param {number} [timeout=30000] - Request timeout in milliseconds
|
||||
* @returns {Function} Executor function compatible with schema stitching
|
||||
* @description Creates an executor function that can be used with GraphQL schema stitching.
|
||||
* The executor handles query execution against a remote GraphQL endpoint, including
|
||||
* timeout handling and error formatting. Returns errors in GraphQL-compatible format.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const executor = createRemoteExecutor('http://localhost:3080/graphql', 10000);
|
||||
*
|
||||
* // Used in schema stitching:
|
||||
* const stitchedSchema = stitchSchemas({
|
||||
* subschemas: [{
|
||||
* schema: remoteSchema,
|
||||
* executor: executor
|
||||
* }]
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export function createRemoteExecutor(url: string, timeout: number = 30000) {
|
||||
return async ({ document, variables, context }: any) => {
|
||||
const query = print(document);
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ query, variables }),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
return {
|
||||
errors: [
|
||||
{
|
||||
message: `Remote GraphQL request failed: ${response.statusText}`,
|
||||
extensions: {
|
||||
code: "REMOTE_ERROR",
|
||||
status: response.status,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error: any) {
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
const errorMessage = error.name === "AbortError"
|
||||
? `Remote request timeout after ${timeout}ms`
|
||||
: `Remote GraphQL execution error: ${error.message}`;
|
||||
|
||||
return {
|
||||
errors: [
|
||||
{
|
||||
message: errorMessage,
|
||||
extensions: {
|
||||
code: error.name === "AbortError" ? "TIMEOUT" : "NETWORK_ERROR",
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validation result for GraphQL schema
|
||||
* @interface ValidationResult
|
||||
* @property {boolean} valid - Whether the schema is valid
|
||||
* @property {string[]} errors - Array of validation error messages
|
||||
*/
|
||||
interface ValidationResult {
|
||||
valid: boolean;
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a GraphQL schema for basic correctness
|
||||
* @function validateSchema
|
||||
* @param {GraphQLSchema} schema - The GraphQL schema to validate
|
||||
* @returns {ValidationResult} Validation result with status and any error messages
|
||||
* @description Performs basic validation on a GraphQL schema, checking for:
|
||||
* - Presence of a Query type
|
||||
* - At least one field in the Query type
|
||||
* Returns a result object indicating validity and any error messages.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const validation = validateSchema(mySchema);
|
||||
* if (!validation.valid) {
|
||||
* console.error('Schema validation failed:', validation.errors);
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function validateSchema(schema: GraphQLSchema): ValidationResult {
|
||||
const errors: string[] = [];
|
||||
|
||||
try {
|
||||
// Check if schema has Query type
|
||||
const queryType = schema.getQueryType();
|
||||
if (!queryType) {
|
||||
errors.push("Schema must have a Query type");
|
||||
}
|
||||
|
||||
// Check if schema has at least one field
|
||||
if (queryType && Object.keys(queryType.getFields()).length === 0) {
|
||||
errors.push("Query type must have at least one field");
|
||||
}
|
||||
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
errors,
|
||||
};
|
||||
} catch (error: any) {
|
||||
return {
|
||||
valid: false,
|
||||
errors: [`Schema validation error: ${error.message}`],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sleep utility for implementing retry delays
|
||||
* @private
|
||||
* @function sleep
|
||||
* @param {number} ms - Number of milliseconds to sleep
|
||||
* @returns {Promise<void>} Promise that resolves after the specified delay
|
||||
* @description Helper function that returns a promise which resolves after
|
||||
* the specified number of milliseconds. Used for implementing retry delays
|
||||
* with exponential backoff.
|
||||
*/
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
Reference in New Issue
Block a user