Files
threetwo-core-service/utils/graphql.schema.utils.ts

303 lines
8.3 KiB
TypeScript

/**
* @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));
}