🐘 graphql consolidation, validators and cleanup

This commit is contained in:
2026-03-05 10:39:33 -05:00
parent 8a8acc656a
commit 17f80682e1
7 changed files with 2274 additions and 10 deletions

View File

@@ -0,0 +1,331 @@
/**
* @fileoverview GraphQL error handling utilities
* @module utils/graphql.error.utils
* @description Provides comprehensive error handling utilities for GraphQL operations,
* including standardized error codes, error creation, error transformation, logging,
* and error sanitization for client responses.
*/
import { GraphQLError } from "graphql";
/**
* Standardized error codes for GraphQL operations
* @enum {string}
* @description Comprehensive set of error codes covering client errors (4xx),
* server errors (5xx), GraphQL-specific errors, remote schema errors, and database errors.
*/
export enum GraphQLErrorCode {
// Client errors (4xx)
/** Bad request - malformed or invalid request */
BAD_REQUEST = "BAD_REQUEST",
/** Unauthorized - authentication required */
UNAUTHORIZED = "UNAUTHORIZED",
/** Forbidden - insufficient permissions */
FORBIDDEN = "FORBIDDEN",
/** Not found - requested resource doesn't exist */
NOT_FOUND = "NOT_FOUND",
/** Validation error - input validation failed */
VALIDATION_ERROR = "VALIDATION_ERROR",
// Server errors (5xx)
/** Internal server error - unexpected server-side error */
INTERNAL_SERVER_ERROR = "INTERNAL_SERVER_ERROR",
/** Service unavailable - service is temporarily unavailable */
SERVICE_UNAVAILABLE = "SERVICE_UNAVAILABLE",
/** Timeout - operation exceeded time limit */
TIMEOUT = "TIMEOUT",
// GraphQL specific
/** GraphQL parse failed - query syntax error */
GRAPHQL_PARSE_FAILED = "GRAPHQL_PARSE_FAILED",
/** GraphQL validation failed - query validation error */
GRAPHQL_VALIDATION_FAILED = "GRAPHQL_VALIDATION_FAILED",
// Remote schema errors
/** Remote schema error - error from remote GraphQL service */
REMOTE_SCHEMA_ERROR = "REMOTE_SCHEMA_ERROR",
/** Remote schema unavailable - cannot connect to remote schema */
REMOTE_SCHEMA_UNAVAILABLE = "REMOTE_SCHEMA_UNAVAILABLE",
// Database errors
/** Database error - database operation failed */
DATABASE_ERROR = "DATABASE_ERROR",
/** Document not found - requested document doesn't exist */
DOCUMENT_NOT_FOUND = "DOCUMENT_NOT_FOUND",
}
/**
* Create a standardized GraphQL error with consistent formatting
* @function createGraphQLError
* @param {string} message - Human-readable error message
* @param {GraphQLErrorCode} [code=INTERNAL_SERVER_ERROR] - Error code from GraphQLErrorCode enum
* @param {Record<string, any>} [extensions] - Additional error metadata
* @returns {GraphQLError} Formatted GraphQL error object
* @description Creates a GraphQL error with standardized structure including error code
* and optional extensions. The error code is automatically added to extensions.
*
* @example
* ```typescript
* throw createGraphQLError(
* 'Comic not found',
* GraphQLErrorCode.NOT_FOUND,
* { comicId: '123' }
* );
* ```
*/
export function createGraphQLError(
message: string,
code: GraphQLErrorCode = GraphQLErrorCode.INTERNAL_SERVER_ERROR,
extensions?: Record<string, any>
): GraphQLError {
return new GraphQLError(message, {
extensions: {
code,
...extensions,
},
});
}
/**
* Handle and format errors for GraphQL responses
* @function handleGraphQLError
* @param {any} error - The error to handle (can be any type)
* @param {string} [context] - Optional context string describing where the error occurred
* @returns {GraphQLError} Formatted GraphQL error
* @description Transforms various error types into standardized GraphQL errors.
* Handles MongoDB errors (CastError, ValidationError, DocumentNotFoundError),
* timeout errors, network errors, and generic errors. Already-formatted GraphQL
* errors are returned as-is.
*
* @example
* ```typescript
* try {
* await someOperation();
* } catch (error) {
* throw handleGraphQLError(error, 'someOperation');
* }
* ```
*/
export function handleGraphQLError(error: any, context?: string): GraphQLError {
// If it's already a GraphQL error, return it
if (error instanceof GraphQLError) {
return error;
}
// Handle MongoDB errors
if (error.name === "CastError") {
return createGraphQLError(
"Invalid ID format",
GraphQLErrorCode.VALIDATION_ERROR,
{ field: error.path }
);
}
if (error.name === "ValidationError") {
return createGraphQLError(
`Validation failed: ${error.message}`,
GraphQLErrorCode.VALIDATION_ERROR
);
}
if (error.name === "DocumentNotFoundError") {
return createGraphQLError(
"Document not found",
GraphQLErrorCode.DOCUMENT_NOT_FOUND
);
}
// Handle timeout errors
if (error.name === "TimeoutError" || error.message?.includes("timeout")) {
return createGraphQLError(
"Operation timed out",
GraphQLErrorCode.TIMEOUT,
{ context }
);
}
// Handle network errors
if (error.code === "ECONNREFUSED" || error.code === "ENOTFOUND") {
return createGraphQLError(
"Service unavailable",
GraphQLErrorCode.SERVICE_UNAVAILABLE,
{ context }
);
}
// Default error
return createGraphQLError(
context ? `${context}: ${error.message}` : error.message,
GraphQLErrorCode.INTERNAL_SERVER_ERROR,
{
originalError: error.name,
stack: process.env.NODE_ENV === "development" ? error.stack : undefined,
}
);
}
/**
* Wrap a resolver function with automatic error handling
* @function withErrorHandling
* @template T - The resolver function type
* @param {T} resolver - The resolver function to wrap
* @param {string} [context] - Optional context string for error messages
* @returns {T} Wrapped resolver function with error handling
* @description Higher-order function that wraps a resolver with try-catch error handling.
* Automatically transforms errors using handleGraphQLError before re-throwing.
*
* @example
* ```typescript
* const getComic = withErrorHandling(
* async (_, { id }) => {
* return await Comic.findById(id);
* },
* 'getComic'
* );
* ```
*/
export function withErrorHandling<T extends (...args: any[]) => any>(
resolver: T,
context?: string
): T {
return (async (...args: any[]) => {
try {
return await resolver(...args);
} catch (error: any) {
throw handleGraphQLError(error, context);
}
}) as T;
}
/**
* Error logging context
* @interface ErrorContext
* @property {string} [operation] - Name of the GraphQL operation
* @property {string} [query] - The GraphQL query string
* @property {any} [variables] - Query variables
* @property {string} [userId] - User ID if available
*/
interface ErrorContext {
operation?: string;
query?: string;
variables?: any;
userId?: string;
}
/**
* Log error with structured context information
* @function logError
* @param {any} logger - Logger instance (e.g., Moleculer logger)
* @param {Error} error - The error to log
* @param {ErrorContext} context - Additional context for the error
* @returns {void}
* @description Logs errors with structured context including operation name, query,
* variables, and user ID. Includes GraphQL error extensions if present.
*
* @example
* ```typescript
* logError(this.logger, error, {
* operation: 'getComic',
* query: 'query { comic(id: "123") { title } }',
* variables: { id: '123' }
* });
* ```
*/
export function logError(
logger: any,
error: Error,
context: ErrorContext
): void {
const errorInfo: any = {
message: error.message,
name: error.name,
stack: error.stack,
...context,
};
if (error instanceof GraphQLError) {
errorInfo.extensions = error.extensions;
}
logger.error("GraphQL Error:", errorInfo);
}
/**
* Check if an error is retryable
* @function isRetryableError
* @param {any} error - The error to check
* @returns {boolean} True if the error is retryable, false otherwise
* @description Determines if an error represents a transient failure that could
* succeed on retry. Returns true for network errors, timeout errors, and
* service unavailable errors.
*
* @example
* ```typescript
* if (isRetryableError(error)) {
* // Implement retry logic
* await retryOperation();
* }
* ```
*/
export function isRetryableError(error: any): boolean {
// Network errors are retryable
if (error.code === "ECONNREFUSED" || error.code === "ENOTFOUND") {
return true;
}
// Timeout errors are retryable
if (error.name === "TimeoutError" || error.message?.includes("timeout")) {
return true;
}
// Service unavailable errors are retryable
if (error.extensions?.code === GraphQLErrorCode.SERVICE_UNAVAILABLE) {
return true;
}
return false;
}
/**
* Sanitize error for client response
* @function sanitizeError
* @param {GraphQLError} error - The GraphQL error to sanitize
* @param {boolean} [includeStack=false] - Whether to include stack trace
* @returns {any} Sanitized error object safe for client consumption
* @description Sanitizes errors for client responses by removing sensitive information
* and including only safe fields. Stack traces are only included if explicitly requested
* (typically only in development environments).
*
* @example
* ```typescript
* const sanitized = sanitizeError(
* error,
* process.env.NODE_ENV === 'development'
* );
* return { errors: [sanitized] };
* ```
*/
export function sanitizeError(error: GraphQLError, includeStack: boolean = false): any {
const sanitized: any = {
message: error.message,
extensions: {
code: error.extensions?.code || GraphQLErrorCode.INTERNAL_SERVER_ERROR,
},
};
// Include additional safe extensions
if (error.extensions?.field) {
sanitized.extensions.field = error.extensions.field;
}
if (error.extensions?.context) {
sanitized.extensions.context = error.extensions.context;
}
// Include stack trace only in development
if (includeStack && error.stack) {
sanitized.extensions.stack = error.stack;
}
return sanitized;
}

