🪢 Fixed schema stitching and added JSDoc
This commit is contained in:
@@ -1,3 +1,19 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview GraphQL service for schema stitching and query execution
|
||||||
|
* @module services/graphql.service
|
||||||
|
* @description Provides a unified GraphQL API by stitching together local canonical metadata
|
||||||
|
* schema with remote metadata-graphql schema. Handles GraphQL query execution, schema
|
||||||
|
* 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 { ServiceBroker, Context } from "moleculer";
|
||||||
import { graphql, GraphQLSchema, parse, validate, execute } from "graphql";
|
import { graphql, GraphQLSchema, parse, validate, execute } from "graphql";
|
||||||
import { makeExecutableSchema } from "@graphql-tools/schema";
|
import { makeExecutableSchema } from "@graphql-tools/schema";
|
||||||
@@ -9,38 +25,78 @@ import { typeDefs } from "../models/graphql/typedef";
|
|||||||
import { resolvers } from "../models/graphql/resolvers";
|
import { resolvers } from "../models/graphql/resolvers";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch remote GraphQL schema via introspection
|
* Fetch remote GraphQL schema via introspection with timeout handling
|
||||||
|
* @async
|
||||||
|
* @function fetchRemoteSchema
|
||||||
|
* @param {string} url - The URL of the remote GraphQL endpoint
|
||||||
|
* @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) {
|
async function fetchRemoteSchema(url: string, timeout: number = 10000) {
|
||||||
const introspectionQuery = getIntrospectionQuery();
|
const introspectionQuery = getIntrospectionQuery();
|
||||||
|
|
||||||
const response = await fetch(url, {
|
const controller = new AbortController();
|
||||||
method: "POST",
|
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ query: introspectionQuery }),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Failed to introspect remote schema: ${response.statusText}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await response.json() as { data?: IntrospectionQuery; errors?: any[] };
|
|
||||||
|
|
||||||
if (result.errors) {
|
try {
|
||||||
throw new Error(`Introspection errors: ${JSON.stringify(result.errors)}`);
|
const response = await fetch(url, {
|
||||||
}
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ query: introspectionQuery }),
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
|
||||||
if (!result.data) {
|
clearTimeout(timeoutId);
|
||||||
throw new Error("No data returned from introspection query");
|
|
||||||
}
|
|
||||||
|
|
||||||
return buildClientSchema(result.data);
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to introspect remote schema: 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");
|
||||||
|
}
|
||||||
|
|
||||||
|
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 for remote GraphQL endpoint
|
* Create executor function for remote GraphQL endpoint
|
||||||
|
* @function createRemoteExecutor
|
||||||
|
* @param {string} url - The URL of the remote GraphQL endpoint
|
||||||
|
* @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) => {
|
||||||
@@ -69,22 +125,71 @@ function createRemoteExecutor(url: string) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* GraphQL Service
|
* GraphQL Service
|
||||||
* Provides a GraphQL API for canonical metadata queries and mutations
|
* @constant {Object} GraphQLService
|
||||||
* Standalone service that exposes a graphql action for moleculer-web
|
* @description Moleculer service that provides a unified GraphQL API by stitching together:
|
||||||
* Stitches remote metadata-graphql schema from port 3080
|
* - Local canonical metadata schema (comics, user preferences, library statistics)
|
||||||
|
* - Remote metadata-graphql schema (weekly pull lists, metadata sources)
|
||||||
|
*
|
||||||
|
* **Features:**
|
||||||
|
* - Schema stitching with automatic fallback to local-only mode
|
||||||
|
* - GraphQL query and mutation execution
|
||||||
|
* - Automatic metadata resolution on import events
|
||||||
|
* - Timeout handling and error recovery
|
||||||
|
* - Debug logging for schema introspection
|
||||||
|
*
|
||||||
|
* **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
|
||||||
|
*
|
||||||
|
* **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
|
/**
|
||||||
|
* 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
|
||||||
* This action is called by moleculer-web from the /graphql route
|
* @action graphql
|
||||||
|
* @param {string} query - GraphQL query or mutation string
|
||||||
|
* @param {Object} [variables] - Variables for the GraphQL operation
|
||||||
|
* @param {string} [operationName] - Name of the operation to execute
|
||||||
|
* @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: {
|
||||||
@@ -124,7 +229,16 @@ export default {
|
|||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get GraphQL schema
|
* Get GraphQL schema type definitions
|
||||||
|
* @action getSchema
|
||||||
|
* @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() {
|
||||||
@@ -137,7 +251,18 @@ export default {
|
|||||||
|
|
||||||
events: {
|
events: {
|
||||||
/**
|
/**
|
||||||
* Trigger metadata resolution when new metadata is imported
|
* Handle metadata imported event
|
||||||
|
* @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) {
|
||||||
@@ -179,7 +304,17 @@ export default {
|
|||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Trigger metadata resolution when comic is imported
|
* Handle comic imported event
|
||||||
|
* @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) {
|
||||||
@@ -219,6 +354,22 @@ export default {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service started lifecycle hook
|
||||||
|
* @async
|
||||||
|
* @function started
|
||||||
|
* @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...");
|
||||||
|
|
||||||
@@ -237,27 +388,38 @@ export default {
|
|||||||
|
|
||||||
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();
|
||||||
|
if (remoteQueryType) {
|
||||||
|
const remoteFields = Object.keys(remoteQueryType.getFields());
|
||||||
|
this.logger.info(`Remote schema Query fields: ${remoteFields.join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
// Create executor for remote schema
|
// Create executor for remote schema
|
||||||
const remoteExecutor = createRemoteExecutor(this.settings.metadataGraphqlUrl);
|
const remoteExecutor = createRemoteExecutor(this.settings.metadataGraphqlUrl);
|
||||||
|
|
||||||
// Wrap the remote schema with executor
|
// Stitch schemas together with proper configuration
|
||||||
const wrappedRemoteSchema = wrapSchema({
|
|
||||||
schema: remoteSchema,
|
|
||||||
executor: remoteExecutor,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Stitch schemas together
|
|
||||||
this.schema = stitchSchemas({
|
this.schema = stitchSchemas({
|
||||||
subschemas: [
|
subschemas: [
|
||||||
{
|
{
|
||||||
schema: localSchema,
|
schema: localSchema,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
schema: wrappedRemoteSchema,
|
schema: remoteSchema,
|
||||||
|
executor: remoteExecutor,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
// Merge types from both schemas
|
||||||
|
mergeTypes: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Log stitched schema types for debugging
|
||||||
|
const stitchedQueryType = this.schema.getQueryType();
|
||||||
|
if (stitchedQueryType) {
|
||||||
|
const stitchedFields = Object.keys(stitchedQueryType.getFields());
|
||||||
|
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(
|
||||||
@@ -272,6 +434,11 @@ export default {
|
|||||||
this.logger.info("GraphQL service started successfully");
|
this.logger.info("GraphQL service started successfully");
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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");
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user