From 8a8acc656ad089f75af44daad7c803a1e8de01ed Mon Sep 17 00:00:00 2001 From: Rishi Ghan Date: Thu, 5 Mar 2026 01:00:04 -0500 Subject: [PATCH] =?UTF-8?q?=F0=9F=8D=B1=20graphql=20schema=20stitching=20r?= =?UTF-8?q?elated=20changes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- models/graphql/resolvers.ts | 13 ++ models/graphql/typedef.ts | 15 ++ package-lock.json | 318 ++++++++++++++++++------- package.json | 9 +- services/api.service.ts | 77 +------ services/graphql.service.ts | 449 +++++++++++++++++++++--------------- utils/file.utils.ts | 9 +- 7 files changed, 539 insertions(+), 351 deletions(-) diff --git a/models/graphql/resolvers.ts b/models/graphql/resolvers.ts index 63befa7..e383c1a 100644 --- a/models/graphql/resolvers.ts +++ b/models/graphql/resolvers.ts @@ -861,6 +861,19 @@ export const resolvers = { }), }, + // Field resolvers for statistics types + FileTypeStats: { + id: (stats: any) => stats._id || stats.id, + }, + + PublisherStats: { + id: (stats: any) => stats._id || stats.id, + }, + + IssueStats: { + id: (stats: any) => stats._id || stats.id, + }, + UserPreferences: { id: (prefs: any) => prefs._id.toString(), fieldPreferences: (prefs: any) => { diff --git a/models/graphql/typedef.ts b/models/graphql/typedef.ts index 8dbd774..fd6d439 100644 --- a/models/graphql/typedef.ts +++ b/models/graphql/typedef.ts @@ -133,6 +133,9 @@ export const typeDefs = gql` # Raw sourced metadata (for transparency) sourcedMetadata: SourcedMetadata + # Inferred metadata (from filename parsing) + inferredMetadata: InferredMetadata + # File information rawFileDetails: RawFileDetails @@ -387,6 +390,18 @@ export const typeDefs = gql` subtitle: String } + # Inferred metadata output type + type InferredMetadata { + issue: Issue + } + + type Issue { + name: String + number: Int + year: String + subtitle: String + } + input RawFileDetailsInput { name: String! filePath: String! diff --git a/package-lock.json b/package-lock.json index 1eba8ce..f06ff59 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,11 @@ "@apollo/server": "^4.12.2", "@bluelovers/fast-glob": "https://github.com/rishighan/fast-glob-v2-api.git", "@elastic/elasticsearch": "^8.13.1", + "@graphql-tools/delegate": "^12.0.8", + "@graphql-tools/schema": "^10.0.31", + "@graphql-tools/stitch": "^10.1.12", + "@graphql-tools/utils": "^11.0.0", + "@graphql-tools/wrap": "^11.1.8", "@jorgeferrero/stream-to-buffer": "^2.0.6", "@npcz/magic": "^1.3.14", "@root/walk": "^1.1.0", @@ -56,6 +61,7 @@ "sharp": "^0.33.3", "threetwo-ui-typings": "^1.0.14", "through2": "^4.0.2", + "undici": "^7.22.0", "unrar": "^0.2.0", "xml2js": "^0.6.2" }, @@ -189,6 +195,47 @@ "graphql": "14.x || 15.x || 16.x" } }, + "node_modules/@apollo/server/node_modules/@graphql-tools/merge": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/@graphql-tools/merge/-/merge-8.4.2.tgz", + "integrity": "sha512-XbrHAaj8yDuINph+sAfuq3QCZ/tKblrTLOpirK0+CAgNlZUCHs0Fa+xtMUURgwCVThLle1AF7svJCxFizygLsw==", + "license": "MIT", + "dependencies": { + "@graphql-tools/utils": "^9.2.1", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, + "node_modules/@apollo/server/node_modules/@graphql-tools/schema": { + "version": "9.0.19", + "resolved": "https://registry.npmjs.org/@graphql-tools/schema/-/schema-9.0.19.tgz", + "integrity": "sha512-oBRPoNBtCkk0zbUsyP4GaIzCt8C0aCI4ycIRUL67KK5pOHljKLBBtGT+Jr6hkzA74C8Gco8bpZPe7aWFjiaK2w==", + "license": "MIT", + "dependencies": { + "@graphql-tools/merge": "^8.4.1", + "@graphql-tools/utils": "^9.2.1", + "tslib": "^2.4.0", + "value-or-promise": "^1.0.12" + }, + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, + "node_modules/@apollo/server/node_modules/@graphql-tools/utils": { + "version": "9.2.1", + "resolved": "https://registry.npmjs.org/@graphql-tools/utils/-/utils-9.2.1.tgz", + "integrity": "sha512-WUw506Ql6xzmOORlriNrD6Ugx+HjVgYxt9KCXD9mHAak+eaXSwuGGPyE60hy9xaDEoXKBsG7SkG69ybitaVl6A==", + "license": "MIT", + "dependencies": { + "@graphql-typed-document-node/core": "^3.1.1", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, "node_modules/@apollo/server/node_modules/lru-cache": { "version": "7.18.3", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", @@ -1735,6 +1782,27 @@ "node": ">=16" } }, + "node_modules/@elastic/transport/node_modules/@fastify/busboy": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.1.1.tgz", + "integrity": "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/@elastic/transport/node_modules/undici": { + "version": "5.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-5.29.0.tgz", + "integrity": "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==", + "license": "MIT", + "dependencies": { + "@fastify/busboy": "^2.0.0" + }, + "engines": { + "node": ">=14.0" + } + }, "node_modules/@emnapi/runtime": { "version": "1.7.1", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.7.1.tgz", @@ -1822,43 +1890,174 @@ "node": ">=14" } }, - "node_modules/@graphql-tools/merge": { - "version": "8.4.2", - "resolved": "https://registry.npmjs.org/@graphql-tools/merge/-/merge-8.4.2.tgz", - "integrity": "sha512-XbrHAaj8yDuINph+sAfuq3QCZ/tKblrTLOpirK0+CAgNlZUCHs0Fa+xtMUURgwCVThLle1AF7svJCxFizygLsw==", + "node_modules/@graphql-tools/batch-delegate": { + "version": "10.0.14", + "resolved": "https://registry.npmjs.org/@graphql-tools/batch-delegate/-/batch-delegate-10.0.14.tgz", + "integrity": "sha512-jHp3TLbetZus5GGTU1CC/syfb/hrJc1d+HcZwlh4atjrZaWPWgWNAP033899FNnVPZY4w9A+sOwCVFe1wxKL8w==", "license": "MIT", "dependencies": { - "@graphql-tools/utils": "^9.2.1", + "@graphql-tools/delegate": "^12.0.8", + "@graphql-tools/utils": "^11.0.0", + "@whatwg-node/promise-helpers": "^1.3.2", + "dataloader": "^2.2.3", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, + "node_modules/@graphql-tools/batch-execute": { + "version": "10.0.5", + "resolved": "https://registry.npmjs.org/@graphql-tools/batch-execute/-/batch-execute-10.0.5.tgz", + "integrity": "sha512-dL13tXkfGvAzLq2XfzTKAy9logIcltKYRuPketxdh3Ok3U6PN1HKMCHfrE9cmtAsxD96/8Hlghz5AtM+LRv/ig==", + "license": "MIT", + "dependencies": { + "@graphql-tools/utils": "^11.0.0", + "@whatwg-node/promise-helpers": "^1.3.2", + "dataloader": "^2.2.3", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, + "node_modules/@graphql-tools/delegate": { + "version": "12.0.8", + "resolved": "https://registry.npmjs.org/@graphql-tools/delegate/-/delegate-12.0.8.tgz", + "integrity": "sha512-yltGepWaJ9KsBY3QREJrZUKadhaiT4mO4ZO42hF/vfD2fIIOKZjn99qCSZBJ0YpVbLctPrgWrgDs3WgAl13fsA==", + "license": "MIT", + "dependencies": { + "@graphql-tools/batch-execute": "^10.0.5", + "@graphql-tools/executor": "^1.4.13", + "@graphql-tools/schema": "^10.0.29", + "@graphql-tools/utils": "^11.0.0", + "@repeaterjs/repeater": "^3.0.6", + "@whatwg-node/promise-helpers": "^1.3.2", + "dataloader": "^2.2.3", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, + "node_modules/@graphql-tools/executor": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@graphql-tools/executor/-/executor-1.5.1.tgz", + "integrity": "sha512-n94Qcu875Mji9GQ52n5UbgOTxlgvFJicBPYD+FRks9HKIQpdNPjkkrKZUYNG51XKa+bf03rxNflm4+wXhoHHrA==", + "license": "MIT", + "dependencies": { + "@graphql-tools/utils": "^11.0.0", + "@graphql-typed-document-node/core": "^3.2.0", + "@repeaterjs/repeater": "^3.0.4", + "@whatwg-node/disposablestack": "^0.0.6", + "@whatwg-node/promise-helpers": "^1.0.0", "tslib": "^2.4.0" }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, + "node_modules/@graphql-tools/merge": { + "version": "9.1.7", + "resolved": "https://registry.npmjs.org/@graphql-tools/merge/-/merge-9.1.7.tgz", + "integrity": "sha512-Y5E1vTbTabvcXbkakdFUt4zUIzB1fyaEnVmIWN0l0GMed2gdD01TpZWLUm4RNAxpturvolrb24oGLQrBbPLSoQ==", + "license": "MIT", + "dependencies": { + "@graphql-tools/utils": "^11.0.0", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=16.0.0" + }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "node_modules/@graphql-tools/schema": { - "version": "9.0.19", - "resolved": "https://registry.npmjs.org/@graphql-tools/schema/-/schema-9.0.19.tgz", - "integrity": "sha512-oBRPoNBtCkk0zbUsyP4GaIzCt8C0aCI4ycIRUL67KK5pOHljKLBBtGT+Jr6hkzA74C8Gco8bpZPe7aWFjiaK2w==", + "version": "10.0.31", + "resolved": "https://registry.npmjs.org/@graphql-tools/schema/-/schema-10.0.31.tgz", + "integrity": "sha512-ZewRgWhXef6weZ0WiP7/MV47HXiuFbFpiDUVLQl6mgXsWSsGELKFxQsyUCBos60Qqy1JEFAIu3Ns6GGYjGkqkQ==", "license": "MIT", "dependencies": { - "@graphql-tools/merge": "^8.4.1", - "@graphql-tools/utils": "^9.2.1", - "tslib": "^2.4.0", - "value-or-promise": "^1.0.12" + "@graphql-tools/merge": "^9.1.7", + "@graphql-tools/utils": "^11.0.0", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, + "node_modules/@graphql-tools/stitch": { + "version": "10.1.12", + "resolved": "https://registry.npmjs.org/@graphql-tools/stitch/-/stitch-10.1.12.tgz", + "integrity": "sha512-dx5dRDVC2OOBXrXgekCmJh22EXGlMrfESMWaNFJKlypP40BK36bPAlUBpMyM1kwx5BQWwjTKUlSghB1Z5j/PgA==", + "license": "MIT", + "dependencies": { + "@graphql-tools/batch-delegate": "^10.0.14", + "@graphql-tools/delegate": "^12.0.8", + "@graphql-tools/executor": "^1.4.13", + "@graphql-tools/merge": "^9.1.5", + "@graphql-tools/schema": "^10.0.29", + "@graphql-tools/utils": "^11.0.0", + "@graphql-tools/wrap": "^11.1.8", + "@whatwg-node/promise-helpers": "^1.3.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=20.0.0" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "node_modules/@graphql-tools/utils": { - "version": "9.2.1", - "resolved": "https://registry.npmjs.org/@graphql-tools/utils/-/utils-9.2.1.tgz", - "integrity": "sha512-WUw506Ql6xzmOORlriNrD6Ugx+HjVgYxt9KCXD9mHAak+eaXSwuGGPyE60hy9xaDEoXKBsG7SkG69ybitaVl6A==", + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/@graphql-tools/utils/-/utils-11.0.0.tgz", + "integrity": "sha512-bM1HeZdXA2C3LSIeLOnH/bcqSgbQgKEDrjxODjqi3y58xai2TkNrtYcQSoWzGbt9VMN1dORGjR7Vem8SPnUFQA==", "license": "MIT", "dependencies": { "@graphql-typed-document-node/core": "^3.1.1", + "@whatwg-node/promise-helpers": "^1.0.0", + "cross-inspect": "1.0.1", "tslib": "^2.4.0" }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, + "node_modules/@graphql-tools/wrap": { + "version": "11.1.8", + "resolved": "https://registry.npmjs.org/@graphql-tools/wrap/-/wrap-11.1.8.tgz", + "integrity": "sha512-VnU7K6IDvj7kM9Viz6oAQNc6lV380u7oOG1hYau5pzHB+h1VrTYg/jHXNtWrXwB88lhCgGHjrQCJJt4wz4QdQQ==", + "license": "MIT", + "dependencies": { + "@graphql-tools/delegate": "^12.0.8", + "@graphql-tools/schema": "^10.0.29", + "@graphql-tools/utils": "^11.0.0", + "@whatwg-node/promise-helpers": "^1.3.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=20.0.0" + }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } @@ -3073,6 +3272,12 @@ "@redis/client": "^1.0.0" } }, + "node_modules/@repeaterjs/repeater": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@repeaterjs/repeater/-/repeater-3.0.6.tgz", + "integrity": "sha512-Javneu5lsuhwNCryN+pXH93VPQ8g0dBX7wItHFgYiwQmzE1sVdg5tWHiOgHywzL2W21XQopa7IwIEnNbmeUJYA==", + "license": "MIT" + }, "node_modules/@root/walk": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@root/walk/-/walk-1.1.0.tgz", @@ -4266,6 +4471,19 @@ "url": "https://opencollective.com/typescript-eslint" } }, + "node_modules/@whatwg-node/disposablestack": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@whatwg-node/disposablestack/-/disposablestack-0.0.6.tgz", + "integrity": "sha512-LOtTn+JgJvX8WfBVJtF08TGrdjuFzGJc4mkP8EdDI8ADbvO7kiexYep1o8dwnt0okb0jYclCDXF13xU7Ge4zSw==", + "license": "MIT", + "dependencies": { + "@whatwg-node/promise-helpers": "^1.0.0", + "tslib": "^2.6.3" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@whatwg-node/promise-helpers": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/@whatwg-node/promise-helpers/-/promise-helpers-1.3.2.tgz", @@ -11147,57 +11365,6 @@ "node": ">=16" } }, - "node_modules/moleculer-apollo-server/node_modules/@graphql-tools/merge": { - "version": "9.1.7", - "resolved": "https://registry.npmjs.org/@graphql-tools/merge/-/merge-9.1.7.tgz", - "integrity": "sha512-Y5E1vTbTabvcXbkakdFUt4zUIzB1fyaEnVmIWN0l0GMed2gdD01TpZWLUm4RNAxpturvolrb24oGLQrBbPLSoQ==", - "license": "MIT", - "dependencies": { - "@graphql-tools/utils": "^11.0.0", - "tslib": "^2.4.0" - }, - "engines": { - "node": ">=16.0.0" - }, - "peerDependencies": { - "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" - } - }, - "node_modules/moleculer-apollo-server/node_modules/@graphql-tools/schema": { - "version": "10.0.31", - "resolved": "https://registry.npmjs.org/@graphql-tools/schema/-/schema-10.0.31.tgz", - "integrity": "sha512-ZewRgWhXef6weZ0WiP7/MV47HXiuFbFpiDUVLQl6mgXsWSsGELKFxQsyUCBos60Qqy1JEFAIu3Ns6GGYjGkqkQ==", - "license": "MIT", - "dependencies": { - "@graphql-tools/merge": "^9.1.7", - "@graphql-tools/utils": "^11.0.0", - "tslib": "^2.4.0" - }, - "engines": { - "node": ">=16.0.0" - }, - "peerDependencies": { - "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" - } - }, - "node_modules/moleculer-apollo-server/node_modules/@graphql-tools/utils": { - "version": "11.0.0", - "resolved": "https://registry.npmjs.org/@graphql-tools/utils/-/utils-11.0.0.tgz", - "integrity": "sha512-bM1HeZdXA2C3LSIeLOnH/bcqSgbQgKEDrjxODjqi3y58xai2TkNrtYcQSoWzGbt9VMN1dORGjR7Vem8SPnUFQA==", - "license": "MIT", - "dependencies": { - "@graphql-typed-document-node/core": "^3.1.1", - "@whatwg-node/promise-helpers": "^1.0.0", - "cross-inspect": "1.0.1", - "tslib": "^2.4.0" - }, - "engines": { - "node": ">=16.0.0" - }, - "peerDependencies": { - "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" - } - }, "node_modules/moleculer-apollo-server/node_modules/body-parser": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", @@ -17515,23 +17682,12 @@ } }, "node_modules/undici": { - "version": "5.29.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-5.29.0.tgz", - "integrity": "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==", + "version": "7.22.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.22.0.tgz", + "integrity": "sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==", "license": "MIT", - "dependencies": { - "@fastify/busboy": "^2.0.0" - }, "engines": { - "node": ">=14.0" - } - }, - "node_modules/undici/node_modules/@fastify/busboy": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.1.1.tgz", - "integrity": "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==", - "engines": { - "node": ">=14" + "node": ">=20.18.1" } }, "node_modules/unit-compare": { diff --git a/package.json b/package.json index 830c844..d3a3bf8 100644 --- a/package.json +++ b/package.json @@ -39,10 +39,13 @@ "uuid": "^9.0.0" }, "dependencies": { - "@apollo/server": "^4.12.2", - "moleculer-apollo-server": "^0.4.0", "@bluelovers/fast-glob": "https://github.com/rishighan/fast-glob-v2-api.git", "@elastic/elasticsearch": "^8.13.1", + "@graphql-tools/delegate": "^12.0.8", + "@graphql-tools/schema": "^10.0.31", + "@graphql-tools/stitch": "^10.1.12", + "@graphql-tools/utils": "^11.0.0", + "@graphql-tools/wrap": "^11.1.8", "@jorgeferrero/stream-to-buffer": "^2.0.6", "@npcz/magic": "^1.3.14", "@root/walk": "^1.1.0", @@ -71,6 +74,7 @@ "leven": "^3.1.0", "lodash": "^4.17.21", "mkdirp": "^0.5.5", + "moleculer-apollo-server": "^0.4.0", "moleculer-bullmq": "^3.0.0", "moleculer-db": "^0.8.23", "moleculer-db-adapter-mongoose": "^0.9.2", @@ -87,6 +91,7 @@ "sharp": "^0.33.3", "threetwo-ui-typings": "^1.0.14", "through2": "^4.0.2", + "undici": "^7.22.0", "unrar": "^0.2.0", "xml2js": "^0.6.2" }, diff --git a/services/api.service.ts b/services/api.service.ts index 0a5a510..da1a0b6 100644 --- a/services/api.service.ts +++ b/services/api.service.ts @@ -66,81 +66,8 @@ export default class ApiService extends Service { maxAge: 3600, }, aliases: { - "POST /": async (req: any, res: any) => { - try { - const { query, variables, operationName } = req.body; - const result = await req.$service.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 }], - }) - ); - } - }, - "GET /": async (req: any, res: any) => { - // Support GraphQL Playground or introspection queries via GET - const query = req.$params.query; - const variables = req.$params.variables - ? JSON.parse(req.$params.variables) - : undefined; - const operationName = req.$params.operationName; - - if (query) { - try { - const result = await req.$service.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 }], - }) - ); - } - } else { - // Return GraphQL Playground HTML - res.setHeader("Content-Type", "text/html"); - res.end(` - - - - GraphQL Playground - - - - - -
- - - - `); - } - }, + "POST /": "graphql.graphql", + "GET /": "graphql.graphql", }, mappingPolicy: "restrict", bodyParsers: { diff --git a/services/graphql.service.ts b/services/graphql.service.ts index c9fc853..fc866c1 100644 --- a/services/graphql.service.ts +++ b/services/graphql.service.ts @@ -1,213 +1,278 @@ -import { Service, ServiceBroker } from "moleculer"; -import { ApolloServer } from "@apollo/server"; +import { ServiceBroker, Context } from "moleculer"; +import { graphql, GraphQLSchema, parse, validate, execute } from "graphql"; +import { makeExecutableSchema } from "@graphql-tools/schema"; +import { stitchSchemas } from "@graphql-tools/stitch"; +import { wrapSchema } from "@graphql-tools/wrap"; +import { print, getIntrospectionQuery, buildClientSchema, IntrospectionQuery } from "graphql"; +import { fetch } from "undici"; import { typeDefs } from "../models/graphql/typedef"; import { resolvers } from "../models/graphql/resolvers"; +/** + * Fetch remote GraphQL schema via introspection + */ +async function fetchRemoteSchema(url: string) { + const introspectionQuery = getIntrospectionQuery(); + + const response = await fetch(url, { + method: "POST", + 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) { + 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); +} + +/** + * Create executor for remote GraphQL endpoint + */ +function createRemoteExecutor(url: string) { + return async ({ document, variables }: any) => { + const query = print(document); + + try { + const response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ query, variables }), + }); + + if (!response.ok) { + throw new Error(`Remote GraphQL request failed: ${response.statusText}`); + } + + return await response.json(); + } catch (error) { + console.error("Error executing remote GraphQL query:", error); + throw error; + } + }; +} + /** * GraphQL Service * Provides a GraphQL API for canonical metadata queries and mutations - * Integrates Apollo Server with Moleculer + * Standalone service that exposes a graphql action for moleculer-web + * Stitches remote metadata-graphql schema from port 3080 */ -export default class GraphQLService extends Service { - private apolloServer?: ApolloServer; +export default { + name: "graphql", + + settings: { + // Remote metadata GraphQL endpoint + metadataGraphqlUrl: process.env.METADATA_GRAPHQL_URL || "http://localhost:3080/metadata-graphql", + }, - public constructor(broker: ServiceBroker) { - super(broker); - - this.parseServiceSchema({ - name: "graphql", - - settings: { - // GraphQL endpoint path - path: "/graphql", + actions: { + /** + * Execute GraphQL queries and mutations + * This action is called by moleculer-web from the /graphql route + */ + graphql: { + params: { + query: { type: "string" }, + variables: { type: "object", optional: true }, + operationName: { type: "string", optional: true }, }, - - actions: { - /** - * Execute a GraphQL query - */ - 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 Server not initialized"); - } - - const { query, variables, operationName } = ctx.params; - - 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 query error:", error); - throw error; - } - }, - }, - - /** - * Get GraphQL schema - */ - getSchema: { - async handler() { - return { - typeDefs: typeDefs.loc?.source.body || "", - }; - }, - }, - }, - - methods: { - /** - * Initialize Apollo Server - */ - async initApolloServer() { - this.logger.info("Initializing Apollo Server..."); - - this.apolloServer = new ApolloServer({ - typeDefs, - resolvers, - introspection: true, // Enable GraphQL Playground in development - formatError: (error) => { - this.logger.error("GraphQL Error:", error); - return { - message: error.message, - locations: error.locations, - path: error.path, - extensions: { - code: error.extensions?.code, - }, - }; + async handler(ctx: Context<{ query: string; variables?: any; operationName?: string }>) { + try { + const { query, variables, operationName } = ctx.params; + + // Execute the GraphQL query + const result = await graphql({ + schema: this.schema, + source: query, + variableValues: variables, + operationName, + contextValue: { + broker: this.broker, + ctx, }, }); - await this.apolloServer.start(); - this.logger.info("Apollo Server started successfully"); - }, - - /** - * Stop Apollo Server - */ - async stopApolloServer() { - if (this.apolloServer) { - this.logger.info("Stopping Apollo Server..."); - await this.apolloServer.stop(); - this.apolloServer = undefined; - this.logger.info("Apollo Server stopped"); - } - }, + return result; + } catch (error: any) { + this.logger.error("GraphQL execution error:", error); + return { + errors: [{ + message: error.message, + extensions: { + code: "INTERNAL_SERVER_ERROR", + }, + }], + }; + } }, + }, - events: { - /** - * Trigger metadata resolution when new metadata is imported - */ - "metadata.imported": { - async handler(ctx: any) { - const { comicId, source } = ctx.params; + /** + * Get GraphQL schema + */ + getSchema: { + async handler() { + return { + typeDefs: typeDefs.loc?.source.body || "", + }; + }, + }, + }, + + events: { + /** + * Trigger metadata resolution when new metadata is imported + */ + "metadata.imported": { + async handler(ctx: any) { + const { comicId, source } = ctx.params; + this.logger.info( + `Metadata imported for comic ${comicId} from ${source}` + ); + + // Optionally trigger auto-resolution if enabled + try { + const UserPreferences = require("../models/userpreferences.model").default; + const preferences = await UserPreferences.findOne({ + userId: "default", + }); + + if ( + preferences?.autoMerge?.enabled && + preferences?.autoMerge?.onMetadataUpdate + ) { this.logger.info( - `Metadata imported for comic ${comicId} from ${source}` + `Auto-resolving metadata for comic ${comicId}` ); - - // Optionally trigger auto-resolution if enabled - try { - const UserPreferences = require("../models/userpreferences.model").default; - const preferences = await UserPreferences.findOne({ - userId: "default", - }); - - if ( - preferences?.autoMerge?.enabled && - preferences?.autoMerge?.onMetadataUpdate - ) { - this.logger.info( - `Auto-resolving metadata for comic ${comicId}` - ); - await this.broker.call("graphql.query", { - query: ` - mutation ResolveMetadata($comicId: ID!) { - resolveMetadata(comicId: $comicId) { - id - } - } - `, - variables: { comicId }, - }); - } - } catch (error) { - this.logger.error("Error in auto-resolution:", error); - } - }, - }, - - /** - * Trigger metadata resolution when comic is imported - */ - "comic.imported": { - async handler(ctx: any) { - const { comicId } = ctx.params; - this.logger.info(`Comic imported: ${comicId}`); - - // Optionally trigger auto-resolution if enabled - try { - const UserPreferences = require("../models/userpreferences.model").default; - const preferences = await UserPreferences.findOne({ - userId: "default", - }); - - if ( - preferences?.autoMerge?.enabled && - preferences?.autoMerge?.onImport - ) { - this.logger.info( - `Auto-resolving metadata for newly imported comic ${comicId}` - ); - await this.broker.call("graphql.query", { - query: ` - mutation ResolveMetadata($comicId: ID!) { - resolveMetadata(comicId: $comicId) { - id - } - } - `, - variables: { comicId }, - }); - } - } catch (error) { - this.logger.error("Error in auto-resolution on import:", error); - } - }, - }, + // Call the graphql action + await this.broker.call("graphql.graphql", { + query: ` + mutation ResolveMetadata($comicId: ID!) { + resolveMetadata(comicId: $comicId) { + id + } + } + `, + variables: { comicId }, + }); + } + } catch (error) { + this.logger.error("Error in auto-resolution:", error); + } }, + }, - started: async function (this: any) { - await this.initApolloServer(); - }, + /** + * Trigger metadata resolution when comic is imported + */ + "comic.imported": { + async handler(ctx: any) { + const { comicId } = ctx.params; + this.logger.info(`Comic imported: ${comicId}`); - stopped: async function (this: any) { - await this.stopApolloServer(); + // Optionally trigger auto-resolution if enabled + try { + const UserPreferences = require("../models/userpreferences.model").default; + const preferences = await UserPreferences.findOne({ + userId: "default", + }); + + if ( + preferences?.autoMerge?.enabled && + preferences?.autoMerge?.onImport + ) { + this.logger.info( + `Auto-resolving metadata for newly imported comic ${comicId}` + ); + // Call the graphql action + await this.broker.call("graphql.graphql", { + query: ` + mutation ResolveMetadata($comicId: ID!) { + resolveMetadata(comicId: $comicId) { + id + } + } + `, + variables: { comicId }, + }); + } + } catch (error) { + this.logger.error("Error in auto-resolution on import:", error); + } }, + }, + }, + + async started() { + this.logger.info("GraphQL service starting..."); + + // Create local schema + const localSchema = makeExecutableSchema({ + typeDefs, + resolvers, }); - } -} + + // Try to stitch remote schema if available + try { + this.logger.info(`Attempting to introspect remote schema at ${this.settings.metadataGraphqlUrl}`); + + // Fetch and build the remote schema + const remoteSchema = await fetchRemoteSchema(this.settings.metadataGraphqlUrl); + + this.logger.info("Successfully introspected remote metadata schema"); + + // Create executor for remote schema + const remoteExecutor = createRemoteExecutor(this.settings.metadataGraphqlUrl); + + // Wrap the remote schema with executor + const wrappedRemoteSchema = wrapSchema({ + schema: remoteSchema, + executor: remoteExecutor, + }); + + // Stitch schemas together + this.schema = stitchSchemas({ + subschemas: [ + { + schema: localSchema, + }, + { + schema: wrappedRemoteSchema, + }, + ], + }); + + this.logger.info("Successfully stitched local and remote schemas"); + } catch (remoteError: any) { + this.logger.warn( + `Could not connect to remote metadata GraphQL at ${this.settings.metadataGraphqlUrl}: ${remoteError.message}` + ); + this.logger.warn("Continuing with local schema only"); + + // Use local schema only + this.schema = localSchema; + } + + this.logger.info("GraphQL service started successfully"); + }, + + stopped() { + this.logger.info("GraphQL service stopped"); + }, +}; diff --git a/utils/file.utils.ts b/utils/file.utils.ts index a6561f7..02ff457 100644 --- a/utils/file.utils.ts +++ b/utils/file.utils.ts @@ -98,10 +98,17 @@ export const getSizeOfDirectory = async ( const files = await readdir(directoryPath); const stats = files.map((file) => stat(path.join(directoryPath, file))); - return (await Promise.all(stats)).reduce( + const totalSizeInBytes = (await Promise.all(stats)).reduce( (accumulator, { size }) => accumulator + size, 0 ); + + return { + totalSize: totalSizeInBytes, + totalSizeInMB: totalSizeInBytes / (1024 * 1024), + totalSizeInGB: totalSizeInBytes / (1024 * 1024 * 1024), + fileCount: files.length, + }; }; export const isValidImageFileExtension = (fileName: string): boolean => {