📕 Added terse JSDoc to graphql.service

This commit is contained in:
2026-03-05 11:07:41 -05:00
parent 965565f7b8
commit a2f9be71ed

View File

@@ -1,195 +1,125 @@
/** /**
* @fileoverview GraphQL service for schema stitching and query execution * @fileoverview GraphQL service for schema stitching and query execution
* @module services/graphql.service * @module services/graphql.service
* @description Provides a unified GraphQL API by stitching together local canonical metadata * @description Provides unified GraphQL API by stitching local canonical metadata schema
* schema with remote metadata-graphql schema. Handles GraphQL query execution, schema * with remote metadata-graphql schema. Falls back to local-only if remote unavailable.
* introspection, and automatic metadata resolution events. Exposes a GraphQL endpoint
* via moleculer-web at /graphql.
*
* The service attempts to connect to a remote metadata GraphQL service and stitch its
* schema with the local schema. If the remote service is unavailable, it falls back to
* serving only the local schema.
*
* @see {@link module:models/graphql/typedef} for local schema definitions
* @see {@link module:models/graphql/resolvers} for local resolver implementations
*/ */
import { ServiceBroker, Context } from "moleculer"; import { Context } from "moleculer";
import { graphql, GraphQLSchema, parse, validate, execute } from "graphql"; import { graphql, GraphQLSchema, buildClientSchema, getIntrospectionQuery, IntrospectionQuery, print } from "graphql";
import { makeExecutableSchema } from "@graphql-tools/schema"; import { makeExecutableSchema } from "@graphql-tools/schema";
import { stitchSchemas } from "@graphql-tools/stitch"; import { stitchSchemas } from "@graphql-tools/stitch";
import { wrapSchema } from "@graphql-tools/wrap";
import { print, getIntrospectionQuery, buildClientSchema, IntrospectionQuery } from "graphql";
import { fetch } from "undici"; import { fetch } from "undici";
import { typeDefs } from "../models/graphql/typedef"; import { typeDefs } from "../models/graphql/typedef";
import { resolvers } from "../models/graphql/resolvers"; import { resolvers } from "../models/graphql/resolvers";
/** /**
* Fetch remote GraphQL schema via introspection with timeout handling * Fetch remote GraphQL schema via introspection with timeout handling
* @async * @param url - Remote GraphQL endpoint URL
* @function fetchRemoteSchema * @param timeout - Request timeout in milliseconds (default: 10000)
* @param {string} url - The URL of the remote GraphQL endpoint * @returns Introspected GraphQL schema
* @param {number} [timeout=10000] - Request timeout in milliseconds
* @returns {Promise<GraphQLSchema>} The introspected GraphQL schema
* @throws {Error} If introspection fails, times out, or returns errors
* @description Fetches a GraphQL schema from a remote endpoint using introspection query.
* Implements timeout handling with AbortController to prevent hanging requests.
* Validates the response and builds a client schema from the introspection result.
*
* @example
* ```typescript
* const schema = await fetchRemoteSchema('http://localhost:3080/metadata-graphql', 5000);
* ```
*/ */
async function fetchRemoteSchema(url: string, timeout: number = 10000) { async function fetchRemoteSchema(url: string, timeout = 10000): Promise<GraphQLSchema> {
const introspectionQuery = getIntrospectionQuery();
const controller = new AbortController(); const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout); const timeoutId = setTimeout(() => controller.abort(), timeout);
try { try {
const response = await fetch(url, { const response = await fetch(url, {
method: "POST", method: "POST",
headers: { headers: { "Content-Type": "application/json" },
"Content-Type": "application/json", body: JSON.stringify({ query: getIntrospectionQuery() }),
},
body: JSON.stringify({ query: introspectionQuery }),
signal: controller.signal, signal: controller.signal,
}); });
clearTimeout(timeoutId); clearTimeout(timeoutId);
if (!response.ok) { if (!response.ok) {
throw new Error(`Failed to introspect remote schema: HTTP ${response.status} ${response.statusText}`); throw new Error(`Failed to introspect remote schema: HTTP ${response.status}`);
} }
const result = await response.json() as { data?: IntrospectionQuery; errors?: any[] }; const result = await response.json() as { data?: IntrospectionQuery; errors?: any[] };
if (result.errors && result.errors.length > 0) { if (result.errors?.length) throw new Error(`Introspection errors: ${JSON.stringify(result.errors)}`);
throw new Error(`Introspection errors: ${JSON.stringify(result.errors)}`); if (!result.data) throw new Error("No data returned from introspection query");
}
if (!result.data) {
throw new Error("No data returned from introspection query");
}
return buildClientSchema(result.data); return buildClientSchema(result.data);
} catch (error: any) { } catch (error: any) {
clearTimeout(timeoutId); clearTimeout(timeoutId);
if (error.name === 'AbortError') { if (error.name === 'AbortError') throw new Error(`Request timeout after ${timeout}ms`);
throw new Error(`Request timeout after ${timeout}ms`);
}
throw error; throw error;
} }
} }
/** /**
* Create executor function for remote GraphQL endpoint * Create executor function for remote GraphQL endpoint
* @function createRemoteExecutor * @param url - Remote GraphQL endpoint URL
* @param {string} url - The URL of the remote GraphQL endpoint * @returns Executor function compatible with schema stitching
* @returns {Function} Executor function compatible with schema stitching
* @description Creates an executor function that forwards GraphQL operations to a remote
* endpoint. The executor handles query printing, variable passing, and error formatting.
* Used by schema stitching to delegate queries to the remote schema.
*
* @example
* ```typescript
* const executor = createRemoteExecutor('http://localhost:3080/metadata-graphql');
* // Used in stitchSchemas configuration
* ```
*/ */
function createRemoteExecutor(url: string) { function createRemoteExecutor(url: string) {
return async ({ document, variables }: any) => { return async ({ document, variables }: any) => {
const query = print(document); const response = await fetch(url, {
method: "POST",
try { headers: { "Content-Type": "application/json" },
const response = await fetch(url, { body: JSON.stringify({ query: print(document), variables }),
method: "POST", });
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ query, variables }),
});
if (!response.ok) { if (!response.ok) throw new Error(`Remote GraphQL request failed: ${response.statusText}`);
throw new Error(`Remote GraphQL request failed: ${response.statusText}`); return response.json();
}
return await response.json();
} catch (error) {
console.error("Error executing remote GraphQL query:", error);
throw error;
}
}; };
} }
/**
* Auto-resolve metadata if user preferences allow
* @param broker - Moleculer broker instance
* @param logger - Logger instance
* @param comicId - Comic ID to resolve metadata for
* @param condition - Preference condition to check (onImport or onMetadataUpdate)
*/
async function autoResolveMetadata(broker: any, logger: any, comicId: string, condition: string) {
try {
const UserPreferences = require("../models/userpreferences.model").default;
const preferences = await UserPreferences.findOne({ userId: "default" });
if (preferences?.autoMerge?.enabled && preferences?.autoMerge?.[condition]) {
logger.info(`Auto-resolving metadata for comic ${comicId}`);
await broker.call("graphql.graphql", {
query: `mutation ResolveMetadata($comicId: ID!) { resolveMetadata(comicId: $comicId) { id } }`,
variables: { comicId },
});
}
} catch (error) {
logger.error("Error in auto-resolution:", error);
}
}
/** /**
* GraphQL Service * GraphQL Service
* @constant {Object} GraphQLService * @description Moleculer service providing unified GraphQL API via schema stitching.
* @description Moleculer service that provides a unified GraphQL API by stitching together: * Stitches local canonical metadata schema with remote metadata-graphql schema.
* - Local canonical metadata schema (comics, user preferences, library statistics)
* - Remote metadata-graphql schema (weekly pull lists, metadata sources)
* *
* **Features:** * Actions:
* - Schema stitching with automatic fallback to local-only mode * - graphql.graphql - Execute GraphQL queries/mutations
* - GraphQL query and mutation execution * - graphql.getSchema - Get schema type definitions
* - Automatic metadata resolution on import events
* - Timeout handling and error recovery
* - Debug logging for schema introspection
* *
* **Actions:** * Events:
* - `graphql.graphql` - Execute GraphQL queries/mutations * - metadata.imported - Triggers auto-resolution if enabled
* - `graphql.getSchema` - Get schema type definitions * - comic.imported - Triggers auto-resolution on import if enabled
*
* **Events:**
* - `metadata.imported` - Triggers auto-resolution if enabled
* - `comic.imported` - Triggers auto-resolution on import if enabled
*
* **Settings:**
* - `metadataGraphqlUrl` - Remote metadata GraphQL endpoint URL
*
* @example
* ```typescript
* // Execute a GraphQL query via broker
* const result = await broker.call('graphql.graphql', {
* query: 'query { comic(id: "123") { id } }',
* variables: {}
* });
* ```
*/ */
export default { export default {
name: "graphql", name: "graphql",
settings: { settings: {
/** /** Remote metadata GraphQL endpoint URL */
* Remote metadata GraphQL endpoint URL
* @type {string}
* @default "http://localhost:3080/metadata-graphql"
* @description URL of the remote metadata GraphQL service to stitch with local schema.
* Can be overridden via METADATA_GRAPHQL_URL environment variable.
*/
metadataGraphqlUrl: process.env.METADATA_GRAPHQL_URL || "http://localhost:3080/metadata-graphql", metadataGraphqlUrl: process.env.METADATA_GRAPHQL_URL || "http://localhost:3080/metadata-graphql",
}, },
actions: { actions: {
/** /**
* Execute GraphQL queries and mutations * Execute GraphQL queries and mutations
* @action graphql * @param query - GraphQL query or mutation string
* @param {string} query - GraphQL query or mutation string * @param variables - Variables for the GraphQL operation
* @param {Object} [variables] - Variables for the GraphQL operation * @param operationName - Name of the operation to execute
* @param {string} [operationName] - Name of the operation to execute * @returns GraphQL execution result with data or errors
* @returns {Promise<Object>} GraphQL execution result with data or errors
* @description Main action for executing GraphQL operations against the stitched schema.
* Called by moleculer-web from the /graphql HTTP endpoint. Provides broker and context
* to resolvers for service communication.
*
* @example
* ```typescript
* await broker.call('graphql.graphql', {
* query: 'mutation { resolveMetadata(comicId: "123") { id } }',
* variables: {}
* });
* ```
*/ */
graphql: { graphql: {
params: { params: {
@@ -199,29 +129,19 @@ export default {
}, },
async handler(ctx: Context<{ query: string; variables?: any; operationName?: string }>) { async handler(ctx: Context<{ query: string; variables?: any; operationName?: string }>) {
try { try {
const { query, variables, operationName } = ctx.params; return await graphql({
// Execute the GraphQL query
const result = await graphql({
schema: this.schema, schema: this.schema,
source: query, source: ctx.params.query,
variableValues: variables, variableValues: ctx.params.variables,
operationName, operationName: ctx.params.operationName,
contextValue: { contextValue: { broker: this.broker, ctx },
broker: this.broker,
ctx,
},
}); });
return result;
} catch (error: any) { } catch (error: any) {
this.logger.error("GraphQL execution error:", error); this.logger.error("GraphQL execution error:", error);
return { return {
errors: [{ errors: [{
message: error.message, message: error.message,
extensions: { extensions: { code: "INTERNAL_SERVER_ERROR" },
code: "INTERNAL_SERVER_ERROR",
},
}], }],
}; };
} }
@@ -230,215 +150,83 @@ export default {
/** /**
* Get GraphQL schema type definitions * Get GraphQL schema type definitions
* @action getSchema * @returns Object containing schema type definitions as string
* @returns {Promise<Object>} Object containing schema type definitions as string
* @description Returns the local schema type definitions. Useful for schema
* documentation and introspection.
*
* @example
* ```typescript
* const { typeDefs } = await broker.call('graphql.getSchema');
* ```
*/ */
getSchema: { getSchema: {
async handler() { async handler() {
return { return { typeDefs: typeDefs.loc?.source.body || "" };
typeDefs: typeDefs.loc?.source.body || "",
};
}, },
}, },
}, },
events: { events: {
/** /**
* Handle metadata imported event * Handle metadata imported event - triggers auto-resolution if enabled
* @event metadata.imported
* @param {Object} params - Event parameters
* @param {string} params.comicId - ID of the comic with new metadata
* @param {string} params.source - Metadata source that was imported
* @description Triggered when new metadata is imported for a comic. If auto-merge
* is enabled in user preferences, automatically resolves canonical metadata.
*
* @example
* ```typescript
* broker.emit('metadata.imported', { comicId: '123', source: 'COMICVINE' });
* ```
*/ */
"metadata.imported": { "metadata.imported": {
async handler(ctx: any) { async handler(ctx: any) {
const { comicId, source } = ctx.params; const { comicId, source } = ctx.params;
this.logger.info( this.logger.info(`Metadata imported for comic ${comicId} from ${source}`);
`Metadata imported for comic ${comicId} from ${source}` await autoResolveMetadata(this.broker, this.logger, comicId, "onMetadataUpdate");
);
// Optionally trigger auto-resolution if enabled
try {
const UserPreferences = require("../models/userpreferences.model").default;
const preferences = await UserPreferences.findOne({
userId: "default",
});
if (
preferences?.autoMerge?.enabled &&
preferences?.autoMerge?.onMetadataUpdate
) {
this.logger.info(
`Auto-resolving metadata for comic ${comicId}`
);
// Call the graphql action
await this.broker.call("graphql.graphql", {
query: `
mutation ResolveMetadata($comicId: ID!) {
resolveMetadata(comicId: $comicId) {
id
}
}
`,
variables: { comicId },
});
}
} catch (error) {
this.logger.error("Error in auto-resolution:", error);
}
}, },
}, },
/** /**
* Handle comic imported event * Handle comic imported event - triggers auto-resolution if enabled
* @event comic.imported
* @param {Object} params - Event parameters
* @param {string} params.comicId - ID of the newly imported comic
* @description Triggered when a new comic is imported into the library. If auto-merge
* on import is enabled in user preferences, automatically resolves canonical metadata.
*
* @example
* ```typescript
* broker.emit('comic.imported', { comicId: '123' });
* ```
*/ */
"comic.imported": { "comic.imported": {
async handler(ctx: any) { async handler(ctx: any) {
const { comicId } = ctx.params; this.logger.info(`Comic imported: ${ctx.params.comicId}`);
this.logger.info(`Comic imported: ${comicId}`); await autoResolveMetadata(this.broker, this.logger, ctx.params.comicId, "onImport");
// Optionally trigger auto-resolution if enabled
try {
const UserPreferences = require("../models/userpreferences.model").default;
const preferences = await UserPreferences.findOne({
userId: "default",
});
if (
preferences?.autoMerge?.enabled &&
preferences?.autoMerge?.onImport
) {
this.logger.info(
`Auto-resolving metadata for newly imported comic ${comicId}`
);
// Call the graphql action
await this.broker.call("graphql.graphql", {
query: `
mutation ResolveMetadata($comicId: ID!) {
resolveMetadata(comicId: $comicId) {
id
}
}
`,
variables: { comicId },
});
}
} catch (error) {
this.logger.error("Error in auto-resolution on import:", error);
}
}, },
}, },
}, },
/** /**
* Service started lifecycle hook * Service started lifecycle hook
* @async * Creates local schema and attempts to stitch with remote metadata schema.
* @function started * Falls back to local-only if remote unavailable.
* @description Initializes the GraphQL service by creating the local schema and attempting
* to stitch it with the remote metadata schema. Implements the following workflow:
*
* 1. Create local executable schema from type definitions and resolvers
* 2. Attempt to introspect remote metadata GraphQL service
* 3. If successful, stitch local and remote schemas together
* 4. If failed, fall back to local schema only
* 5. Log available Query fields for debugging
*
* The service will continue to function with local schema only if remote stitching fails,
* but queries requiring remote types (like WeeklyPullList) will not be available.
*/ */
async started() { async started() {
this.logger.info("GraphQL service starting..."); this.logger.info("GraphQL service starting...");
// Create local schema const localSchema = makeExecutableSchema({ typeDefs, resolvers });
const localSchema = makeExecutableSchema({
typeDefs,
resolvers,
});
// Try to stitch remote schema if available
try { try {
this.logger.info(`Attempting to introspect remote schema at ${this.settings.metadataGraphqlUrl}`); this.logger.info(`Attempting to introspect remote schema at ${this.settings.metadataGraphqlUrl}`);
// Fetch and build the remote schema
const remoteSchema = await fetchRemoteSchema(this.settings.metadataGraphqlUrl); const remoteSchema = await fetchRemoteSchema(this.settings.metadataGraphqlUrl);
this.logger.info("Successfully introspected remote metadata schema"); this.logger.info("Successfully introspected remote metadata schema");
// Log remote schema types for debugging
const remoteQueryType = remoteSchema.getQueryType(); const remoteQueryType = remoteSchema.getQueryType();
if (remoteQueryType) { if (remoteQueryType) {
const remoteFields = Object.keys(remoteQueryType.getFields()); this.logger.info(`Remote schema Query fields: ${Object.keys(remoteQueryType.getFields()).join(', ')}`);
this.logger.info(`Remote schema Query fields: ${remoteFields.join(', ')}`);
} }
// Create executor for remote schema
const remoteExecutor = createRemoteExecutor(this.settings.metadataGraphqlUrl);
// Stitch schemas together with proper configuration
this.schema = stitchSchemas({ this.schema = stitchSchemas({
subschemas: [ subschemas: [
{ { schema: localSchema },
schema: localSchema, { schema: remoteSchema, executor: createRemoteExecutor(this.settings.metadataGraphqlUrl) },
},
{
schema: remoteSchema,
executor: remoteExecutor,
},
], ],
// Merge types from both schemas
mergeTypes: true, mergeTypes: true,
}); });
// Log stitched schema types for debugging
const stitchedQueryType = this.schema.getQueryType(); const stitchedQueryType = this.schema.getQueryType();
if (stitchedQueryType) { if (stitchedQueryType) {
const stitchedFields = Object.keys(stitchedQueryType.getFields()); this.logger.info(`Stitched schema Query fields: ${Object.keys(stitchedQueryType.getFields()).join(', ')}`);
this.logger.info(`Stitched schema Query fields: ${stitchedFields.join(', ')}`);
} }
this.logger.info("Successfully stitched local and remote schemas"); this.logger.info("Successfully stitched local and remote schemas");
} catch (remoteError: any) { } catch (remoteError: any) {
this.logger.warn( this.logger.warn(`Could not connect to remote metadata GraphQL at ${this.settings.metadataGraphqlUrl}: ${remoteError.message}`);
`Could not connect to remote metadata GraphQL at ${this.settings.metadataGraphqlUrl}: ${remoteError.message}`
);
this.logger.warn("Continuing with local schema only"); this.logger.warn("Continuing with local schema only");
// Use local schema only
this.schema = localSchema; this.schema = localSchema;
} }
this.logger.info("GraphQL service started successfully"); this.logger.info("GraphQL service started successfully");
}, },
/** /** Service stopped lifecycle hook */
* Service stopped lifecycle hook
* @function stopped
* @description Cleanup hook called when the service is stopped. Logs service shutdown.
*/
stopped() { stopped() {
this.logger.info("GraphQL service stopped"); this.logger.info("GraphQL service stopped");
}, },