Fix for schema-stitching issues at service startup
Some checks failed
Docker Image CI / build (push) Has been cancelled
Some checks failed
Docker Image CI / build (push) Has been cancelled
This commit is contained in:
@@ -151,7 +151,7 @@ export const typeDefs = gql`
|
|||||||
}
|
}
|
||||||
|
|
||||||
# Weekly pull list item (from League of Comic Geeks)
|
# Weekly pull list item (from League of Comic Geeks)
|
||||||
type PullListItem {
|
type MetadataPullListItem {
|
||||||
name: String
|
name: String
|
||||||
publisher: String
|
publisher: String
|
||||||
url: String
|
url: String
|
||||||
@@ -165,13 +165,13 @@ export const typeDefs = gql`
|
|||||||
}
|
}
|
||||||
|
|
||||||
# Paginated pull list response
|
# Paginated pull list response
|
||||||
type PullListResponse {
|
type MetadataPullListResponse {
|
||||||
result: [PullListItem!]!
|
result: [MetadataPullListItem!]!
|
||||||
meta: PaginationMeta!
|
meta: MetadataPaginationMeta!
|
||||||
}
|
}
|
||||||
|
|
||||||
# Pagination metadata
|
# Pagination metadata
|
||||||
type PaginationMeta {
|
type MetadataPaginationMeta {
|
||||||
currentPage: Int!
|
currentPage: Int!
|
||||||
totalPages: Int!
|
totalPages: Int!
|
||||||
pageSize: Int!
|
pageSize: Int!
|
||||||
@@ -336,7 +336,7 @@ export const typeDefs = gql`
|
|||||||
"""
|
"""
|
||||||
Get weekly pull list from League of Comic Geeks
|
Get weekly pull list from League of Comic Geeks
|
||||||
"""
|
"""
|
||||||
getWeeklyPullList(input: WeeklyPullListInput!): PullListResponse!
|
getWeeklyPullList(input: WeeklyPullListInput!): MetadataPullListResponse!
|
||||||
|
|
||||||
"""
|
"""
|
||||||
Fetch resource from Metron API
|
Fetch resource from Metron API
|
||||||
|
|||||||
@@ -135,10 +135,10 @@ export default class ApiService extends Service {
|
|||||||
logging: true,
|
logging: true,
|
||||||
},
|
},
|
||||||
// Standalone metadata GraphQL endpoint (no stitching)
|
// 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",
|
path: "/metadata-graphql",
|
||||||
whitelist: ["graphql.query"],
|
whitelist: ["gateway.queryLocal"],
|
||||||
cors: {
|
cors: {
|
||||||
origin: "*",
|
origin: "*",
|
||||||
methods: ["GET", "POST", "OPTIONS"],
|
methods: ["GET", "POST", "OPTIONS"],
|
||||||
@@ -152,7 +152,7 @@ export default class ApiService extends Service {
|
|||||||
try {
|
try {
|
||||||
const { query, variables, operationName } = req.body;
|
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,
|
query,
|
||||||
variables,
|
variables,
|
||||||
operationName,
|
operationName,
|
||||||
@@ -182,7 +182,7 @@ export default class ApiService extends Service {
|
|||||||
const operationName = req.$params.operationName;
|
const operationName = req.$params.operationName;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await req.$ctx.broker.call("graphql.query", {
|
const result = await req.$ctx.broker.call("gateway.queryLocal", {
|
||||||
query,
|
query,
|
||||||
variables,
|
variables,
|
||||||
operationName,
|
operationName,
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { Service, ServiceBroker } from "moleculer";
|
import { Service, ServiceBroker } from "moleculer";
|
||||||
import { ApolloServer } from "@apollo/server";
|
import { ApolloServer } from "@apollo/server";
|
||||||
import { stitchSchemas } from "@graphql-tools/stitch";
|
import { stitchSchemas } from "@graphql-tools/stitch";
|
||||||
import { wrapSchema } from "@graphql-tools/wrap";
|
|
||||||
import { print, getIntrospectionQuery, buildClientSchema } from "graphql";
|
import { print, getIntrospectionQuery, buildClientSchema } from "graphql";
|
||||||
import { AsyncExecutor } from "@graphql-tools/utils";
|
import { AsyncExecutor } from "@graphql-tools/utils";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
@@ -10,11 +9,11 @@ import { resolvers } from "../models/graphql/resolvers";
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* GraphQL Gateway Service with Schema Stitching
|
* 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 {
|
export default class GatewayService extends Service {
|
||||||
private apolloServer?: ApolloServer;
|
private apolloServer?: ApolloServer;
|
||||||
private remoteGraphQLUrl = process.env.REMOTE_GRAPHQL_URL || "http://localhost:3000/graphql";
|
private localApolloServer?: ApolloServer;
|
||||||
|
|
||||||
public constructor(broker: ServiceBroker) {
|
public constructor(broker: ServiceBroker) {
|
||||||
super(broker);
|
super(broker);
|
||||||
@@ -23,8 +22,6 @@ export default class GatewayService extends Service {
|
|||||||
name: "gateway",
|
name: "gateway",
|
||||||
|
|
||||||
settings: {
|
settings: {
|
||||||
// Gateway endpoint path
|
|
||||||
path: "/graphql",
|
|
||||||
remoteGraphQLUrl: process.env.REMOTE_GRAPHQL_URL || "http://localhost:3000/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 },
|
operationName: { type: "string", optional: true },
|
||||||
},
|
},
|
||||||
async handler(ctx: any) {
|
async handler(ctx: any) {
|
||||||
try {
|
if (!this.apolloServer) {
|
||||||
if (!this.apolloServer) {
|
throw new Error("Apollo Gateway Server not initialized");
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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: {
|
queryLocal: {
|
||||||
async handler() {
|
params: {
|
||||||
return {
|
query: "string",
|
||||||
message: "Stitched schema combining local metadata service and remote GraphQL server",
|
variables: { type: "object", optional: true },
|
||||||
remoteUrl: this.settings.remoteGraphQLUrl,
|
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 {
|
createRemoteExecutor(): AsyncExecutor {
|
||||||
const remoteUrl = this.settings.remoteGraphQLUrl;
|
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 {
|
try {
|
||||||
const response = await axios.post(
|
const response = await axios.post(
|
||||||
remoteUrl,
|
remoteUrl,
|
||||||
{
|
{ query: print(document), variables },
|
||||||
query,
|
{ headers: { "Content-Type": "application/json" }, timeout: 30000 }
|
||||||
variables,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
timeout: 30000, // 30 second timeout
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
logger.error("Remote GraphQL execution error:", error.message);
|
|
||||||
|
|
||||||
// Return a GraphQL-formatted error
|
|
||||||
return {
|
return {
|
||||||
errors: [
|
errors: [{
|
||||||
{
|
message: `Remote server error: ${error.message}`,
|
||||||
message: `Failed to execute query on remote server: ${error.message}`,
|
extensions: { code: "REMOTE_GRAPHQL_ERROR" },
|
||||||
extensions: {
|
}],
|
||||||
code: "REMOTE_GRAPHQL_ERROR",
|
|
||||||
remoteUrl,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -142,169 +107,73 @@ export default class GatewayService extends Service {
|
|||||||
* Initialize Apollo Server with stitched schema
|
* Initialize Apollo Server with stitched schema
|
||||||
*/
|
*/
|
||||||
async initApolloGateway() {
|
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 {
|
try {
|
||||||
// Create executor for remote schema
|
const response = await axios.post(
|
||||||
const remoteExecutor = this.createRemoteExecutor();
|
this.settings.remoteGraphQLUrl,
|
||||||
|
{ query: getIntrospectionQuery() },
|
||||||
// Try to introspect the remote schema
|
{ headers: { "Content-Type": "application/json" }, timeout: 30000 }
|
||||||
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)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
if (!response.data.errors) {
|
||||||
remoteSchema = buildClientSchema(response.data.data);
|
remoteSchema = buildClientSchema(response.data.data);
|
||||||
this.logger.info("Successfully introspected remote schema");
|
this.logger.info("Remote schema introspected successfully");
|
||||||
} 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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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) {
|
} catch (error: any) {
|
||||||
this.logger.error("Failed to initialize Apollo Gateway:", error);
|
this.logger.warn(`Remote schema unavailable: ${error.message}`);
|
||||||
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 } };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
* Stop Apollo Gateway Server
|
||||||
*/
|
*/
|
||||||
async stopApolloGateway() {
|
async stopApolloGateway() {
|
||||||
|
if (this.localApolloServer) {
|
||||||
|
await this.localApolloServer.stop();
|
||||||
|
this.localApolloServer = undefined;
|
||||||
|
}
|
||||||
if (this.apolloServer) {
|
if (this.apolloServer) {
|
||||||
this.logger.info("Stopping Apollo Gateway Server...");
|
|
||||||
await this.apolloServer.stop();
|
await this.apolloServer.stop();
|
||||||
this.apolloServer = undefined;
|
this.apolloServer = undefined;
|
||||||
this.logger.info("Apollo Gateway Server stopped");
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user