🪢 Added resolvers for LoCG

This commit is contained in:
2026-03-04 23:36:10 -05:00
parent cad3326417
commit b753481754
10 changed files with 3906 additions and 253 deletions

View File

@@ -56,6 +56,163 @@ export default class ApiService extends Service {
// Enable/disable logging
logging: true,
},
// GraphQL Gateway endpoint with schema stitching
{
path: "/graphql",
whitelist: ["gateway.query"],
cors: {
origin: "*",
methods: ["GET", "POST", "OPTIONS"],
allowedHeaders: ["*"],
exposedHeaders: [],
credentials: false,
maxAge: 3600,
},
aliases: {
"POST /": async (req: any, res: any) => {
try {
const { query, variables, operationName } = req.body;
const result = await req.$ctx.broker.call("gateway.query", {
query,
variables,
operationName,
});
res.setHeader("Content-Type", "application/json");
res.end(JSON.stringify(result));
} catch (error: any) {
res.statusCode = 500;
res.setHeader("Content-Type", "application/json");
res.end(JSON.stringify({
errors: [{
message: error.message,
extensions: {
code: error.code || "INTERNAL_SERVER_ERROR",
},
}],
}));
}
},
"GET /": async (req: any, res: any) => {
// Support GraphQL Playground/introspection via GET
const query = req.$params.query;
const variables = req.$params.variables
? JSON.parse(req.$params.variables)
: undefined;
const operationName = req.$params.operationName;
try {
const result = await req.$ctx.broker.call("gateway.query", {
query,
variables,
operationName,
});
res.setHeader("Content-Type", "application/json");
res.end(JSON.stringify(result));
} catch (error: any) {
res.statusCode = 500;
res.setHeader("Content-Type", "application/json");
res.end(JSON.stringify({
errors: [{
message: error.message,
extensions: {
code: error.code || "INTERNAL_SERVER_ERROR",
},
}],
}));
}
},
},
bodyParsers: {
json: {
strict: false,
limit: "1MB",
},
},
mappingPolicy: "restrict",
logging: true,
},
// Standalone metadata GraphQL endpoint (no stitching)
// This endpoint exposes only the local metadata schema for the core-service to stitch
{
path: "/metadata-graphql",
whitelist: ["graphql.query"],
cors: {
origin: "*",
methods: ["GET", "POST", "OPTIONS"],
allowedHeaders: ["*"],
exposedHeaders: [],
credentials: false,
maxAge: 3600,
},
aliases: {
"POST /": async (req: any, res: any) => {
try {
const { query, variables, operationName } = req.body;
const result = await req.$ctx.broker.call("graphql.query", {
query,
variables,
operationName,
});
res.setHeader("Content-Type", "application/json");
res.end(JSON.stringify(result));
} catch (error: any) {
res.statusCode = 500;
res.setHeader("Content-Type", "application/json");
res.end(JSON.stringify({
errors: [{
message: error.message,
extensions: {
code: error.code || "INTERNAL_SERVER_ERROR",
},
}],
}));
}
},
"GET /": async (req: any, res: any) => {
// Support GraphQL Playground/introspection via GET
const query = req.$params.query;
const variables = req.$params.variables
? JSON.parse(req.$params.variables)
: undefined;
const operationName = req.$params.operationName;
try {
const result = await req.$ctx.broker.call("graphql.query", {
query,
variables,
operationName,
});
res.setHeader("Content-Type", "application/json");
res.end(JSON.stringify(result));
} catch (error: any) {
res.statusCode = 500;
res.setHeader("Content-Type", "application/json");
res.end(JSON.stringify({
errors: [{
message: error.message,
extensions: {
code: error.code || "INTERNAL_SERVER_ERROR",
},
}],
}));
}
},
},
bodyParsers: {
json: {
strict: false,
limit: "1MB",
},
},
mappingPolicy: "restrict",
logging: true,
},
],
// Do not log client side errors (does not log an error response when the error.code is 400<=X<500)
log4XXResponses: false,

View File

