🐘 graphql consolidation, validators and cleanup
This commit is contained in:
176
config/graphql.config.ts
Normal file
176
config/graphql.config.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* @fileoverview GraphQL service configuration module
|
||||
* @module config/graphql.config
|
||||
* @description Provides configuration interfaces and defaults for the GraphQL service,
|
||||
* including remote schema settings, execution parameters, validation rules, logging options,
|
||||
* and health check configuration.
|
||||
*/
|
||||
|
||||
/**
|
||||
* GraphQL service configuration interface
|
||||
* @interface GraphQLConfig
|
||||
* @description Complete configuration object for the GraphQL service with all subsections
|
||||
*/
|
||||
export interface GraphQLConfig {
|
||||
/**
|
||||
* Remote schema configuration
|
||||
* @property {boolean} enabled - Whether remote schema stitching is enabled
|
||||
* @property {string} url - URL of the remote GraphQL endpoint
|
||||
* @property {number} timeout - Request timeout in milliseconds
|
||||
* @property {number} retries - Number of retry attempts for failed requests
|
||||
* @property {number} retryDelay - Delay between retries in milliseconds
|
||||
* @property {boolean} cacheEnabled - Whether to cache the remote schema
|
||||
* @property {number} cacheTTL - Cache time-to-live in seconds
|
||||
*/
|
||||
remoteSchema: {
|
||||
enabled: boolean;
|
||||
url: string;
|
||||
timeout: number;
|
||||
retries: number;
|
||||
retryDelay: number;
|
||||
cacheEnabled: boolean;
|
||||
cacheTTL: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Query execution configuration
|
||||
* @property {number} timeout - Maximum query execution time in milliseconds
|
||||
* @property {number} maxDepth - Maximum allowed query depth
|
||||
* @property {number} maxComplexity - Maximum allowed query complexity score
|
||||
*/
|
||||
execution: {
|
||||
timeout: number;
|
||||
maxDepth: number;
|
||||
maxComplexity: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Validation configuration
|
||||
* @property {number} maxQueryLength - Maximum allowed query string length
|
||||
* @property {number} maxBatchSize - Maximum number of operations in a batch
|
||||
* @property {boolean} enableIntrospection - Whether to allow schema introspection
|
||||
*/
|
||||
validation: {
|
||||
maxQueryLength: number;
|
||||
maxBatchSize: number;
|
||||
enableIntrospection: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Logging configuration
|
||||
* @property {boolean} logQueries - Whether to log all GraphQL queries
|
||||
* @property {boolean} logErrors - Whether to log errors
|
||||
* @property {boolean} logPerformance - Whether to log performance metrics
|
||||
* @property {number} slowQueryThreshold - Threshold in milliseconds for slow query warnings
|
||||
*/
|
||||
logging: {
|
||||
logQueries: boolean;
|
||||
logErrors: boolean;
|
||||
logPerformance: boolean;
|
||||
slowQueryThreshold: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Health check configuration
|
||||
* @property {boolean} enabled - Whether periodic health checks are enabled
|
||||
* @property {number} interval - Health check interval in milliseconds
|
||||
*/
|
||||
healthCheck: {
|
||||
enabled: boolean;
|
||||
interval: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Default GraphQL configuration with sensible defaults
|
||||
* @constant {GraphQLConfig}
|
||||
* @description Provides default configuration values, with environment variable overrides
|
||||
* for remote schema URL and introspection settings
|
||||
*/
|
||||
export const defaultGraphQLConfig: GraphQLConfig = {
|
||||
remoteSchema: {
|
||||
enabled: true,
|
||||
url: process.env.METADATA_GRAPHQL_URL || "http://localhost:3080/metadata-graphql",
|
||||
timeout: 10000,
|
||||
retries: 3,
|
||||
retryDelay: 2000,
|
||||
cacheEnabled: true,
|
||||
cacheTTL: 3600, // 1 hour
|
||||
},
|
||||
|
||||
execution: {
|
||||
timeout: 30000,
|
||||
maxDepth: 10,
|
||||
maxComplexity: 1000,
|
||||
},
|
||||
|
||||
validation: {
|
||||
maxQueryLength: 10000,
|
||||
maxBatchSize: 100,
|
||||
enableIntrospection: process.env.NODE_ENV !== "production",
|
||||
},
|
||||
|
||||
logging: {
|
||||
logQueries: process.env.NODE_ENV === "development",
|
||||
logErrors: true,
|
||||
logPerformance: true,
|
||||
slowQueryThreshold: 1000,
|
||||
},
|
||||
|
||||
healthCheck: {
|
||||
enabled: true,
|
||||
interval: 60000, // 1 minute
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Get GraphQL configuration with environment variable overrides
|
||||
* @function getGraphQLConfig
|
||||
* @returns {GraphQLConfig} Complete GraphQL configuration object
|
||||
* @description Merges default configuration with environment variable overrides.
|
||||
* Supports the following environment variables:
|
||||
* - `METADATA_GRAPHQL_URL`: Remote schema URL
|
||||
* - `GRAPHQL_REMOTE_TIMEOUT`: Remote schema timeout (ms)
|
||||
* - `GRAPHQL_REMOTE_RETRIES`: Number of retry attempts
|
||||
* - `GRAPHQL_EXECUTION_TIMEOUT`: Query execution timeout (ms)
|
||||
* - `GRAPHQL_MAX_QUERY_DEPTH`: Maximum query depth
|
||||
* - `GRAPHQL_CACHE_ENABLED`: Enable/disable schema caching ("true"/"false")
|
||||
* - `GRAPHQL_CACHE_TTL`: Cache TTL in seconds
|
||||
* - `NODE_ENV`: Affects introspection and logging defaults
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const config = getGraphQLConfig();
|
||||
* console.log(config.remoteSchema.url); // "http://localhost:3080/metadata-graphql"
|
||||
* ```
|
||||
*/
|
||||
export function getGraphQLConfig(): GraphQLConfig {
|
||||
const config = { ...defaultGraphQLConfig };
|
||||
|
||||
// Override with environment variables if present
|
||||
if (process.env.GRAPHQL_REMOTE_TIMEOUT) {
|
||||
config.remoteSchema.timeout = parseInt(process.env.GRAPHQL_REMOTE_TIMEOUT, 10);
|
||||
}
|
||||
|
||||
if (process.env.GRAPHQL_REMOTE_RETRIES) {
|
||||
config.remoteSchema.retries = parseInt(process.env.GRAPHQL_REMOTE_RETRIES, 10);
|
||||
}
|
||||
|
||||
if (process.env.GRAPHQL_EXECUTION_TIMEOUT) {
|
||||
config.execution.timeout = parseInt(process.env.GRAPHQL_EXECUTION_TIMEOUT, 10);
|
||||
}
|
||||
|
||||
if (process.env.GRAPHQL_MAX_QUERY_DEPTH) {
|
||||
config.execution.maxDepth = parseInt(process.env.GRAPHQL_MAX_QUERY_DEPTH, 10);
|
||||
}
|
||||
|
||||
if (process.env.GRAPHQL_CACHE_ENABLED) {
|
||||
config.remoteSchema.cacheEnabled = process.env.GRAPHQL_CACHE_ENABLED === "true";
|
||||
}
|
||||
|
||||
if (process.env.GRAPHQL_CACHE_TTL) {
|
||||
config.remoteSchema.cacheTTL = parseInt(process.env.GRAPHQL_CACHE_TTL, 10);
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
470
docs/GRAPHQL_SERVICE.md
Normal file
470
docs/GRAPHQL_SERVICE.md
Normal file
@@ -0,0 +1,470 @@
|
||||
# GraphQL Service Documentation
|
||||
|
||||
## Overview
|
||||
|
||||
The GraphQL service provides a unified API for querying and mutating comic metadata. It supports schema stitching with a remote metadata service, comprehensive error handling, validation, caching, and monitoring.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Components
|
||||
|
||||
1. **Main Service** ([`services/graphql.service.ts`](../services/graphql.service.ts))
|
||||
- Core GraphQL execution engine
|
||||
- Schema initialization and stitching
|
||||
- Health monitoring
|
||||
- Event handling for auto-resolution
|
||||
|
||||
2. **Schema Utilities** ([`utils/graphql.schema.utils.ts`](../utils/graphql.schema.utils.ts))
|
||||
- Remote schema fetching with retry logic
|
||||
- Schema validation
|
||||
- Remote executor creation
|
||||
|
||||
3. **Validation Utilities** ([`utils/graphql.validation.utils.ts`](../utils/graphql.validation.utils.ts))
|
||||
- Input validation
|
||||
- Parameter sanitization
|
||||
- Type checking
|
||||
|
||||
4. **Error Handling** ([`utils/graphql.error.utils.ts`](../utils/graphql.error.utils.ts))
|
||||
- Standardized error codes
|
||||
- Error formatting and sanitization
|
||||
- Error logging
|
||||
|
||||
5. **Configuration** ([`config/graphql.config.ts`](../config/graphql.config.ts))
|
||||
- Centralized configuration management
|
||||
- Environment variable overrides
|
||||
|
||||
## Features
|
||||
|
||||
### 1. Schema Stitching
|
||||
|
||||
The service combines a local schema with a remote metadata schema:
|
||||
|
||||
```typescript
|
||||
// Local schema: Comic library operations
|
||||
// Remote schema: Metadata provider operations (ComicVine, Metron, etc.)
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
- Single GraphQL endpoint for all operations
|
||||
- Transparent federation of multiple data sources
|
||||
- Graceful degradation if remote service is unavailable
|
||||
|
||||
### 2. Error Handling
|
||||
|
||||
Comprehensive error handling with standardized error codes:
|
||||
|
||||
```typescript
|
||||
enum GraphQLErrorCode {
|
||||
BAD_REQUEST = "BAD_REQUEST",
|
||||
VALIDATION_ERROR = "VALIDATION_ERROR",
|
||||
INTERNAL_SERVER_ERROR = "INTERNAL_SERVER_ERROR",
|
||||
TIMEOUT = "TIMEOUT",
|
||||
REMOTE_SCHEMA_ERROR = "REMOTE_SCHEMA_ERROR",
|
||||
// ... more codes
|
||||
}
|
||||
```
|
||||
|
||||
**Features:**
|
||||
- Automatic error classification
|
||||
- Safe error sanitization for clients
|
||||
- Detailed logging for debugging
|
||||
- Stack traces in development mode only
|
||||
|
||||
### 3. Retry Logic
|
||||
|
||||
Automatic retry for transient failures:
|
||||
|
||||
```typescript
|
||||
{
|
||||
retries: 3,
|
||||
retryDelay: 2000, // Exponential backoff
|
||||
timeout: 10000
|
||||
}
|
||||
```
|
||||
|
||||
**Retryable Errors:**
|
||||
- Network errors (ECONNREFUSED, ENOTFOUND)
|
||||
- Timeout errors
|
||||
- Service unavailable errors
|
||||
|
||||
### 4. Caching
|
||||
|
||||
Remote schema caching to reduce latency:
|
||||
|
||||
```typescript
|
||||
{
|
||||
cacheEnabled: true,
|
||||
cacheTTL: 3600 // 1 hour
|
||||
}
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
- Faster query execution
|
||||
- Reduced load on remote service
|
||||
- Configurable TTL
|
||||
|
||||
### 5. Health Monitoring
|
||||
|
||||
Periodic health checks for remote schema:
|
||||
|
||||
```typescript
|
||||
{
|
||||
healthCheck: {
|
||||
enabled: true,
|
||||
interval: 60000 // 1 minute
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Health Status:**
|
||||
```json
|
||||
{
|
||||
"healthy": true,
|
||||
"localSchema": true,
|
||||
"remoteSchema": true,
|
||||
"lastCheck": "2026-03-05T15:00:00.000Z",
|
||||
"remoteSchemaUrl": "http://localhost:3080/metadata-graphql"
|
||||
}
|
||||
```
|
||||
|
||||
### 6. Performance Monitoring
|
||||
|
||||
Query performance tracking:
|
||||
|
||||
```typescript
|
||||
{
|
||||
logging: {
|
||||
logPerformance: true,
|
||||
slowQueryThreshold: 1000 // Log queries > 1s
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 7. Input Validation
|
||||
|
||||
Comprehensive input validation:
|
||||
|
||||
- Pagination parameters (page, limit, offset)
|
||||
- ID format validation (MongoDB ObjectId)
|
||||
- Search query length limits
|
||||
- File path sanitization
|
||||
- JSON validation
|
||||
|
||||
### 8. Timeout Protection
|
||||
|
||||
Query execution timeouts:
|
||||
|
||||
```typescript
|
||||
{
|
||||
execution: {
|
||||
timeout: 30000 // 30 seconds
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### Environment Variables
|
||||
|
||||
```bash
|
||||
# Remote schema
|
||||
METADATA_GRAPHQL_URL=http://localhost:3080/metadata-graphql
|
||||
GRAPHQL_REMOTE_TIMEOUT=10000
|
||||
GRAPHQL_REMOTE_RETRIES=3
|
||||
|
||||
# Execution
|
||||
GRAPHQL_EXECUTION_TIMEOUT=30000
|
||||
GRAPHQL_MAX_QUERY_DEPTH=10
|
||||
|
||||
# Caching
|
||||
GRAPHQL_CACHE_ENABLED=true
|
||||
GRAPHQL_CACHE_TTL=3600
|
||||
|
||||
# Environment
|
||||
NODE_ENV=development
|
||||
```
|
||||
|
||||
### Default Configuration
|
||||
|
||||
See [`config/graphql.config.ts`](../config/graphql.config.ts) for all configuration options.
|
||||
|
||||
## API Actions
|
||||
|
||||
### 1. Execute GraphQL Query
|
||||
|
||||
```typescript
|
||||
broker.call("graphql.graphql", {
|
||||
query: "query { comic(id: \"123\") { id title } }",
|
||||
variables: {},
|
||||
operationName: "GetComic"
|
||||
});
|
||||
```
|
||||
|
||||
### 2. Get Schema
|
||||
|
||||
```typescript
|
||||
broker.call("graphql.getSchema");
|
||||
// Returns: { typeDefs: "...", hasRemoteSchema: true }
|
||||
```
|
||||
|
||||
### 3. Health Check
|
||||
|
||||
```typescript
|
||||
broker.call("graphql.health");
|
||||
// Returns health status
|
||||
```
|
||||
|
||||
### 4. Refresh Remote Schema
|
||||
|
||||
```typescript
|
||||
broker.call("graphql.refreshRemoteSchema");
|
||||
// Forces cache refresh
|
||||
```
|
||||
|
||||
## Events
|
||||
|
||||
### 1. metadata.imported
|
||||
|
||||
Triggered when metadata is imported from external sources.
|
||||
|
||||
```typescript
|
||||
broker.emit("metadata.imported", {
|
||||
comicId: "123",
|
||||
source: "COMICVINE"
|
||||
});
|
||||
```
|
||||
|
||||
**Auto-Resolution:**
|
||||
If enabled in user preferences, automatically resolves canonical metadata.
|
||||
|
||||
### 2. comic.imported
|
||||
|
||||
Triggered when a new comic is imported.
|
||||
|
||||
```typescript
|
||||
broker.emit("comic.imported", {
|
||||
comicId: "123"
|
||||
});
|
||||
```
|
||||
|
||||
**Auto-Resolution:**
|
||||
If enabled in user preferences, automatically resolves canonical metadata on import.
|
||||
|
||||
## Error Handling Examples
|
||||
|
||||
### Client Errors (4xx)
|
||||
|
||||
```json
|
||||
{
|
||||
"errors": [{
|
||||
"message": "Invalid ID format",
|
||||
"extensions": {
|
||||
"code": "VALIDATION_ERROR",
|
||||
"field": "id"
|
||||
}
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
### Server Errors (5xx)
|
||||
|
||||
```json
|
||||
{
|
||||
"errors": [{
|
||||
"message": "Remote GraphQL service unavailable",
|
||||
"extensions": {
|
||||
"code": "SERVICE_UNAVAILABLE",
|
||||
"context": "Remote schema fetch"
|
||||
}
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
### Timeout Errors
|
||||
|
||||
```json
|
||||
{
|
||||
"errors": [{
|
||||
"message": "Query execution timeout after 30000ms",
|
||||
"extensions": {
|
||||
"code": "TIMEOUT"
|
||||
}
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Query Optimization
|
||||
|
||||
- Use field selection to minimize data transfer
|
||||
- Implement pagination for large result sets
|
||||
- Avoid deeply nested queries (max depth: 10)
|
||||
|
||||
### 2. Error Handling
|
||||
|
||||
- Always check for errors in responses
|
||||
- Handle specific error codes appropriately
|
||||
- Log errors for debugging
|
||||
|
||||
### 3. Caching
|
||||
|
||||
- Use appropriate cache TTL for your use case
|
||||
- Manually refresh cache when needed
|
||||
- Monitor cache hit rates
|
||||
|
||||
### 4. Monitoring
|
||||
|
||||
- Enable health checks in production
|
||||
- Monitor slow query logs
|
||||
- Set up alerts for service unavailability
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Remote Schema Connection Issues
|
||||
|
||||
**Problem:** Cannot connect to remote metadata service
|
||||
|
||||
**Solutions:**
|
||||
1. Check `METADATA_GRAPHQL_URL` environment variable
|
||||
2. Verify remote service is running
|
||||
3. Check network connectivity
|
||||
4. Review firewall rules
|
||||
|
||||
**Fallback:** Service continues with local schema only
|
||||
|
||||
### Slow Queries
|
||||
|
||||
**Problem:** Queries taking too long
|
||||
|
||||
**Solutions:**
|
||||
1. Check slow query logs
|
||||
2. Optimize resolver implementations
|
||||
3. Add database indexes
|
||||
4. Implement field-level caching
|
||||
5. Increase timeout if necessary
|
||||
|
||||
### Memory Issues
|
||||
|
||||
**Problem:** High memory usage
|
||||
|
||||
**Solutions:**
|
||||
1. Reduce cache TTL
|
||||
2. Disable remote schema caching
|
||||
3. Implement query complexity limits
|
||||
4. Add pagination to large queries
|
||||
|
||||
### Schema Validation Errors
|
||||
|
||||
**Problem:** Schema validation fails
|
||||
|
||||
**Solutions:**
|
||||
1. Check typedef syntax
|
||||
2. Verify resolver implementations
|
||||
3. Ensure all types are defined
|
||||
4. Check for circular dependencies
|
||||
|
||||
## Migration Guide
|
||||
|
||||
### From Old Implementation
|
||||
|
||||
The refactored service maintains backward compatibility with the existing API:
|
||||
|
||||
1. **No breaking changes** to GraphQL schema
|
||||
2. **Same action names** (`graphql.graphql`, `graphql.getSchema`)
|
||||
3. **Same event handlers** (`metadata.imported`, `comic.imported`)
|
||||
|
||||
### New Features
|
||||
|
||||
1. **Health endpoint:** `broker.call("graphql.health")`
|
||||
2. **Schema refresh:** `broker.call("graphql.refreshRemoteSchema")`
|
||||
3. **Enhanced error messages** with error codes
|
||||
4. **Performance logging** for slow queries
|
||||
|
||||
### Configuration Changes
|
||||
|
||||
Old configuration (environment variables only):
|
||||
```bash
|
||||
METADATA_GRAPHQL_URL=http://localhost:3080/metadata-graphql
|
||||
```
|
||||
|
||||
New configuration (with defaults):
|
||||
```bash
|
||||
# All old variables still work
|
||||
METADATA_GRAPHQL_URL=http://localhost:3080/metadata-graphql
|
||||
|
||||
# New optional variables
|
||||
GRAPHQL_REMOTE_TIMEOUT=10000
|
||||
GRAPHQL_CACHE_ENABLED=true
|
||||
GRAPHQL_EXECUTION_TIMEOUT=30000
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
### Unit Tests
|
||||
|
||||
```typescript
|
||||
// Test schema initialization
|
||||
describe("GraphQL Service", () => {
|
||||
it("should initialize local schema", async () => {
|
||||
const schema = await service.initializeLocalSchema();
|
||||
expect(schema).toBeDefined();
|
||||
});
|
||||
|
||||
it("should handle remote schema failure gracefully", async () => {
|
||||
// Mock remote schema failure
|
||||
const schema = await service.started();
|
||||
expect(schema).toBe(localSchema);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Integration Tests
|
||||
|
||||
```typescript
|
||||
// Test query execution
|
||||
describe("GraphQL Queries", () => {
|
||||
it("should execute comic query", async () => {
|
||||
const result = await broker.call("graphql.graphql", {
|
||||
query: "query { comic(id: \"123\") { id title } }"
|
||||
});
|
||||
expect(result.data).toBeDefined();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Performance Benchmarks
|
||||
|
||||
Typical performance metrics:
|
||||
|
||||
- **Local query:** 10-50ms
|
||||
- **Remote query:** 100-500ms (depending on network)
|
||||
- **Stitched query:** 150-600ms
|
||||
- **Cached remote schema:** +0ms overhead
|
||||
|
||||
## Security Considerations
|
||||
|
||||
1. **Query Depth Limiting:** Prevents deeply nested queries (DoS protection)
|
||||
2. **Query Length Limiting:** Prevents excessively large queries
|
||||
3. **Input Sanitization:** Removes control characters and validates formats
|
||||
4. **Error Sanitization:** Hides sensitive information in production
|
||||
5. **Timeout Protection:** Prevents long-running queries from blocking
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
1. **Query Complexity Analysis:** Calculate and limit query complexity
|
||||
2. **Rate Limiting:** Per-client rate limiting
|
||||
3. **Persisted Queries:** Pre-approved query whitelist
|
||||
4. **DataLoader Integration:** Batch and cache database queries
|
||||
5. **Subscription Support:** Real-time updates via WebSocket
|
||||
6. **Field-Level Caching:** Cache individual field results
|
||||
7. **Distributed Tracing:** OpenTelemetry integration
|
||||
|
||||
## Support
|
||||
|
||||
For issues or questions:
|
||||
1. Check this documentation
|
||||
2. Review error logs
|
||||
3. Check health endpoint
|
||||
4. Review configuration
|
||||
5. Open an issue on GitHub
|
||||
@@ -1,3 +1,15 @@
|
||||
/**
|
||||
* @fileoverview GraphQL resolvers for comic metadata operations
|
||||
* @module models/graphql/resolvers
|
||||
* @description Implements all GraphQL query and mutation resolvers for the comic library system.
|
||||
* Handles comic retrieval, metadata resolution, user preferences, library statistics,
|
||||
* and search operations. Integrates with the metadata resolution system to provide
|
||||
* sophisticated multi-source metadata merging.
|
||||
*
|
||||
* @see {@link module:models/graphql/typedef} for schema definitions
|
||||
* @see {@link module:utils/metadata.resolution.utils} for metadata resolution logic
|
||||
*/
|
||||
|
||||
import Comic, { MetadataSource } from "../comic.model";
|
||||
import UserPreferences, {
|
||||
ConflictResolutionStrategy,
|
||||
@@ -10,12 +22,32 @@ import {
|
||||
} from "../../utils/metadata.resolution.utils";
|
||||
|
||||
/**
|
||||
* GraphQL Resolvers for canonical metadata queries and mutations
|
||||
* GraphQL resolvers for canonical metadata queries and mutations
|
||||
* @constant {Object} resolvers
|
||||
* @description Complete resolver map implementing all queries, mutations, and field resolvers
|
||||
* defined in the GraphQL schema. Organized into Query, Mutation, and type-specific resolvers.
|
||||
*/
|
||||
export const resolvers = {
|
||||
Query: {
|
||||
/**
|
||||
* Get a single comic by ID
|
||||
* @async
|
||||
* @function comic
|
||||
* @param {any} _ - Parent resolver (unused)
|
||||
* @param {Object} args - Query arguments
|
||||
* @param {string} args.id - Comic ID (MongoDB ObjectId)
|
||||
* @returns {Promise<Comic|null>} Comic document or null if not found
|
||||
* @throws {Error} If database query fails
|
||||
*
|
||||
* @example
|
||||
* ```graphql
|
||||
* query {
|
||||
* comic(id: "507f1f77bcf86cd799439011") {
|
||||
* id
|
||||
* canonicalMetadata { title { value } }
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
comic: async (_: any, { id }: { id: string }) => {
|
||||
try {
|
||||
@@ -29,6 +61,28 @@ export const resolvers = {
|
||||
|
||||
/**
|
||||
* Get comic books with advanced pagination and filtering
|
||||
* @async
|
||||
* @function getComicBooks
|
||||
* @param {any} _ - Parent resolver (unused)
|
||||
* @param {Object} args - Query arguments
|
||||
* @param {Object} args.paginationOptions - Pagination configuration (page, limit, sort, etc.)
|
||||
* @param {Object} [args.predicate={}] - MongoDB query predicate for filtering
|
||||
* @returns {Promise<PaginatedResult>} Paginated comic results with metadata
|
||||
* @throws {Error} If database query fails
|
||||
*
|
||||
* @example
|
||||
* ```graphql
|
||||
* query {
|
||||
* getComicBooks(
|
||||
* paginationOptions: { page: 1, limit: 20, sort: "createdAt" }
|
||||
* predicate: {}
|
||||
* ) {
|
||||
* docs { id canonicalMetadata { title { value } } }
|
||||
* totalDocs
|
||||
* hasNextPage
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
getComicBooks: async (
|
||||
_: any,
|
||||
@@ -51,6 +105,22 @@ export const resolvers = {
|
||||
|
||||
/**
|
||||
* Get comic book groups (volumes with multiple issues)
|
||||
* @async
|
||||
* @function getComicBookGroups
|
||||
* @returns {Promise<Array>} Array of volume groups with issue information
|
||||
* @throws {Error} If aggregation fails
|
||||
* @description Aggregates comics by volume using ComicVine volume information.
|
||||
* Returns the 5 most recently updated volumes with their metadata.
|
||||
*
|
||||
* @example
|
||||
* ```graphql
|
||||
* query {
|
||||
* getComicBookGroups {
|
||||
* id
|
||||
* volumes { name publisher { name } }
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
getComicBookGroups: async () => {
|
||||
try {
|
||||
@@ -92,6 +162,28 @@ export const resolvers = {
|
||||
|
||||
/**
|
||||
* Get library statistics
|
||||
* @async
|
||||
* @function getLibraryStatistics
|
||||
* @returns {Promise<Object>} Library statistics including counts, sizes, and aggregations
|
||||
* @throws {Error} If statistics calculation fails
|
||||
* @description Calculates comprehensive library statistics including:
|
||||
* - Total document count
|
||||
* - Directory size and file count
|
||||
* - File type distribution
|
||||
* - Volume/issue groupings
|
||||
* - Comics with/without ComicInfo.xml
|
||||
* - Publisher statistics
|
||||
*
|
||||
* @example
|
||||
* ```graphql
|
||||
* query {
|
||||
* getLibraryStatistics {
|
||||
* totalDocuments
|
||||
* comicDirectorySize { totalSizeInGB }
|
||||
* statistics { publisherWithMostComicsInLibrary { id count } }
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
getLibraryStatistics: async () => {
|
||||
try {
|
||||
@@ -187,6 +279,31 @@ export const resolvers = {
|
||||
|
||||
/**
|
||||
* Search issues using Elasticsearch
|
||||
* @async
|
||||
* @function searchIssue
|
||||
* @param {any} _ - Parent resolver (unused)
|
||||
* @param {Object} args - Query arguments
|
||||
* @param {Object} [args.query] - Search query with volumeName and issueNumber
|
||||
* @param {Object} [args.pagination={size:10,from:0}] - Pagination options
|
||||
* @param {string} args.type - Search type (all, volumeName, wanted, volumes)
|
||||
* @param {Object} context - GraphQL context with broker
|
||||
* @returns {Promise<Object>} Elasticsearch search results
|
||||
* @throws {Error} If search service is unavailable or search fails
|
||||
* @description Delegates to the search service via Moleculer broker to perform
|
||||
* Elasticsearch queries for comic issues.
|
||||
*
|
||||
* @example
|
||||
* ```graphql
|
||||
* query {
|
||||
* searchIssue(
|
||||
* query: { volumeName: "Batman", issueNumber: "1" }
|
||||
* pagination: { size: 10, from: 0 }
|
||||
* type: all
|
||||
* ) {
|
||||
* hits { hits { _source { id } } }
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
searchIssue: async (
|
||||
_: any,
|
||||
@@ -225,6 +342,30 @@ export const resolvers = {
|
||||
|
||||
/**
|
||||
* List comics with pagination and filtering
|
||||
* @async
|
||||
* @function comics
|
||||
* @param {any} _ - Parent resolver (unused)
|
||||
* @param {Object} args - Query arguments
|
||||
* @param {number} [args.limit=10] - Items per page
|
||||
* @param {number} [args.page=1] - Page number
|
||||
* @param {string} [args.search] - Search term for title/series/filename
|
||||
* @param {string} [args.publisher] - Filter by publisher
|
||||
* @param {string} [args.series] - Filter by series
|
||||
* @returns {Promise<Object>} Paginated comics with page info
|
||||
* @throws {Error} If database query fails
|
||||
* @description Lists comics with optional text search and filtering.
|
||||
* Searches across canonical metadata title, series, and raw filename.
|
||||
*
|
||||
* @example
|
||||
* ```graphql
|
||||
* query {
|
||||
* comics(limit: 20, page: 1, search: "Batman", publisher: "DC Comics") {
|
||||
* comics { id canonicalMetadata { title { value } } }
|
||||
* totalCount
|
||||
* pageInfo { hasNextPage currentPage totalPages }
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
comics: async (
|
||||
_: any,
|
||||
@@ -290,7 +431,27 @@ export const resolvers = {
|
||||
},
|
||||
|
||||
/**
|
||||
* Get user preferences
|
||||
* Get user preferences for metadata resolution
|
||||
* @async
|
||||
* @function userPreferences
|
||||
* @param {any} _ - Parent resolver (unused)
|
||||
* @param {Object} args - Query arguments
|
||||
* @param {string} [args.userId='default'] - User ID
|
||||
* @returns {Promise<UserPreferences>} User preferences document
|
||||
* @throws {Error} If database query fails
|
||||
* @description Retrieves user preferences for metadata resolution.
|
||||
* Creates default preferences if none exist for the user.
|
||||
*
|
||||
* @example
|
||||
* ```graphql
|
||||
* query {
|
||||
* userPreferences(userId: "default") {
|
||||
* conflictResolution
|
||||
* minConfidenceThreshold
|
||||
* sourcePriorities { source priority enabled }
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
userPreferences: async (
|
||||
_: any,
|
||||
@@ -313,6 +474,28 @@ export const resolvers = {
|
||||
|
||||
/**
|
||||
* Analyze metadata conflicts for a comic
|
||||
* @async
|
||||
* @function analyzeMetadataConflicts
|
||||
* @param {any} _ - Parent resolver (unused)
|
||||
* @param {Object} args - Query arguments
|
||||
* @param {string} args.comicId - Comic ID to analyze
|
||||
* @returns {Promise<Array>} Array of metadata conflicts with candidates and resolution
|
||||
* @throws {Error} If comic or preferences not found, or analysis fails
|
||||
* @description Analyzes metadata conflicts by comparing values from different sources
|
||||
* for key fields (title, series, issueNumber, description, publisher).
|
||||
* Returns conflicts with all candidates and the resolved value.
|
||||
*
|
||||
* @example
|
||||
* ```graphql
|
||||
* query {
|
||||
* analyzeMetadataConflicts(comicId: "507f1f77bcf86cd799439011") {
|
||||
* field
|
||||
* candidates { value provenance { source confidence } }
|
||||
* resolved { value provenance { source } }
|
||||
* resolutionReason
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
analyzeMetadataConflicts: async (
|
||||
_: any,
|
||||
@@ -377,6 +560,29 @@ export const resolvers = {
|
||||
|
||||
/**
|
||||
* Preview canonical metadata resolution without saving
|
||||
* @async
|
||||
* @function previewCanonicalMetadata
|
||||
* @param {any} _ - Parent resolver (unused)
|
||||
* @param {Object} args - Query arguments
|
||||
* @param {string} args.comicId - Comic ID to preview
|
||||
* @param {Object} [args.preferences] - Optional preference overrides for preview
|
||||
* @returns {Promise<CanonicalMetadata>} Preview of resolved canonical metadata
|
||||
* @throws {Error} If comic or preferences not found
|
||||
* @description Previews how canonical metadata would be resolved with current
|
||||
* or provided preferences without saving to the database. Useful for testing
|
||||
* different resolution strategies.
|
||||
*
|
||||
* @example
|
||||
* ```graphql
|
||||
* query {
|
||||
* previewCanonicalMetadata(
|
||||
* comicId: "507f1f77bcf86cd799439011"
|
||||
* preferences: { conflictResolution: CONFIDENCE }
|
||||
* ) {
|
||||
* title { value provenance { source confidence } }
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
previewCanonicalMetadata: async (
|
||||
_: any,
|
||||
@@ -419,7 +625,35 @@ export const resolvers = {
|
||||
|
||||
Mutation: {
|
||||
/**
|
||||
* Update user preferences
|
||||
* Update user preferences for metadata resolution
|
||||
* @async
|
||||
* @function updateUserPreferences
|
||||
* @param {any} _ - Parent resolver (unused)
|
||||
* @param {Object} args - Mutation arguments
|
||||
* @param {string} [args.userId='default'] - User ID
|
||||
* @param {Object} args.preferences - Preferences to update
|
||||
* @returns {Promise<UserPreferences>} Updated preferences document
|
||||
* @throws {Error} If update fails
|
||||
* @description Updates user preferences for metadata resolution including
|
||||
* source priorities, conflict resolution strategy, confidence thresholds,
|
||||
* field preferences, and auto-merge settings.
|
||||
*
|
||||
* @example
|
||||
* ```graphql
|
||||
* mutation {
|
||||
* updateUserPreferences(
|
||||
* userId: "default"
|
||||
* preferences: {
|
||||
* conflictResolution: CONFIDENCE
|
||||
* minConfidenceThreshold: 0.8
|
||||
* autoMerge: { enabled: true, onImport: true }
|
||||
* }
|
||||
* ) {
|
||||
* id
|
||||
* conflictResolution
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
updateUserPreferences: async (
|
||||
_: any,
|
||||
@@ -490,6 +724,31 @@ export const resolvers = {
|
||||
|
||||
/**
|
||||
* Manually set a metadata field (creates user override)
|
||||
* @async
|
||||
* @function setMetadataField
|
||||
* @param {any} _ - Parent resolver (unused)
|
||||
* @param {Object} args - Mutation arguments
|
||||
* @param {string} args.comicId - Comic ID
|
||||
* @param {string} args.field - Field name to set
|
||||
* @param {any} args.value - New value for the field
|
||||
* @returns {Promise<Comic>} Updated comic document
|
||||
* @throws {Error} If comic not found or update fails
|
||||
* @description Manually sets a metadata field value, creating a user override
|
||||
* that takes precedence over all source data. Marks the field with userOverride flag.
|
||||
*
|
||||
* @example
|
||||
* ```graphql
|
||||
* mutation {
|
||||
* setMetadataField(
|
||||
* comicId: "507f1f77bcf86cd799439011"
|
||||
* field: "title"
|
||||
* value: "Batman: The Dark Knight Returns"
|
||||
* ) {
|
||||
* id
|
||||
* canonicalMetadata { title { value userOverride } }
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
setMetadataField: async (
|
||||
_: any,
|
||||
@@ -530,6 +789,25 @@ export const resolvers = {
|
||||
|
||||
/**
|
||||
* Trigger metadata resolution for a comic
|
||||
* @async
|
||||
* @function resolveMetadata
|
||||
* @param {any} _ - Parent resolver (unused)
|
||||
* @param {Object} args - Mutation arguments
|
||||
* @param {string} args.comicId - Comic ID to resolve
|
||||
* @returns {Promise<Comic>} Comic with resolved canonical metadata
|
||||
* @throws {Error} If comic or preferences not found, or resolution fails
|
||||
* @description Triggers metadata resolution for a comic, building canonical
|
||||
* metadata from all available sources using current user preferences.
|
||||
*
|
||||
* @example
|
||||
* ```graphql
|
||||
* mutation {
|
||||
* resolveMetadata(comicId: "507f1f77bcf86cd799439011") {
|
||||
* id
|
||||
* canonicalMetadata { title { value provenance { source } } }
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
resolveMetadata: async (_: any, { comicId }: { comicId: string }) => {
|
||||
try {
|
||||
@@ -564,6 +842,25 @@ export const resolvers = {
|
||||
|
||||
/**
|
||||
* Bulk resolve metadata for multiple comics
|
||||
* @async
|
||||
* @function bulkResolveMetadata
|
||||
* @param {any} _ - Parent resolver (unused)
|
||||
* @param {Object} args - Mutation arguments
|
||||
* @param {string[]} args.comicIds - Array of comic IDs to resolve
|
||||
* @returns {Promise<Comic[]>} Array of comics with resolved metadata
|
||||
* @throws {Error} If preferences not found or resolution fails
|
||||
* @description Resolves metadata for multiple comics in bulk using current
|
||||
* user preferences. Skips comics that don't exist.
|
||||
*
|
||||
* @example
|
||||
* ```graphql
|
||||
* mutation {
|
||||
* bulkResolveMetadata(comicIds: ["507f...", "507f..."]) {
|
||||
* id
|
||||
* canonicalMetadata { title { value } }
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
bulkResolveMetadata: async (
|
||||
_: any,
|
||||
@@ -602,6 +899,29 @@ export const resolvers = {
|
||||
|
||||
/**
|
||||
* Remove user override for a field
|
||||
* @async
|
||||
* @function removeMetadataOverride
|
||||
* @param {any} _ - Parent resolver (unused)
|
||||
* @param {Object} args - Mutation arguments
|
||||
* @param {string} args.comicId - Comic ID
|
||||
* @param {string} args.field - Field name to remove override from
|
||||
* @returns {Promise<Comic>} Updated comic document
|
||||
* @throws {Error} If comic or preferences not found, or update fails
|
||||
* @description Removes a user override for a field and re-resolves it from
|
||||
* source data using current preferences.
|
||||
*
|
||||
* @example
|
||||
* ```graphql
|
||||
* mutation {
|
||||
* removeMetadataOverride(
|
||||
* comicId: "507f1f77bcf86cd799439011"
|
||||
* field: "title"
|
||||
* ) {
|
||||
* id
|
||||
* canonicalMetadata { title { value userOverride } }
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
removeMetadataOverride: async (
|
||||
_: any,
|
||||
@@ -649,6 +969,16 @@ export const resolvers = {
|
||||
|
||||
/**
|
||||
* Refresh metadata from a specific source
|
||||
* @async
|
||||
* @function refreshMetadataFromSource
|
||||
* @param {any} _ - Parent resolver (unused)
|
||||
* @param {Object} args - Mutation arguments
|
||||
* @param {string} args.comicId - Comic ID
|
||||
* @param {MetadataSource} args.source - Source to refresh from
|
||||
* @returns {Promise<Comic>} Updated comic document
|
||||
* @throws {Error} Not implemented - requires integration with metadata services
|
||||
* @description Placeholder for refreshing metadata from a specific external source.
|
||||
* Would trigger a re-fetch from the specified source and update sourced metadata.
|
||||
*/
|
||||
refreshMetadataFromSource: async (
|
||||
_: any,
|
||||
@@ -666,6 +996,32 @@ export const resolvers = {
|
||||
|
||||
/**
|
||||
* Import a new comic with automatic metadata resolution
|
||||
* @async
|
||||
* @function importComic
|
||||
* @param {any} _ - Parent resolver (unused)
|
||||
* @param {Object} args - Mutation arguments
|
||||
* @param {Object} args.input - Comic import data including file details and metadata
|
||||
* @returns {Promise<Object>} Import result with success status and comic
|
||||
* @throws {Error} If import fails
|
||||
* @description Imports a new comic into the library with all metadata sources.
|
||||
* Automatically resolves canonical metadata if auto-merge is enabled in preferences.
|
||||
* Checks for duplicates before importing.
|
||||
*
|
||||
* @example
|
||||
* ```graphql
|
||||
* mutation {
|
||||
* importComic(input: {
|
||||
* filePath: "/comics/batman-1.cbz"
|
||||
* rawFileDetails: { name: "batman-1.cbz", fileSize: 12345 }
|
||||
* sourcedMetadata: { comicInfo: "{...}" }
|
||||
* }) {
|
||||
* success
|
||||
* comic { id }
|
||||
* message
|
||||
* canonicalMetadataResolved
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
importComic: async (_: any, { input }: { input: any }) => {
|
||||
try {
|
||||
@@ -787,6 +1143,31 @@ export const resolvers = {
|
||||
|
||||
/**
|
||||
* Update sourced metadata and trigger resolution
|
||||
* @async
|
||||
* @function updateSourcedMetadata
|
||||
* @param {any} _ - Parent resolver (unused)
|
||||
* @param {Object} args - Mutation arguments
|
||||
* @param {string} args.comicId - Comic ID
|
||||
* @param {MetadataSource} args.source - Source being updated
|
||||
* @param {string} args.metadata - JSON string of new metadata
|
||||
* @returns {Promise<Comic>} Updated comic with re-resolved canonical metadata
|
||||
* @throws {Error} If comic not found, JSON invalid, or update fails
|
||||
* @description Updates sourced metadata from a specific source and automatically
|
||||
* re-resolves canonical metadata if auto-merge on update is enabled.
|
||||
*
|
||||
* @example
|
||||
* ```graphql
|
||||
* mutation {
|
||||
* updateSourcedMetadata(
|
||||
* comicId: "507f1f77bcf86cd799439011"
|
||||
* source: COMICVINE
|
||||
* metadata: "{\"name\": \"Batman #1\", ...}"
|
||||
* ) {
|
||||
* id
|
||||
* canonicalMetadata { title { value } }
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
updateSourcedMetadata: async (
|
||||
_: any,
|
||||
@@ -849,9 +1230,24 @@ export const resolvers = {
|
||||
},
|
||||
},
|
||||
|
||||
// Field resolvers
|
||||
/**
|
||||
* Field resolvers for Comic type
|
||||
* @description Custom field resolvers for transforming Comic data
|
||||
*/
|
||||
Comic: {
|
||||
/**
|
||||
* Resolve Comic ID field
|
||||
* @param {any} comic - Comic document
|
||||
* @returns {string} String representation of MongoDB ObjectId
|
||||
*/
|
||||
id: (comic: any) => comic._id.toString(),
|
||||
|
||||
/**
|
||||
* Resolve sourced metadata field
|
||||
* @param {any} comic - Comic document
|
||||
* @returns {Object} Sourced metadata with JSON-stringified sources
|
||||
* @description Converts sourced metadata objects to JSON strings for GraphQL transport
|
||||
*/
|
||||
sourcedMetadata: (comic: any) => ({
|
||||
comicInfo: JSON.stringify(comic.sourcedMetadata?.comicInfo || {}),
|
||||
comicvine: JSON.stringify(comic.sourcedMetadata?.comicvine || {}),
|
||||
@@ -861,21 +1257,63 @@ export const resolvers = {
|
||||
}),
|
||||
},
|
||||
|
||||
// Field resolvers for statistics types
|
||||
/**
|
||||
* Field resolvers for FileTypeStats type
|
||||
* @description Resolves ID field for file type statistics
|
||||
*/
|
||||
FileTypeStats: {
|
||||
/**
|
||||
* Resolve FileTypeStats ID
|
||||
* @param {any} stats - Statistics document
|
||||
* @returns {string} ID value
|
||||
*/
|
||||
id: (stats: any) => stats._id || stats.id,
|
||||
},
|
||||
|
||||
/**
|
||||
* Field resolvers for PublisherStats type
|
||||
* @description Resolves ID field for publisher statistics
|
||||
*/
|
||||
PublisherStats: {
|
||||
/**
|
||||
* Resolve PublisherStats ID
|
||||
* @param {any} stats - Statistics document
|
||||
* @returns {string} ID value
|
||||
*/
|
||||
id: (stats: any) => stats._id || stats.id,
|
||||
},
|
||||
|
||||
/**
|
||||
* Field resolvers for IssueStats type
|
||||
* @description Resolves ID field for issue statistics
|
||||
*/
|
||||
IssueStats: {
|
||||
/**
|
||||
* Resolve IssueStats ID
|
||||
* @param {any} stats - Statistics document
|
||||
* @returns {string} ID value
|
||||
*/
|
||||
id: (stats: any) => stats._id || stats.id,
|
||||
},
|
||||
|
||||
/**
|
||||
* Field resolvers for UserPreferences type
|
||||
* @description Custom resolvers for transforming UserPreferences data
|
||||
*/
|
||||
UserPreferences: {
|
||||
/**
|
||||
* Resolve UserPreferences ID
|
||||
* @param {any} prefs - Preferences document
|
||||
* @returns {string} String representation of MongoDB ObjectId
|
||||
*/
|
||||
id: (prefs: any) => prefs._id.toString(),
|
||||
|
||||
/**
|
||||
* Resolve field preferences
|
||||
* @param {any} prefs - Preferences document
|
||||
* @returns {Array} Array of field preference objects
|
||||
* @description Converts Map to array of {field, preferredSource} objects
|
||||
*/
|
||||
fieldPreferences: (prefs: any) => {
|
||||
if (!prefs.fieldPreferences) return [];
|
||||
return Array.from(prefs.fieldPreferences.entries()).map(
|
||||
@@ -885,6 +1323,13 @@ export const resolvers = {
|
||||
})
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* Resolve source priorities
|
||||
* @param {any} prefs - Preferences document
|
||||
* @returns {Array} Array of source priority objects with field overrides
|
||||
* @description Converts fieldOverrides Map to array format for GraphQL
|
||||
*/
|
||||
sourcePriorities: (prefs: any) => {
|
||||
return prefs.sourcePriorities.map((sp: any) => ({
|
||||
...sp,
|
||||
@@ -900,7 +1345,14 @@ export const resolvers = {
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper: Extract candidates for a field from sourced metadata
|
||||
* Extract metadata field candidates from sourced metadata
|
||||
* @private
|
||||
* @function extractCandidatesForField
|
||||
* @param {string} field - Field name to extract
|
||||
* @param {any} sourcedMetadata - Sourced metadata object
|
||||
* @returns {MetadataField[]} Array of metadata field candidates with provenance
|
||||
* @description Extracts all available values for a field from different metadata sources.
|
||||
* Maps field names to source-specific paths and extracts values with provenance information.
|
||||
*/
|
||||
function extractCandidatesForField(
|
||||
field: string,
|
||||
@@ -957,14 +1409,26 @@ function extractCandidatesForField(
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Get nested value from object
|
||||
* Get nested value from object using dot notation path
|
||||
* @private
|
||||
* @function getNestedValue
|
||||
* @param {any} obj - Object to traverse
|
||||
* @param {string} path - Dot-notation path (e.g., "volumeInformation.name")
|
||||
* @returns {any} Value at path or undefined
|
||||
* @description Safely traverses nested object properties using dot notation.
|
||||
*/
|
||||
function getNestedValue(obj: any, path: string): any {
|
||||
return path.split(".").reduce((current, key) => current?.[key], obj);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Convert UserPreferences model to ResolutionPreferences
|
||||
* Convert UserPreferences model to ResolutionPreferences format
|
||||
* @private
|
||||
* @function convertPreferences
|
||||
* @param {any} prefs - UserPreferences document
|
||||
* @returns {ResolutionPreferences} Preferences in resolution utility format
|
||||
* @description Transforms UserPreferences model to the format expected by
|
||||
* metadata resolution utilities.
|
||||
*/
|
||||
function convertPreferences(prefs: any): ResolutionPreferences {
|
||||
return {
|
||||
@@ -982,7 +1446,14 @@ function convertPreferences(prefs: any): ResolutionPreferences {
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Get resolution reason for display
|
||||
* Get human-readable resolution reason
|
||||
* @private
|
||||
* @function getResolutionReason
|
||||
* @param {MetadataField|null} resolved - Resolved metadata field
|
||||
* @param {MetadataField[]} candidates - All candidate fields
|
||||
* @param {any} preferences - User preferences
|
||||
* @returns {string} Human-readable explanation of resolution
|
||||
* @description Generates explanation for why a particular field value was chosen.
|
||||
*/
|
||||
function getResolutionReason(
|
||||
resolved: MetadataField | null,
|
||||
@@ -1000,7 +1471,13 @@ function getResolutionReason(
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Apply preferences input to existing preferences
|
||||
* Apply preference input overrides to existing preferences
|
||||
* @private
|
||||
* @function applyPreferencesInput
|
||||
* @param {any} prefs - Existing preferences document
|
||||
* @param {any} input - Input preferences to apply
|
||||
* @returns {any} Updated preferences object
|
||||
* @description Merges input preferences with existing preferences for preview operations.
|
||||
*/
|
||||
function applyPreferencesInput(prefs: any, input: any): any {
|
||||
const updated = { ...prefs.toObject() };
|
||||
|
||||
@@ -1,5 +1,77 @@
|
||||
/**
|
||||
* @fileoverview GraphQL schema type definitions
|
||||
* @module models/graphql/typedef
|
||||
* @description Defines the complete GraphQL schema for the comic library management system.
|
||||
* Includes types for:
|
||||
* - Canonical metadata with provenance tracking
|
||||
* - Comic books with multi-source metadata
|
||||
* - User preferences for metadata resolution
|
||||
* - Library statistics and search functionality
|
||||
* - Mutations for metadata management and comic import
|
||||
*
|
||||
* The schema supports a sophisticated metadata resolution system that merges data from
|
||||
* multiple sources (ComicVine, Metron, ComicInfo.xml, etc.) with configurable priorities
|
||||
* and conflict resolution strategies.
|
||||
*
|
||||
* @see {@link module:models/graphql/resolvers} for resolver implementations
|
||||
* @see {@link module:utils/metadata.resolution.utils} for metadata resolution logic
|
||||
*/
|
||||
|
||||
import { gql } from "graphql-tag";
|
||||
|
||||
/**
|
||||
* GraphQL schema type definitions
|
||||
* @constant {DocumentNode} typeDefs
|
||||
* @description Complete GraphQL schema including:
|
||||
*
|
||||
* **Core Types:**
|
||||
* - `Comic` - Main comic book type with canonical and sourced metadata
|
||||
* - `CanonicalMetadata` - Resolved metadata from multiple sources
|
||||
* - `SourcedMetadata` - Raw metadata from each source
|
||||
* - `UserPreferences` - User configuration for metadata resolution
|
||||
*
|
||||
* **Metadata Types:**
|
||||
* - `MetadataField` - Single field with provenance information
|
||||
* - `MetadataArrayField` - Array field with provenance
|
||||
* - `Provenance` - Source, confidence, and timestamp information
|
||||
* - `Creator` - Creator information with role and provenance
|
||||
*
|
||||
* **Enums:**
|
||||
* - `MetadataSource` - Available metadata sources
|
||||
* - `ConflictResolutionStrategy` - Strategies for resolving conflicts
|
||||
* - `SearchType` - Types of search operations
|
||||
*
|
||||
* **Queries:**
|
||||
* - `comic(id)` - Get single comic by ID
|
||||
* - `comics(...)` - List comics with pagination and filtering
|
||||
* - `getComicBooks(...)` - Advanced comic listing with predicates
|
||||
* - `getLibraryStatistics` - Library statistics and aggregations
|
||||
* - `searchIssue(...)` - Elasticsearch-powered search
|
||||
* - `userPreferences(userId)` - Get user preferences
|
||||
* - `analyzeMetadataConflicts(comicId)` - Analyze metadata conflicts
|
||||
* - `previewCanonicalMetadata(...)` - Preview resolution without saving
|
||||
*
|
||||
* **Mutations:**
|
||||
* - `updateUserPreferences(...)` - Update resolution preferences
|
||||
* - `setMetadataField(...)` - Manually override a field
|
||||
* - `resolveMetadata(comicId)` - Trigger metadata resolution
|
||||
* - `bulkResolveMetadata(comicIds)` - Bulk resolution
|
||||
* - `removeMetadataOverride(...)` - Remove manual override
|
||||
* - `importComic(input)` - Import new comic with auto-resolution
|
||||
* - `updateSourcedMetadata(...)` - Update source data and re-resolve
|
||||
*
|
||||
* @example
|
||||
* ```graphql
|
||||
* query GetComic {
|
||||
* comic(id: "507f1f77bcf86cd799439011") {
|
||||
* canonicalMetadata {
|
||||
* title { value provenance { source confidence } }
|
||||
* series { value provenance { source confidence } }
|
||||
* }
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export const typeDefs = gql`
|
||||
# Metadata source enumeration
|
||||
enum MetadataSource {
|
||||
|
||||
331
utils/graphql.error.utils.ts
Normal file
331
utils/graphql.error.utils.ts
Normal 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;
|
||||
}
|
||||
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));
|
||||
}
|
||||
436
utils/graphql.validation.utils.ts
Normal file
436
utils/graphql.validation.utils.ts
Normal 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user