From 2e31f6cf4909995767591d8be9680c23044f3d4b Mon Sep 17 00:00:00 2001 From: Rishi Ghan Date: Thu, 26 Mar 2026 21:02:31 -0400 Subject: [PATCH] Fix for schema-stitching issues at service startup --- models/graphql/typedef.ts | 12 +- services/api.service.ts | 8 +- services/gateway.service.ts | 311 +++++++++++------------------------- 3 files changed, 100 insertions(+), 231 deletions(-) diff --git a/models/graphql/typedef.ts b/models/graphql/typedef.ts index afdf4d9..8228edb 100644 --- a/models/graphql/typedef.ts +++ b/models/graphql/typedef.ts @@ -151,7 +151,7 @@ export const typeDefs = gql` } # Weekly pull list item (from League of Comic Geeks) - type PullListItem { + type MetadataPullListItem { name: String publisher: String url: String @@ -165,13 +165,13 @@ export const typeDefs = gql` } # Paginated pull list response - type PullListResponse { - result: [PullListItem!]! - meta: PaginationMeta! + type MetadataPullListResponse { + result: [MetadataPullListItem!]! + meta: MetadataPaginationMeta! } # Pagination metadata - type PaginationMeta { + type MetadataPaginationMeta { currentPage: Int! totalPages: Int! pageSize: Int! @@ -336,7 +336,7 @@ export const typeDefs = gql` """ Get weekly pull list from League of Comic Geeks """ - getWeeklyPullList(input: WeeklyPullListInput!): PullListResponse! + getWeeklyPullList(input: WeeklyPullListInput!): MetadataPullListResponse! """ Fetch resource from Metron API diff --git a/services/api.service.ts b/services/api.service.ts index 192a387..33938a8 100644 --- a/services/api.service.ts +++ b/services/api.service.ts @@ -135,10 +135,10 @@ export default class ApiService extends Service { logging: true, }, // Standalone metadata GraphQL endpoint (no stitching) - // This endpoint exposes only the local metadata schema for the core-service to stitch + // This endpoint exposes only the local metadata schema for external services to stitch { path: "/metadata-graphql", - whitelist: ["graphql.query"], + whitelist: ["gateway.queryLocal"], cors: { origin: "*", methods: ["GET", "POST", "OPTIONS"], @@ -152,7 +152,7 @@ export default class ApiService extends Service { try { const { query, variables, operationName } = req.body; - const result = await req.$ctx.broker.call("graphql.query", { + const result = await req.$ctx.broker.call("gateway.queryLocal", { query, variables, operationName, @@ -182,7 +182,7 @@ export default class ApiService extends Service { const operationName = req.$params.operationName; try { - const result = await req.$ctx.broker.call("graphql.query", { + const result = await req.$ctx.broker.call("gateway.queryLocal", { query, variables, operationName, diff --git a/services/gateway.service.ts b/services/gateway.service.ts index 19f2c6f..3d38009 100644 --- a/services/gateway.service.ts +++ b/services/gateway.service.ts @@ -1,7 +1,6 @@ import { Service, ServiceBroker } from "moleculer"; import { ApolloServer } from "@apollo/server"; import { stitchSchemas } from "@graphql-tools/stitch"; -import { wrapSchema } from "@graphql-tools/wrap"; import { print, getIntrospectionQuery, buildClientSchema } from "graphql"; import { AsyncExecutor } from "@graphql-tools/utils"; import axios from "axios"; @@ -10,11 +9,11 @@ import { resolvers } from "../models/graphql/resolvers"; /** * GraphQL Gateway Service with Schema Stitching - * Combines the local metadata schema with the remote GraphQL server on port 3000 + * Combines the local metadata schema with the remote GraphQL server */ export default class GatewayService extends Service { private apolloServer?: ApolloServer; - private remoteGraphQLUrl = process.env.REMOTE_GRAPHQL_URL || "http://localhost:3000/graphql"; + private localApolloServer?: ApolloServer; public constructor(broker: ServiceBroker) { super(broker); @@ -23,8 +22,6 @@ export default class GatewayService extends Service { name: "gateway", settings: { - // Gateway endpoint path - path: "/graphql", remoteGraphQLUrl: process.env.REMOTE_GRAPHQL_URL || "http://localhost:3000/graphql", }, @@ -39,53 +36,43 @@ export default class GatewayService extends Service { operationName: { type: "string", optional: true }, }, async handler(ctx: any) { - try { - if (!this.apolloServer) { - throw new Error("Apollo Gateway Server not initialized"); - } - - const { query, variables, operationName } = ctx.params; - - this.logger.debug("Executing GraphQL query through gateway:", { - operationName, - variables, - }); - - const response = await this.apolloServer.executeOperation( - { - query, - variables, - operationName, - }, - { - contextValue: { - broker: this.broker, - ctx, - }, - } - ); - - if (response.body.kind === "single") { - return response.body.singleResult; - } - - return response; - } catch (error) { - this.logger.error("GraphQL gateway query error:", error); - throw error; + if (!this.apolloServer) { + throw new Error("Apollo Gateway Server not initialized"); } + + const { query, variables, operationName } = ctx.params; + + const response = await this.apolloServer.executeOperation( + { query, variables, operationName }, + { contextValue: { broker: this.broker, ctx } } + ); + + return response.body.kind === "single" ? response.body.singleResult : response; }, }, /** - * Get stitched schema information + * Execute a GraphQL query against local metadata schema only */ - getSchema: { - async handler() { - return { - message: "Stitched schema combining local metadata service and remote GraphQL server", - remoteUrl: this.settings.remoteGraphQLUrl, - }; + queryLocal: { + params: { + query: "string", + variables: { type: "object", optional: true }, + operationName: { type: "string", optional: true }, + }, + async handler(ctx: any) { + if (!this.localApolloServer) { + throw new Error("Local Apollo Server not initialized"); + } + + const { query, variables, operationName } = ctx.params; + + const response = await this.localApolloServer.executeOperation( + { query, variables, operationName }, + { contextValue: { broker: this.broker, ctx } } + ); + + return response.body.kind === "single" ? response.body.singleResult : response; }, }, }, @@ -96,43 +83,21 @@ export default class GatewayService extends Service { */ createRemoteExecutor(): AsyncExecutor { const remoteUrl = this.settings.remoteGraphQLUrl; - const logger = this.logger; - - return async ({ document, variables, context }) => { - const query = print(document); - - logger.debug(`Executing remote query to ${remoteUrl}`); + return async ({ document, variables }) => { try { const response = await axios.post( remoteUrl, - { - query, - variables, - }, - { - headers: { - "Content-Type": "application/json", - }, - timeout: 30000, // 30 second timeout - } + { query: print(document), variables }, + { headers: { "Content-Type": "application/json" }, timeout: 30000 } ); - return response.data; } catch (error: any) { - logger.error("Remote GraphQL execution error:", error.message); - - // Return a GraphQL-formatted error return { - errors: [ - { - message: `Failed to execute query on remote server: ${error.message}`, - extensions: { - code: "REMOTE_GRAPHQL_ERROR", - remoteUrl, - }, - }, - ], + errors: [{ + message: `Remote server error: ${error.message}`, + extensions: { code: "REMOTE_GRAPHQL_ERROR" }, + }], }; } }; @@ -142,169 +107,73 @@ export default class GatewayService extends Service { * Initialize Apollo Server with stitched schema */ async initApolloGateway() { - this.logger.info("Initializing Apollo Gateway with Schema Stitching..."); + this.logger.info("Initializing Apollo Gateway..."); + const { makeExecutableSchema } = await import("@graphql-tools/schema"); + const { execute } = await import("graphql"); + + // Create local schema + const localSchema = makeExecutableSchema({ typeDefs, resolvers }); + + // Create standalone local Apollo Server for /metadata-graphql endpoint + this.localApolloServer = new ApolloServer({ schema: localSchema, introspection: true }); + await this.localApolloServer.start(); + this.logger.info("Local metadata Apollo Server started"); + + // Create local executor + const localExecutor: AsyncExecutor = async ({ document, variables, context }) => { + return execute({ + schema: localSchema, + document, + variableValues: variables, + contextValue: { broker: context?.broker || this.broker, ctx: context?.ctx }, + }) as any; + }; + + // Try to introspect remote schema + let remoteSchema = null; try { - // Create executor for remote schema - const remoteExecutor = this.createRemoteExecutor(); - - // Try to introspect the remote schema - let remoteSchema; - try { - this.logger.info(`Attempting to introspect remote schema at ${this.remoteGraphQLUrl}`); - - // Manually introspect the remote schema - const introspectionQuery = getIntrospectionQuery(); - const introspectionResult = await remoteExecutor({ - document: { kind: 'Document', definitions: [] } as any, - variables: {}, - context: {}, - }); - - // Fetch introspection via direct query - const response = await axios.post( - this.remoteGraphQLUrl, - { query: introspectionQuery }, - { - headers: { "Content-Type": "application/json" }, - timeout: 30000, - } - ); - - if (response.data.errors) { - throw new Error(`Introspection failed: ${JSON.stringify(response.data.errors)}`); - } + const response = await axios.post( + this.settings.remoteGraphQLUrl, + { query: getIntrospectionQuery() }, + { headers: { "Content-Type": "application/json" }, timeout: 30000 } + ); + if (!response.data.errors) { remoteSchema = buildClientSchema(response.data.data); - this.logger.info("Successfully introspected remote schema"); - } catch (error: any) { - this.logger.warn( - `Could not introspect remote schema at ${this.remoteGraphQLUrl}: ${error.message}` - ); - this.logger.warn("Gateway will start with local schema only. Remote schema will be unavailable."); - remoteSchema = null; + this.logger.info("Remote schema introspected successfully"); } - - // Create local executable schema - const { makeExecutableSchema } = await import("@graphql-tools/schema"); - const localSchema = makeExecutableSchema({ - typeDefs, - resolvers: { - Query: { - ...resolvers.Query, - }, - Mutation: { - ...resolvers.Mutation, - }, - JSON: resolvers.JSON, - }, - }); - - // Stitch schemas together - let stitchedSchema; - if (remoteSchema) { - this.logger.info("Stitching local and remote schemas together..."); - stitchedSchema = stitchSchemas({ - subschemas: [ - { - schema: localSchema, - executor: async ({ document, variables, context }) => { - // Execute local queries through Moleculer broker - const query = print(document); - const broker = context?.broker || this.broker; - - // Parse the query to determine which resolver to call - // For now, we'll execute through the local resolvers directly - const result = await this.executeLocalQuery(query, variables, context); - return result; - }, - }, - { - schema: remoteSchema, - executor: remoteExecutor, - }, - ], - mergeTypes: true, // Merge types with the same name - }); - this.logger.info("Schema stitching completed successfully"); - } else { - this.logger.info("Using local schema only (remote unavailable)"); - stitchedSchema = localSchema; - } - - // Create Apollo Server with stitched schema - this.apolloServer = new ApolloServer({ - schema: stitchedSchema, - introspection: true, - formatError: (error) => { - this.logger.error("GraphQL Gateway Error:", error); - return { - message: error.message, - locations: error.locations, - path: error.path, - extensions: { - code: error.extensions?.code, - stacktrace: - process.env.NODE_ENV === "development" - ? error.extensions?.stacktrace - : undefined, - }, - }; - }, - }); - - await this.apolloServer.start(); - this.logger.info("Apollo Gateway Server started successfully"); } catch (error: any) { - this.logger.error("Failed to initialize Apollo Gateway:", error); - throw error; - } - }, - - /** - * Execute local queries through Moleculer actions - */ - async executeLocalQuery(query: string, variables: any, context: any) { - // This is a simplified implementation - // In production, you'd want more sophisticated query parsing - const broker = context?.broker || this.broker; - - // Determine which action to call based on the query - // This is a basic implementation - you may need to enhance this - if (query.includes("searchComicVine")) { - const result = await broker.call("comicvine.search", variables.input); - return { data: { searchComicVine: result } }; - } else if (query.includes("volumeBasedSearch")) { - const result = await broker.call("comicvine.volumeBasedSearch", variables.input); - return { data: { volumeBasedSearch: result } }; - } else if (query.includes("getIssuesForSeries")) { - const result = await broker.call("comicvine.getIssuesForSeries", { - comicObjectId: variables.comicObjectId, - }); - return { data: { getIssuesForSeries: result } }; - } else if (query.includes("getWeeklyPullList")) { - const result = await broker.call("comicvine.getWeeklyPullList", variables.input); - return { data: { getWeeklyPullList: result } }; - } else if (query.includes("getVolume")) { - const result = await broker.call("comicvine.getVolume", variables.input); - return { data: { getVolume: result } }; - } else if (query.includes("fetchMetronResource")) { - const result = await broker.call("metron.fetchResource", variables.input); - return { data: { fetchMetronResource: result } }; + this.logger.warn(`Remote schema unavailable: ${error.message}`); } - return { data: null }; + // Stitch schemas or use local only + const schema = remoteSchema + ? stitchSchemas({ + subschemas: [ + { schema: localSchema, executor: localExecutor }, + { schema: remoteSchema, executor: this.createRemoteExecutor() }, + ], + mergeTypes: false, + }) + : localSchema; + + this.apolloServer = new ApolloServer({ schema, introspection: true }); + await this.apolloServer.start(); + this.logger.info("Apollo Gateway started"); }, /** * Stop Apollo Gateway Server */ async stopApolloGateway() { + if (this.localApolloServer) { + await this.localApolloServer.stop(); + this.localApolloServer = undefined; + } if (this.apolloServer) { - this.logger.info("Stopping Apollo Gateway Server..."); await this.apolloServer.stop(); this.apolloServer = undefined; - this.logger.info("Apollo Gateway Server stopped"); } }, },