@@ -220,29 +220,70 @@ export default class ComicVineService extends Service {
"passed to fetchVolumesFromCV",
ctx.params
);
// Send initial status to client
await this.broker.call("socket.broadcast", {
namespace: "/",
event: "CV_SCRAPING_STATUS",
args: [
{
message: `Starting volume search for: ${ctx.params.scorerConfiguration.searchParams.name}`,
stage: "fetching_volumes"
},
],
});
const volumes = await this.fetchVolumesFromCV(
ctx.params,
results
);
// Notify client that volume fetching is complete
await this.broker.call("socket.broadcast", {
namespace: "/",
event: "CV_SCRAPING_STATUS",
args: [
{
message: `Fetched ${volumes.length} volumes, now ranking matches...`,
stage: "ranking_volumes"
},
],
});
// 1. Run the current batch of volumes through the matcher
const potentialVolumeMatches = rankVolumes(
volumes,
ctx.params.scorerConfiguration
);
// Sort by totalScore in descending order to prioritize best matches
potentialVolumeMatches.sort((a: any, b: any) => b.totalScore - a.totalScore);
// Notify client about ranked matches
await this.broker.call("socket.broadcast", {
namespace: "/",
event: "CV_SCRAPING_STATUS",
args: [
{
message: `Found ${potentialVolumeMatches.length} potential volume matches, searching for issues...`,
stage: "searching_issues"
},
],
});
// 2. Construct the filter string
// 2a. volume: 1111|2222|3333
let volumeIdString = "volume:";
potentialVolumeMatches.map(
(volumeId: string, idx: number) => {
(volumeMatch: any, idx: number) => {
if (
idx >=
potentialVolumeMatches.length - 1
) {
volumeIdString += `${volumeId}`;
volumeIdString += `${volumeMatch.id}`;
return volumeIdString;
}
volumeIdString += `${volumeId}|`;
volumeIdString += `${volumeMatch.id}|`;
}
);
@@ -286,6 +327,39 @@ export default class ComicVineService extends Service {
console.log(
`Total issues matching the criteria: ${issueMatches.data.results.length}`
);
// Handle case when no issues are found
if (issueMatches.data.results.length === 0) {
await this.broker.call("socket.broadcast", {
namespace: "/",
event: "CV_SCRAPING_STATUS",
args: [
{
message: `No matching issues found. Try adjusting your search criteria.`,
stage: "complete"
},
],
});
return {
finalMatches: [],
rawFileDetails,
scorerConfiguration,
};
}
// Notify client about issue matches found
await this.broker.call("socket.broadcast", {
namespace: "/",
event: "CV_SCRAPING_STATUS",
args: [
{
message: `Found ${issueMatches.data.results.length} issue matches, fetching volume details...`,
stage: "fetching_volume_details"
},
],
});
// 3. get volume information for the issue matches
if (issueMatches.data.results.length === 1) {
const volumeInformation =
@@ -299,9 +373,44 @@ export default class ComicVineService extends Service {
);
issueMatches.data.results[0].volumeInformation =
volumeInformation;
return issueMatches.data;
// Notify scoring for single match
await this.broker.call("socket.broadcast", {
namespace: "/",
event: "CV_SCRAPING_STATUS",
args: [
{
message: `Scoring 1 match...`,
stage: "scoring_matches"
},
],
});
// Score the single match
const scoredMatch = await this.broker.call(
"comicvine.getComicVineMatchScores",
{
finalMatches: issueMatches.data.results,
rawFileDetails,
scorerConfiguration,
}
);
// Notify completion
await this.broker.call("socket.broadcast", {
namespace: "/",
event: "CV_SCRAPING_STATUS",
args: [
{
message: `Search complete! Found 1 match.`,
stage: "complete"
},
],
});
return scoredMatch;
}
const finalMatches = issueMatches.data.results.map(
const finalMatchesPromises = issueMatches.data.results.map(
async (issue: any) => {
const volumeDetails =
await this.broker.call(
@@ -315,9 +424,24 @@ export default class ComicVineService extends Service {
return issue;
}
);
// Wait for all volume details to be fetched
const finalMatches = await Promise.all(finalMatchesPromises);
// Notify client about scoring
await this.broker.call("socket.broadcast", {
namespace: "/",
event: "CV_SCRAPING_STATUS",
args: [
{
message: `Scoring ${finalMatches.length} matches...`,
stage: "scoring_matches"
},
],
});
// Score the final matches
const foo = await this.broker.call(
const scoredMatches = await this.broker.call(
"comicvine.getComicVineMatchScores",
{
finalMatches,
@@ -325,14 +449,49 @@ export default class ComicVineService extends Service {
scorerConfiguration,
}
);
return Promise.all(finalMatches);
// Notify completion
await this.broker.call("socket.broadcast", {
namespace: "/",
event: "CV_SCRAPING_STATUS",
args: [
{
message: `Search complete! Returning scored matches.`,
stage: "complete"
},
],
});
return scoredMatches;
} catch (error) {
console.log(error);
console.error("Error in volumeBasedSearch:", error);
// Surface error to UI
await this.broker.call("socket.broadcast", {
namespace: "/",
event: "CV_SCRAPING_STATUS",
args: [
{
message: `Error during search: ${error.message || 'Unknown error'}`,
stage: "error",
error: {
message: error.message,
code: error.code,
type: error.type,
retryable: error.retryable
}
},
],
});
// Re-throw or return error response
throw error;
}
},
},
getComicVineMatchScores: {
rest: "POST /getComicVineMatchScores",
timeout: 120000, // 2 minutes - allows time for image downloads and hash calculations
handler: async (
ctx: Context<{
finalMatches: any[];

324
services/gateway.service.ts Normal file
View File

@@ -0,0 +1,324 @@
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";
import { typeDefs } from "../models/graphql/typedef";
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
*/
export default class GatewayService extends Service {
private apolloServer?: ApolloServer;
private remoteGraphQLUrl = process.env.REMOTE_GRAPHQL_URL || "http://localhost:3000/graphql";
public constructor(broker: ServiceBroker) {
super(broker);
this.parseServiceSchema({
name: "gateway",
settings: {
// Gateway endpoint path
path: "/graphql",
remoteGraphQLUrl: process.env.REMOTE_GRAPHQL_URL || "http://localhost:3000/graphql",
},
actions: {
/**
* Execute a GraphQL query through the stitched schema
*/
query: {
params: {
query: "string",
variables: { type: "object", optional: true },
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;
}
},
},
/**
* Get stitched schema information
*/
getSchema: {
async handler() {
return {
message: "Stitched schema combining local metadata service and remote GraphQL server",
remoteUrl: this.settings.remoteGraphQLUrl,
};
},
},
},
methods: {
/**
* Create an executor for the remote GraphQL server
*/
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}`);
try {
const response = await axios.post(
remoteUrl,
{
query,
variables,
},
{
headers: {
"Content-Type": "application/json",
},
timeout: 30000, // 30 second timeout
}
);
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,
},
},
],
};
}
};
},
/**
* Initialize Apollo Server with stitched schema
*/
async initApolloGateway() {
this.logger.info("Initializing Apollo Gateway with Schema Stitching...");
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)}`);
}
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;
}
// 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 } };
}
return { data: null };
},
/**
* Stop Apollo Gateway Server
*/
async stopApolloGateway() {
if (this.apolloServer) {
this.logger.info("Stopping Apollo Gateway Server...");
await this.apolloServer.stop();
this.apolloServer = undefined;
this.logger.info("Apollo Gateway Server stopped");
}
},
},
/**
* Service lifecycle hooks
*/
started: async function (this: any) {
await this.initApolloGateway();
},
stopped: async function (this: any) {
await this.stopApolloGateway();
},
});
}
}

View File

@@ -27,19 +27,15 @@ export default class MetronService extends Service {
console.log(ctx.params);
const results = await axios({
method: "GET",
url: `https://metron.cloud/api/${ctx.params.resource}`,
url: `https://metron.cloud/api/${ctx.params.resource}/`,
params: {
name: ctx.params.query.name,
page: ctx.params.query.page,
},
headers: {
"Authorization": "Basic ZnJpc2hpOlRpdHVAMTU4OA=="
},
auth: {
"username": "frishi",
"password": "Titu@1588"
username: "frishi",
password: "Titu@1588"
}
});
return results.data;
},