View 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));
}

View File

@@ -0,0 +1,436 @@
/**
* @fileoverview GraphQL input validation utilities
* @module utils/graphql.validation.utils
* @description Provides comprehensive validation utilities for GraphQL inputs including
* pagination parameters, IDs, search queries, file paths, metadata sources, and JSON strings.
* Includes custom ValidationError class and conversion to GraphQL errors.
*/
import { GraphQLError } from "graphql";
/**
* Custom validation error class
* @class ValidationError
* @extends Error
* @description Custom error class for validation failures with optional field and code information
*/
export class ValidationError extends Error {
/**
* Create a validation error
* @param {string} message - Human-readable error message
* @param {string} [field] - The field that failed validation
* @param {string} [code='VALIDATION_ERROR'] - Error code for categorization
*/
constructor(
message: string,
public field?: string,
public code: string = "VALIDATION_ERROR"
) {
super(message);
this.name = "ValidationError";
}
}
/**
* Validate pagination parameters
* @function validatePaginationParams
* @param {Object} params - Pagination parameters to validate
* @param {number} [params.page] - Page number (must be >= 1)
* @param {number} [params.limit] - Items per page (must be 1-100)
* @param {number} [params.offset] - Offset for cursor-based pagination (must be >= 0)
* @throws {ValidationError} If any parameter is invalid
* @returns {void}
* @description Validates pagination parameters ensuring page is positive, limit is within
* acceptable range (1-100), and offset is non-negative.
*
* @example
* ```typescript
* validatePaginationParams({ page: 1, limit: 20 }); // OK
* validatePaginationParams({ page: 0, limit: 20 }); // Throws ValidationError
* validatePaginationParams({ limit: 150 }); // Throws ValidationError
* ```
*/
export function validatePaginationParams(params: {
page?: number;
limit?: number;
offset?: number;
}): void {
const { page, limit, offset } = params;
if (page !== undefined) {
if (!Number.isInteger(page) || page < 1) {
throw new ValidationError(
"Page must be a positive integer",
"page",
"INVALID_PAGE"
);
}
}
if (limit !== undefined) {
if (!Number.isInteger(limit) || limit < 1 || limit > 100) {
throw new ValidationError(
"Limit must be between 1 and 100",
"limit",
"INVALID_LIMIT"
);
}
}
if (offset !== undefined) {
if (!Number.isInteger(offset) || offset < 0) {
throw new ValidationError(
"Offset must be a non-negative integer",
"offset",
"INVALID_OFFSET"
);
}
}
}
/**
* Validate a MongoDB ObjectId
* @function validateId
* @param {string} id - The ID to validate
* @param {string} [fieldName='id'] - Name of the field for error messages
* @throws {ValidationError} If ID is invalid
* @returns {void}
* @description Validates that an ID is a string and matches MongoDB ObjectId format
* (24 hexadecimal characters).
*
* @example
* ```typescript
* validateId('507f1f77bcf86cd799439011'); // OK
* validateId('invalid-id'); // Throws ValidationError
* validateId('', 'comicId'); // Throws ValidationError with field 'comicId'
* ```
*/
export function validateId(id: string, fieldName: string = "id"): void {
if (!id || typeof id !== "string") {
throw new ValidationError(
`${fieldName} is required and must be a string`,
fieldName,
"INVALID_ID"
);
}
// MongoDB ObjectId validation (24 hex characters)
if (!/^[a-f\d]{24}$/i.test(id)) {
throw new ValidationError(
`${fieldName} must be a valid ObjectId`,
fieldName,
"INVALID_ID_FORMAT"
);
}
}
/**
* Validate an array of MongoDB ObjectIds
* @function validateIds
* @param {string[]} ids - Array of IDs to validate
* @param {string} [fieldName='ids'] - Name of the field for error messages
* @throws {ValidationError} If array is invalid or contains invalid IDs
* @returns {void}
* @description Validates that the input is a non-empty array (max 100 items) and
* all elements are valid MongoDB ObjectIds.
*
* @example
* ```typescript
* validateIds(['507f1f77bcf86cd799439011', '507f191e810c19729de860ea']); // OK
* validateIds([]); // Throws ValidationError (empty array)
* validateIds(['invalid']); // Throws ValidationError (invalid ID)
* ```
*/
export function validateIds(ids: string[], fieldName: string = "ids"): void {
if (!Array.isArray(ids) || ids.length === 0) {
throw new ValidationError(
`${fieldName} must be a non-empty array`,
fieldName,
"INVALID_IDS"
);
}
if (ids.length > 100) {
throw new ValidationError(
`${fieldName} cannot contain more than 100 items`,
fieldName,
"TOO_MANY_IDS"
);
}
ids.forEach((id, index) => {
try {
validateId(id, `${fieldName}[${index}]`);
} catch (error: any) {
throw new ValidationError(
`Invalid ID at index ${index}: ${error.message}`,
fieldName,
"INVALID_ID_IN_ARRAY"
);
}
});
}
/**
* Validate a search query string
* @function validateSearchQuery
* @param {string} [query] - Search query to validate
* @throws {ValidationError} If query is invalid
* @returns {void}
* @description Validates that a search query is a string and doesn't exceed 500 characters.
* Undefined or null values are allowed (optional search).
*
* @example
* ```typescript
* validateSearchQuery('Batman'); // OK
* validateSearchQuery(undefined); // OK (optional)
* validateSearchQuery('a'.repeat(501)); // Throws ValidationError (too long)
* ```
*/
export function validateSearchQuery(query?: string): void {
if (query !== undefined && query !== null) {
if (typeof query !== "string") {
throw new ValidationError(
"Search query must be a string",
"query",
"INVALID_QUERY"
);
}
if (query.length > 500) {
throw new ValidationError(
"Search query cannot exceed 500 characters",
"query",
"QUERY_TOO_LONG"
);
}
}
}
/**
* Validate a confidence threshold value
* @function validateConfidenceThreshold
* @param {number} [threshold] - Confidence threshold to validate (0-1)
* @throws {ValidationError} If threshold is invalid
* @returns {void}
* @description Validates that a confidence threshold is a number between 0 and 1 inclusive.
* Undefined values are allowed (optional threshold).
*
* @example
* ```typescript
* validateConfidenceThreshold(0.8); // OK
* validateConfidenceThreshold(undefined); // OK (optional)
* validateConfidenceThreshold(1.5); // Throws ValidationError (out of range)
* validateConfidenceThreshold('0.8'); // Throws ValidationError (not a number)
* ```
*/
export function validateConfidenceThreshold(threshold?: number): void {
if (threshold !== undefined) {
if (typeof threshold !== "number" || isNaN(threshold)) {
throw new ValidationError(
"Confidence threshold must be a number",
"minConfidenceThreshold",
"INVALID_THRESHOLD"
);
}
if (threshold < 0 || threshold > 1) {
throw new ValidationError(
"Confidence threshold must be between 0 and 1",
"minConfidenceThreshold",
"THRESHOLD_OUT_OF_RANGE"
);
}
}
}
/**
* Sanitize a string input by removing control characters and limiting length
* @function sanitizeString
* @param {string} input - String to sanitize
* @param {number} [maxLength=1000] - Maximum allowed length
* @returns {string} Sanitized string
* @description Removes null bytes and control characters, trims whitespace,
* and truncates to maximum length. Non-string inputs return empty string.
*
* @example
* ```typescript
* sanitizeString(' Hello\x00World '); // 'HelloWorld'
* sanitizeString('a'.repeat(2000), 100); // 'aaa...' (100 chars)
* sanitizeString(123); // '' (non-string)
* ```
*/
export function sanitizeString(input: string, maxLength: number = 1000): string {
if (typeof input !== "string") {
return "";
}
// Remove null bytes and control characters
let sanitized = input.replace(/[\x00-\x1F\x7F]/g, "");
// Trim whitespace
sanitized = sanitized.trim();
// Truncate to max length
if (sanitized.length > maxLength) {
sanitized = sanitized.substring(0, maxLength);
}
return sanitized;
}
/**
* Convert a validation error to a GraphQL error
* @function toGraphQLError
* @param {Error} error - Error to convert
* @returns {GraphQLError} GraphQL-formatted error
* @description Converts ValidationError instances to GraphQL errors with proper
* extensions. Other errors are converted to generic GraphQL errors.
*
* @example
* ```typescript
* try {
* validateId('invalid');
* } catch (error) {
* throw toGraphQLError(error);
* }
* ```
*/
export function toGraphQLError(error: Error): GraphQLError {
if (error instanceof ValidationError) {
return new GraphQLError(error.message, {
extensions: {
code: error.code,
field: error.field,
},
});
}
return new GraphQLError(error.message, {
extensions: {
code: "INTERNAL_SERVER_ERROR",
},
});
}
/**
* Validate a file path for security and correctness
* @function validateFilePath
* @param {string} filePath - File path to validate
* @throws {ValidationError} If file path is invalid or unsafe
* @returns {void}
* @description Validates file paths to prevent path traversal attacks and ensure
* reasonable length. Rejects paths containing ".." or "~" and paths exceeding 4096 characters.
*
* @example
* ```typescript
* validateFilePath('/comics/batman.cbz'); // OK
* validateFilePath('../../../etc/passwd'); // Throws ValidationError (path traversal)
* validateFilePath('~/comics/file.cbz'); // Throws ValidationError (tilde expansion)
* ```
*/
export function validateFilePath(filePath: string): void {
if (!filePath || typeof filePath !== "string") {
throw new ValidationError(
"File path is required and must be a string",
"filePath",
"INVALID_FILE_PATH"
);
}
// Check for path traversal attempts
if (filePath.includes("..") || filePath.includes("~")) {
throw new ValidationError(
"File path contains invalid characters",
"filePath",
"UNSAFE_FILE_PATH"
);
}
if (filePath.length > 4096) {
throw new ValidationError(
"File path is too long",
"filePath",
"FILE_PATH_TOO_LONG"
);
}
}
/**
* Validate a metadata source value
* @function validateMetadataSource
* @param {string} source - Metadata source to validate
* @throws {ValidationError} If source is not a valid metadata source
* @returns {void}
* @description Validates that a metadata source is one of the allowed values:
* COMICVINE, METRON, GRAND_COMICS_DATABASE, LOCG, COMICINFO_XML, or MANUAL.
*
* @example
* ```typescript
* validateMetadataSource('COMICVINE'); // OK
* validateMetadataSource('INVALID_SOURCE'); // Throws ValidationError
* ```
*/
export function validateMetadataSource(source: string): void {
const validSources = [
"COMICVINE",
"METRON",
"GRAND_COMICS_DATABASE",
"LOCG",
"COMICINFO_XML",
"MANUAL",
];
if (!validSources.includes(source)) {
throw new ValidationError(
`Invalid metadata source. Must be one of: ${validSources.join(", ")}`,
"source",
"INVALID_METADATA_SOURCE"
);
}
}
/**
* Validate a JSON string for correctness and size
* @function validateJSONString
* @param {string} jsonString - JSON string to validate
* @param {string} [fieldName='metadata'] - Name of the field for error messages
* @throws {ValidationError} If JSON is invalid or too large
* @returns {void}
* @description Validates that a string is valid JSON and doesn't exceed 1MB in size.
* Checks for proper JSON syntax and enforces size limits to prevent memory issues.
*
* @example
* ```typescript
* validateJSONString('{"title": "Batman"}'); // OK
* validateJSONString('invalid json'); // Throws ValidationError (malformed)
* validateJSONString('{"data": "' + 'x'.repeat(2000000) + '"}'); // Throws (too large)
* ```
*/
export function validateJSONString(jsonString: string, fieldName: string = "metadata"): void {
if (!jsonString || typeof jsonString !== "string") {
throw new ValidationError(
`${fieldName} must be a valid JSON string`,
fieldName,
"INVALID_JSON"
);
}
try {
JSON.parse(jsonString);
} catch (error) {
throw new ValidationError(
`${fieldName} contains invalid JSON`,
fieldName,
"MALFORMED_JSON"
);
}
if (jsonString.length > 1048576) { // 1MB limit
throw new ValidationError(
`${fieldName} exceeds maximum size of 1MB`,
fieldName,
"JSON_TOO_LARGE"
);
}
}