/** * @fileoverview GraphQL service for schema stitching and query execution * @module services/graphql.service * @description Provides unified GraphQL API by stitching local canonical metadata schema * with remote metadata-graphql schema. Falls back to local-only if remote unavailable. */ import { Context } from "moleculer"; import { graphql, GraphQLSchema, buildClientSchema, getIntrospectionQuery, IntrospectionQuery, print } from "graphql"; import { makeExecutableSchema } from "@graphql-tools/schema"; import { stitchSchemas } from "@graphql-tools/stitch"; import { fetch } from "undici"; import { typeDefs } from "../models/graphql/typedef"; import { resolvers } from "../models/graphql/resolvers"; /** * Fetch remote GraphQL schema via introspection with timeout handling * @param url - Remote GraphQL endpoint URL * @param timeout - Request timeout in milliseconds (default: 10000) * @returns Introspected GraphQL schema */ async function fetchRemoteSchema(url: string, timeout = 10000): Promise { 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: getIntrospectionQuery() }), signal: controller.signal, }); clearTimeout(timeoutId); if (!response.ok) { throw new Error(`Failed to introspect remote schema: HTTP ${response.status}`); } const result = await response.json() as { data?: IntrospectionQuery; errors?: any[] }; if (result.errors?.length) throw new Error(`Introspection errors: ${JSON.stringify(result.errors)}`); if (!result.data) throw new Error("No data returned from introspection query"); return buildClientSchema(result.data); } catch (error: any) { clearTimeout(timeoutId); if (error.name === 'AbortError') throw new Error(`Request timeout after ${timeout}ms`); throw error; } } /** * Create executor function for remote GraphQL endpoint * @param url - Remote GraphQL endpoint URL * @returns Executor function compatible with schema stitching */ function createRemoteExecutor(url: string) { return async ({ document, variables }: any) => { const response = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ query: print(document), variables }), }); if (!response.ok) throw new Error(`Remote GraphQL request failed: ${response.statusText}`); return response.json(); }; } /** * 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 * @description Moleculer service providing unified GraphQL API via schema stitching. * Stitches local canonical metadata schema with remote metadata-graphql schema. * * Actions: * - graphql.graphql - Execute GraphQL queries/mutations * - graphql.getSchema - Get schema type definitions * * Events: * - metadata.imported - Triggers auto-resolution if enabled * - comic.imported - Triggers auto-resolution on import if enabled */ export default { name: "graphql", settings: { /** Remote metadata GraphQL endpoint URL */ metadataGraphqlUrl: process.env.METADATA_GRAPHQL_URL || "http://localhost:3080/metadata-graphql", /** Remote acquisition GraphQL endpoint URL */ acquisitionGraphqlUrl: process.env.ACQUISITION_GRAPHQL_URL || "http://localhost:3060/acquisition-graphql", /** Retry interval in ms for re-stitching remote schemas (0 = disabled) */ schemaRetryInterval: 5000, }, actions: { /** * Check remote schema health and availability * @returns Status of remote schema connection with appropriate HTTP status */ checkRemoteSchema: { async handler(ctx: Context) { const status: any = { remoteSchemaAvailable: this.remoteSchemaAvailable || false, remoteUrl: this.settings.metadataGraphqlUrl, localSchemaOnly: !this.remoteSchemaAvailable, }; if (this.remoteSchemaAvailable && this.schema) { const queryType = this.schema.getQueryType(); if (queryType) { const fields = Object.keys(queryType.getFields()); status.availableQueryFields = fields; status.hasWeeklyPullList = fields.includes('getWeeklyPullList'); } } // Set HTTP status code based on schema stitching status // 200 = Schema stitching complete (remote available) // 503 = Service degraded (local only, remote unavailable) (ctx.meta as any).$statusCode = this.remoteSchemaAvailable ? 200 : 503; return status; }, }, /** * Execute GraphQL queries and mutations * @param query - GraphQL query or mutation string * @param variables - Variables for the GraphQL operation * @param operationName - Name of the operation to execute * @returns GraphQL execution result with data or errors */ graphql: { params: { query: { type: "string" }, variables: { type: "object", optional: true }, operationName: { type: "string", optional: true }, }, async handler(ctx: Context<{ query: string; variables?: any; operationName?: string }>) { try { return await graphql({ schema: this.schema, source: ctx.params.query, variableValues: ctx.params.variables, operationName: ctx.params.operationName, contextValue: { broker: this.broker, ctx }, }); } catch (error: any) { this.logger.error("GraphQL execution error:", error); return { errors: [{ message: error.message, extensions: { code: "INTERNAL_SERVER_ERROR" }, }], }; } }, }, /** * Get GraphQL schema type definitions * @returns Object containing schema type definitions as string */ getSchema: { async handler() { return { typeDefs: typeDefs.loc?.source.body || "" }; }, }, }, events: { /** * Handle metadata imported event - triggers auto-resolution if enabled */ "metadata.imported": { async handler(ctx: any) { const { comicId, source } = ctx.params; this.logger.info(`Metadata imported for comic ${comicId} from ${source}`); await autoResolveMetadata(this.broker, this.logger, comicId, "onMetadataUpdate"); }, }, /** * Handle comic imported event - triggers auto-resolution if enabled */ "comic.imported": { async handler(ctx: any) { this.logger.info(`Comic imported: ${ctx.params.comicId}`); await autoResolveMetadata(this.broker, this.logger, ctx.params.comicId, "onImport"); }, }, }, methods: { /** * Attempt to build/rebuild the stitched schema. * Returns true if at least one remote schema was stitched. */ async _buildSchema(localSchema: any): Promise { const subschemas: any[] = [{ schema: localSchema }]; // Stitch metadata schema try { this.logger.info(`Attempting to introspect remote schema at ${this.settings.metadataGraphqlUrl}`); const metadataSchema = await fetchRemoteSchema(this.settings.metadataGraphqlUrl); subschemas.push({ schema: metadataSchema, executor: createRemoteExecutor(this.settings.metadataGraphqlUrl) }); this.logger.info("✓ Successfully introspected remote metadata schema"); } catch (error: any) { this.logger.warn(`⚠ Metadata schema unavailable: ${error.message}`); } // Stitch acquisition schema try { this.logger.info(`Attempting to introspect remote schema at ${this.settings.acquisitionGraphqlUrl}`); const acquisitionSchema = await fetchRemoteSchema(this.settings.acquisitionGraphqlUrl); subschemas.push({ schema: acquisitionSchema, executor: createRemoteExecutor(this.settings.acquisitionGraphqlUrl) }); this.logger.info("✓ Successfully introspected remote acquisition schema"); } catch (error: any) { this.logger.warn(`⚠ Acquisition schema unavailable: ${error.message}`); } if (subschemas.length > 1) { this.schema = stitchSchemas({ subschemas, mergeTypes: true }); this.logger.info(`✓ Stitched ${subschemas.length} schemas`); this.remoteSchemaAvailable = true; return true; } else { this.schema = localSchema; this.remoteSchemaAvailable = false; return false; } }, }, /** * Service started lifecycle hook * Blocks until remote schemas are stitched, retrying every schemaRetryInterval ms. */ async started() { this.logger.info("GraphQL service starting..."); this._localSchema = makeExecutableSchema({ typeDefs, resolvers }); this.schema = this._localSchema; this.remoteSchemaAvailable = false; while (true) { const stitched = await this._buildSchema(this._localSchema); if (stitched) break; this.logger.warn(`⚠ Remote schemas unavailable — retrying in ${this.settings.schemaRetryInterval}ms`); await new Promise(resolve => setTimeout(resolve, this.settings.schemaRetryInterval)); } this.logger.info("GraphQL service started successfully"); }, /** Service stopped lifecycle hook */ stopped() { this.logger.info("GraphQL service stopped"); }, };