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

437 lines
12 KiB
TypeScript

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