From a9bfa479c404830c7492d5cbbfa9e268d6d50a9c Mon Sep 17 00:00:00 2001 From: Rishi Ghan Date: Tue, 23 Sep 2025 18:14:35 -0400 Subject: [PATCH] =?UTF-8?q?=F0=9F=94=A7=20Added=20graphQL=20bits?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- graphql-server.ts | 47 +++++ models/graphql/typedef.ts | 71 +++++-- package-lock.json | 197 ++++++++++++++++++- package.json | 10 +- services/api.service.ts | 365 +++++++++++++++++++----------------- services/graphql.service.ts | 116 ++++++++++++ 6 files changed, 607 insertions(+), 199 deletions(-) create mode 100644 graphql-server.ts create mode 100644 services/graphql.service.ts diff --git a/graphql-server.ts b/graphql-server.ts new file mode 100644 index 0000000..ca771c6 --- /dev/null +++ b/graphql-server.ts @@ -0,0 +1,47 @@ +import express from "express"; +import { ApolloServer } from "@apollo/server"; +import { expressMiddleware } from "@as-integrations/express4"; +import { typeDefs } from "./models/graphql/typedef"; +import { resolvers } from "./models/graphql/resolvers"; +import { ServiceBroker } from "moleculer"; +import cors from "cors"; + +// Boot Moleculer broker in parallel +const broker = new ServiceBroker({ transporter: null }); // or your actual transporter config + +async function startGraphQLServer() { + const app = express(); + const apollo = new ApolloServer({ + typeDefs, + resolvers, + }); + + await apollo.start(); + + app.use( + "/graphql", + cors(), + express.json(), + expressMiddleware(apollo, { + context: async ({ req }) => ({ + authToken: req.headers.authorization || null, + broker, + }), + }) + ); + + const PORT = 4000; + app.listen(PORT, () => + console.log(`🚀 GraphQL server running at http://localhost:${PORT}/graphql`) + ); +} + +async function bootstrap() { + await broker.start(); // make sure Moleculer is up + await startGraphQLServer(); +} + +bootstrap().catch((err) => { + console.error("❌ Failed to start GraphQL server:", err); + process.exit(1); +}); diff --git a/models/graphql/typedef.ts b/models/graphql/typedef.ts index da0ba7d..797b152 100644 --- a/models/graphql/typedef.ts +++ b/models/graphql/typedef.ts @@ -1,24 +1,59 @@ import { gql } from "graphql-tag"; export const typeDefs = gql` - type Query { - comic(id: ID!): Comic - comics(limit: Int = 10): [Comic] - } + type Query { + comic(id: ID!): Comic + comics(limit: Int = 10): [Comic] + wantedComics(limit: Int = 25, offset: Int = 0): ComicPage! + } - type Comic { - id: ID! - title: String - volume: Int - issueNumber: String - publicationDate: String - coverUrl: String - creators: [Creator] - source: String - } + type Comic { + id: ID! + title: String! + volume: Int + issueNumber: String! + publicationDate: String + variant: String + format: String + creators: [Creator!]! + arcs: [String!] + coverUrl: String + filePath: String + pageCount: Int + tags: [String!] + source: String - type Creator { - name: String - role: String - } + confidence: ConfidenceMap + provenance: ProvenanceMap + } + + type Creator { + name: String! + role: String! + } + + type ConfidenceMap { + title: Float + volume: Float + issueNumber: Float + publicationDate: Float + creators: Float + variant: Float + format: Float + } + + type ProvenanceMap { + title: String + volume: String + issueNumber: String + publicationDate: String + creators: String + variant: String + format: String + } + + type ComicPage { + total: Int! + results: [Comic!]! + } `; diff --git a/package-lock.json b/package-lock.json index 17bbe86..e919954 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,15 +9,17 @@ "version": "0.0.1", "dependencies": { "@apollo/server": "^4.12.2", + "@as-integrations/express4": "^1.1.1", "@bluelovers/fast-glob": "https://github.com/rishighan/fast-glob-v2-api.git", "@elastic/elasticsearch": "^8.13.1", "@jorgeferrero/stream-to-buffer": "^2.0.6", + "@ltv/moleculer-apollo-server-mixin": "^0.1.30", "@npcz/magic": "^1.3.14", "@root/walk": "^1.1.0", "@socket.io/redis-adapter": "^8.1.0", "@types/jest": "^27.4.1", "@types/mkdirp": "^1.0.0", - "@types/node": "^13.9.8", + "@types/node": "^24.0.13", "@types/string-similarity": "^4.0.0", "airdcpp-apisocket": "^3.0.0-beta.8", "axios": "^1.6.8", @@ -25,6 +27,7 @@ "bree": "^7.1.5", "calibre-opds": "^1.0.7", "chokidar": "^4.0.3", + "cors": "^2.8.5", "delay": "^5.0.0", "dotenv": "^10.0.0", "filename-parser": "^1.0.4", @@ -43,7 +46,7 @@ "moleculer-db": "^0.8.23", "moleculer-db-adapter-mongoose": "^0.9.2", "moleculer-io": "^2.2.0", - "moleculer-web": "^0.10.5", + "moleculer-web": "^0.10.8", "mongoosastic-ts": "^6.0.3", "mongoose": "^6.10.4", "mongoose-paginate-v2": "^1.3.18", @@ -62,6 +65,7 @@ "@types/lodash": "^4.14.168", "@typescript-eslint/eslint-plugin": "^5.56.0", "@typescript-eslint/parser": "^5.56.0", + "concurrently": "^9.2.0", "eslint": "^8.36.0", "eslint-plugin-import": "^2.20.2", "eslint-plugin-prefer-arrow": "^1.2.2", @@ -77,7 +81,7 @@ "uuid": "^9.0.0" }, "engines": { - "node": ">= 18.x.x" + "node": ">= 22.x.x" } }, "node_modules/@aashutoshrathi/word-wrap": { @@ -110,6 +114,24 @@ "graphql": "14.x || 15.x || 16.x" } }, + "node_modules/@apollo/federation-internals": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@apollo/federation-internals/-/federation-internals-2.11.2.tgz", + "integrity": "sha512-GSFGL2fLox3EBszWKJvRkVLFA0hkJF9PHGMQH+WdB/12KVB3QHKwDyW1T9VZtxe2SJhNU3puleSxCsO16Bf3iA==", + "license": "Elastic-2.0", + "dependencies": { + "@types/uuid": "^9.0.0", + "chalk": "^4.1.0", + "js-levenshtein": "^1.1.6", + "uuid": "^9.0.0" + }, + "engines": { + "node": ">=14.15.0" + }, + "peerDependencies": { + "graphql": "^16.5.0" + } + }, "node_modules/@apollo/protobufjs": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/@apollo/protobufjs/-/protobufjs-1.2.7.tgz", @@ -197,6 +219,22 @@ "node": ">=12" } }, + "node_modules/@apollo/subgraph": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@apollo/subgraph/-/subgraph-2.11.2.tgz", + "integrity": "sha512-S14osF5Zc8pd6lzeNtX1QHboMcQK5PXcN9EumZyRYBF0TRbnEFLF8Me9zMcfR3QP7GCiggjd6PA2IAaPC9uCSQ==", + "license": "MIT", + "dependencies": { + "@apollo/cache-control-types": "^1.0.2", + "@apollo/federation-internals": "2.11.2" + }, + "engines": { + "node": ">=14.15.0" + }, + "peerDependencies": { + "graphql": "^16.5.0" + } + }, "node_modules/@apollo/usage-reporting-protobuf": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/@apollo/usage-reporting-protobuf/-/usage-reporting-protobuf-4.1.1.tgz", @@ -360,6 +398,19 @@ "node": ">=14" } }, + "node_modules/@as-integrations/express4": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@as-integrations/express4/-/express4-1.1.1.tgz", + "integrity": "sha512-a2pur5nko91UaqWYwNRmcMEtmxgZH9eQzpner2ht/2CNSDuC+PHU3K+/uiISVgLC+2b+1TPzvutPejkN/+bsTw==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@apollo/server": "^4.0.0 || 5.0.0-rc.0", + "express": "^4.0.0" + } + }, "node_modules/@aws-crypto/sha256-browser": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz", @@ -2413,6 +2464,19 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@ltv/moleculer-apollo-server-mixin": { + "version": "0.1.30", + "resolved": "https://registry.npmjs.org/@ltv/moleculer-apollo-server-mixin/-/moleculer-apollo-server-mixin-0.1.30.tgz", + "integrity": "sha512-/t1/aGwGIgwpwL2IMJl7WF/NtOKbJcpIObc31ur2ZaAYSjV+3anhTZgb9R2ePwdjdkZq5882vqCDJ8gnrrbiDg==", + "license": "MIT", + "dependencies": { + "@apollo/server": "^4.3.2", + "@apollo/subgraph": "^2.3.0", + "graphql": "^16.6.0", + "lodash.defaultsdeep": "^4.6.1", + "lodash.omit": "^4.5.0" + } + }, "node_modules/@mongodb-js/saslprep": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.2.2.tgz", @@ -3493,9 +3557,13 @@ } }, "node_modules/@types/node": { - "version": "13.13.52", - "resolved": "https://registry.npmjs.org/@types/node/-/node-13.13.52.tgz", - "integrity": "sha512-s3nugnZumCC//n4moGGe6tkNMyYEdaDBitVjwPxXmR5lnMG5dHePinH2EdxkG3Rh1ghFHHixAG4NJhpJW1rthQ==" + "version": "24.0.13", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.0.13.tgz", + "integrity": "sha512-Qm9OYVOFHFYg3wJoTSrz80hoec5Lia/dPp84do3X7dZvLikQvM1YpmvTBEdIr/e+U8HTkFjLHLnl78K/qjf+jQ==", + "license": "MIT", + "dependencies": { + "undici-types": "~7.8.0" + } }, "node_modules/@types/node-fetch": { "version": "2.6.12", @@ -3568,6 +3636,12 @@ "resolved": "https://registry.npmjs.org/@types/string-similarity/-/string-similarity-4.0.0.tgz", "integrity": "sha512-dMS4S07fbtY1AILG/RhuwmptmzK1Ql8scmAebOTJ/8iBtK/KI17NwGwKzu1uipjj8Kk+3mfPxum56kKZE93mzQ==" }, + "node_modules/@types/uuid": { + "version": "9.0.8", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-9.0.8.tgz", + "integrity": "sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA==", + "license": "MIT" + }, "node_modules/@types/webidl-conversions": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz", @@ -5148,6 +5222,48 @@ "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==" }, + "node_modules/concurrently": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.0.tgz", + "integrity": "sha512-IsB/fiXTupmagMW4MNp2lx2cdSN2FfZq78vF90LBB+zZHArbIQZjQtzXCiXnvTxCZSvXanTqFLWBjw2UkLx1SQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.2", + "lodash": "^4.17.21", + "rxjs": "^7.8.1", + "shell-quote": "^1.8.1", + "supports-color": "^8.1.1", + "tree-kill": "^1.2.2", + "yargs": "^17.7.2" + }, + "bin": { + "conc": "dist/bin/concurrently.js", + "concurrently": "dist/bin/concurrently.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" + } + }, + "node_modules/concurrently/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, "node_modules/content-disposition": { "version": "0.5.4", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", @@ -5207,6 +5323,7 @@ "version": "2.8.5", "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "license": "MIT", "dependencies": { "object-assign": "^4", "vary": "^1" @@ -9503,6 +9620,15 @@ "resolved": "https://registry.npmjs.org/jpeg-js/-/jpeg-js-0.4.4.tgz", "integrity": "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg==" }, + "node_modules/js-levenshtein": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/js-levenshtein/-/js-levenshtein-1.1.6.tgz", + "integrity": "sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/js-priority-queue": { "version": "0.1.5", "resolved": "https://registry.npmjs.org/js-priority-queue/-/js-priority-queue-0.1.5.tgz", @@ -9812,6 +9938,12 @@ "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==" }, + "node_modules/lodash.defaultsdeep": { + "version": "4.6.1", + "resolved": "https://registry.npmjs.org/lodash.defaultsdeep/-/lodash.defaultsdeep-4.6.1.tgz", + "integrity": "sha512-3j8wdDzYuWO3lM3Reg03MuQR957t287Rpcxp1njpEa8oDrikb+FwGdW3n+FELh/A6qib6yPit0j/pv9G/yeAqA==", + "license": "MIT" + }, "node_modules/lodash.isarguments": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz", @@ -9828,6 +9960,13 @@ "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==" }, + "node_modules/lodash.omit": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.omit/-/lodash.omit-4.5.0.tgz", + "integrity": "sha512-XeqSp49hNGmlkj2EJlfrQFIzQ6lXdNro9sddtQzcJY8QaoC2GO0DT7xaIokHeyM+mIT0mPMlPvkYzg2xCuHdZg==", + "deprecated": "This package is deprecated. Use destructuring assignment syntax instead.", + "license": "MIT" + }, "node_modules/lodash.sortby": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz", @@ -10598,9 +10737,10 @@ } }, "node_modules/moleculer-web": { - "version": "0.10.7", - "resolved": "https://registry.npmjs.org/moleculer-web/-/moleculer-web-0.10.7.tgz", - "integrity": "sha512-/UJtV+O7iQ3aSg/xi/sw3ZswhvzkigzGPjKOR5R97sm2FSihKuLTftUpXlk4dYls7/8c8WSz6H/M/40BenEx9Q==", + "version": "0.10.8", + "resolved": "https://registry.npmjs.org/moleculer-web/-/moleculer-web-0.10.8.tgz", + "integrity": "sha512-kQtyN8AccdBqSZUh+PRLYmLPy7RBd48j/raA5682wNDo1fPEKdCHa8d7tUjtmI53gELkQZKCv3GckyMqdxZYXQ==", + "license": "MIT", "dependencies": { "@fastify/busboy": "^1.0.0", "body-parser": "^1.19.0", @@ -15001,6 +15141,16 @@ "queue-microtask": "^1.2.2" } }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, "node_modules/safe-array-concat": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.0.0.tgz", @@ -15302,6 +15452,19 @@ "node": ">=8" } }, + "node_modules/shell-quote": { + "version": "1.8.3", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz", + "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/side-channel": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.6.tgz", @@ -16090,6 +16253,16 @@ "node": ">=14" } }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true, + "license": "MIT", + "bin": { + "tree-kill": "cli.js" + } + }, "node_modules/truncate-utf8-bytes": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/truncate-utf8-bytes/-/truncate-utf8-bytes-1.0.2.tgz", @@ -16479,6 +16652,12 @@ "node": ">=14.0" } }, + "node_modules/undici-types": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.8.0.tgz", + "integrity": "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw==", + "license": "MIT" + }, "node_modules/undici/node_modules/@fastify/busboy": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.1.1.tgz", diff --git a/package.json b/package.json index 1f9feee..6471840 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "@types/lodash": "^4.14.168", "@typescript-eslint/eslint-plugin": "^5.56.0", "@typescript-eslint/parser": "^5.56.0", + "concurrently": "^9.2.0", "eslint": "^8.36.0", "eslint-plugin-import": "^2.20.2", "eslint-plugin-prefer-arrow": "^1.2.2", @@ -39,15 +40,17 @@ }, "dependencies": { "@apollo/server": "^4.12.2", + "@as-integrations/express4": "^1.1.1", "@bluelovers/fast-glob": "https://github.com/rishighan/fast-glob-v2-api.git", "@elastic/elasticsearch": "^8.13.1", "@jorgeferrero/stream-to-buffer": "^2.0.6", + "@ltv/moleculer-apollo-server-mixin": "^0.1.30", "@npcz/magic": "^1.3.14", "@root/walk": "^1.1.0", "@socket.io/redis-adapter": "^8.1.0", "@types/jest": "^27.4.1", "@types/mkdirp": "^1.0.0", - "@types/node": "^13.9.8", + "@types/node": "^24.0.13", "@types/string-similarity": "^4.0.0", "airdcpp-apisocket": "^3.0.0-beta.8", "axios": "^1.6.8", @@ -55,6 +58,7 @@ "bree": "^7.1.5", "calibre-opds": "^1.0.7", "chokidar": "^4.0.3", + "cors": "^2.8.5", "delay": "^5.0.0", "dotenv": "^10.0.0", "filename-parser": "^1.0.4", @@ -73,7 +77,7 @@ "moleculer-db": "^0.8.23", "moleculer-db-adapter-mongoose": "^0.9.2", "moleculer-io": "^2.2.0", - "moleculer-web": "^0.10.5", + "moleculer-web": "^0.10.8", "mongoosastic-ts": "^6.0.3", "mongoose": "^6.10.4", "mongoose-paginate-v2": "^1.3.18", @@ -89,7 +93,7 @@ "xml2js": "^0.6.2" }, "engines": { - "node": ">= 18.x.x" + "node": ">= 22.x.x" }, "jest": { "coverageDirectory": "/coverage", diff --git a/services/api.service.ts b/services/api.service.ts index 698d1d5..35439dc 100644 --- a/services/api.service.ts +++ b/services/api.service.ts @@ -12,180 +12,207 @@ import { IFolderData } from "threetwo-ui-typings"; * @extends Service */ export default class ApiService extends Service { - /** - * The chokidar file system watcher instance. - * @private - */ - private fileWatcher?: any; + /** + * The chokidar file system watcher instance. + * @private + */ + private fileWatcher?: any; - /** - * Creates an instance of ApiService. - * @param {ServiceBroker} broker - The Moleculer service broker instance. - */ - public constructor(broker: ServiceBroker) { - super(broker); - this.parseServiceSchema({ - name: "api", - mixins: [ApiGateway], - settings: { - port: process.env.PORT || 3000, - routes: [ - { - path: "/api", - whitelist: ["**"], - cors: { - origin: "*", - methods: ["GET", "OPTIONS", "POST", "PUT", "DELETE"], - allowedHeaders: ["*"], - exposedHeaders: [], - credentials: false, - maxAge: 3600, - }, - use: [], - mergeParams: true, - authentication: false, - authorization: false, - autoAliases: true, - aliases: {}, - callingOptions: {}, - bodyParsers: { - json: { strict: false, limit: "1MB" }, - urlencoded: { extended: true, limit: "1MB" }, - }, - mappingPolicy: "all", - logging: true, - }, - { - path: "/userdata", - use: [ApiGateway.serveStatic(path.resolve("./userdata"))], - }, - { - path: "/comics", - use: [ApiGateway.serveStatic(path.resolve("./comics"))], - }, - { - path: "/logs", - use: [ApiGateway.serveStatic("logs")], - }, - ], - log4XXResponses: false, - logRequestParams: true, - logResponseData: true, - assets: { folder: "public", options: {} }, - }, - events: {}, - methods: {}, - started: this.startWatcher, - stopped: this.stopWatcher, - }); - } + /** + * Creates an instance of ApiService. + * @param {ServiceBroker} broker - The Moleculer service broker instance. + */ + public constructor(broker: ServiceBroker) { + super(broker); + this.parseServiceSchema({ + name: "api", + mixins: [ApiGateway], + settings: { + port: process.env.PORT || 3000, + routes: [ + { + path: "/graphql", + whitelist: ["graphql.*"], + bodyParsers: { + json: true, + urlencoded: { extended: true }, + }, + aliases: { + "POST /": "graphql.wantedComics", + }, + cors: { + origin: "*", + methods: ["GET", "OPTIONS", "POST"], + allowedHeaders: ["*"], + credentials: false, + }, + }, + { + path: "/api", + whitelist: ["**"], + cors: { + origin: "*", + methods: [ + "GET", + "OPTIONS", + "POST", + "PUT", + "DELETE", + ], + allowedHeaders: ["*"], + exposedHeaders: [], + credentials: false, + maxAge: 3600, + }, + use: [], + mergeParams: true, + authentication: false, + authorization: false, + autoAliases: true, + aliases: {}, + callingOptions: {}, + bodyParsers: { + json: { strict: false, limit: "1MB" }, + urlencoded: { extended: true, limit: "1MB" }, + }, + mappingPolicy: "all", + logging: true, + }, + { + path: "/userdata", + use: [ + ApiGateway.serveStatic(path.resolve("./userdata")), + ], + }, + { + path: "/comics", + use: [ApiGateway.serveStatic(path.resolve("./comics"))], + }, + { + path: "/logs", + use: [ApiGateway.serveStatic("logs")], + }, + ], + log4XXResponses: false, + logRequestParams: true, + logResponseData: true, + assets: { folder: "public", options: {} }, + }, + events: {}, + methods: {}, + started: this.startWatcher, + stopped: this.stopWatcher, + }); + } - /** - * Initializes and starts the chokidar watcher on the COMICS_DIRECTORY. - * Debounces rapid events and logs initial scan completion. - * @private - */ - private startWatcher(): void { - const rawDir = process.env.COMICS_DIRECTORY; - if (!rawDir) { - this.logger.error("COMICS_DIRECTORY not set; cannot start watcher"); - return; - } - const watchDir = path.resolve(rawDir); - this.logger.info(`Watching comics folder at: ${watchDir}`); - if (!fs.existsSync(watchDir)) { - this.logger.error(`✖ Comics folder does not exist: ${watchDir}`); - return; - } + /** + * Initializes and starts the chokidar watcher on the COMICS_DIRECTORY. + * Debounces rapid events and logs initial scan completion. + * @private + */ + private startWatcher(): void { + const rawDir = process.env.COMICS_DIRECTORY; + if (!rawDir) { + this.logger.error("COMICS_DIRECTORY not set; cannot start watcher"); + return; + } + const watchDir = path.resolve(rawDir); + this.logger.info(`Watching comics folder at: ${watchDir}`); + if (!fs.existsSync(watchDir)) { + this.logger.error(`✖ Comics folder does not exist: ${watchDir}`); + return; + } - this.fileWatcher = chokidar.watch(watchDir, { - persistent: true, - ignoreInitial: true, - followSymlinks: true, - depth: 10, - usePolling: true, - interval: 5000, - atomic: true, - awaitWriteFinish: { stabilityThreshold: 2000, pollInterval: 100 }, - ignored: (p) => p.endsWith(".dctmp") || p.includes("/.git/"), - }); + this.fileWatcher = chokidar.watch(watchDir, { + persistent: true, + ignoreInitial: true, + followSymlinks: true, + depth: 10, + usePolling: true, + interval: 5000, + atomic: true, + awaitWriteFinish: { stabilityThreshold: 2000, pollInterval: 100 }, + ignored: (p) => p.endsWith(".dctmp") || p.includes("/.git/"), + }); - /** - * Debounced handler for file system events, batching rapid triggers - * into a 200ms window. Leading and trailing calls invoked. - * @param {string} event - Type of file event (add, change, etc.). - * @param {string} p - Path of the file or directory. - * @param {fs.Stats} [stats] - Optional file stats for add/change events. - */ - const debouncedEvent = debounce( - (event: string, p: string, stats?: fs.Stats) => { - try { - this.handleFileEvent(event, p, stats); - } catch (err) { - this.logger.error( - `Error handling file event [${event}] for ${p}:`, - err - ); - } - }, - 200, - { leading: true, trailing: true } - ); + /** + * Debounced handler for file system events, batching rapid triggers + * into a 200ms window. Leading and trailing calls invoked. + * @param {string} event - Type of file event (add, change, etc.). + * @param {string} p - Path of the file or directory. + * @param {fs.Stats} [stats] - Optional file stats for add/change events. + */ + const debouncedEvent = debounce( + (event: string, p: string, stats?: fs.Stats) => { + try { + this.handleFileEvent(event, p, stats); + } catch (err) { + this.logger.error( + `Error handling file event [${event}] for ${p}:`, + err + ); + } + }, + 200, + { leading: true, trailing: true } + ); - this.fileWatcher - .on("ready", () => this.logger.info("Initial scan complete.")) - .on("error", (err) => this.logger.error("Watcher error:", err)) - .on("add", (p, stats) => debouncedEvent("add", p, stats)) - .on("change", (p, stats) => debouncedEvent("change", p, stats)) - .on("unlink", (p) => debouncedEvent("unlink", p)) - .on("addDir", (p) => debouncedEvent("addDir", p)) - .on("unlinkDir", (p) => debouncedEvent("unlinkDir", p)); - } + this.fileWatcher + .on("ready", () => this.logger.info("Initial scan complete.")) + .on("error", (err) => this.logger.error("Watcher error:", err)) + .on("add", (p, stats) => debouncedEvent("add", p, stats)) + .on("change", (p, stats) => debouncedEvent("change", p, stats)) + .on("unlink", (p) => debouncedEvent("unlink", p)) + .on("addDir", (p) => debouncedEvent("addDir", p)) + .on("unlinkDir", (p) => debouncedEvent("unlinkDir", p)); + } - /** - * Stops and closes the chokidar watcher, freeing resources. - * @private - */ - private async stopWatcher(): Promise { - if (this.fileWatcher) { - this.logger.info("Stopping file watcher..."); - await this.fileWatcher.close(); - this.fileWatcher = undefined; - } - } + /** + * Stops and closes the chokidar watcher, freeing resources. + * @private + */ + private async stopWatcher(): Promise { + if (this.fileWatcher) { + this.logger.info("Stopping file watcher..."); + await this.fileWatcher.close(); + this.fileWatcher = undefined; + } + } - /** - * Handles a filesystem event by logging and optionally importing new files. - * @param event - The type of chokidar event ('add', 'change', 'unlink', etc.). - * @param filePath - The full path of the file or directory that triggered the event. - * @param stats - Optional fs.Stats data for 'add' or 'change' events. - * @private - */ - private async handleFileEvent( - event: string, - filePath: string, - stats?: fs.Stats - ): Promise { - this.logger.info(`File event [${event}]: ${filePath}`); - if (event === "add" && stats) { - setTimeout(async () => { - const newStats = await fs.promises.stat(filePath); - if (newStats.mtime.getTime() === stats.mtime.getTime()) { - this.logger.info(`Stable file detected: ${filePath}, importing.`); - const folderData: IFolderData = await this.broker.call( - "library.walkFolders", - { basePathToWalk: filePath } - ); - // this would have to be a call to importDownloadedComic - await this.broker.call("importqueue.processImport", { - fileObject: { - filePath, - fileSize: folderData[0].fileSize, - }, - }); - } - }, 3000); - } - this.broker.broadcast(event, { path: filePath }); - } + /** + * Handles a filesystem event by logging and optionally importing new files. + * @param event - The type of chokidar event ('add', 'change', 'unlink', etc.). + * @param filePath - The full path of the file or directory that triggered the event. + * @param stats - Optional fs.Stats data for 'add' or 'change' events. + * @private + */ + private async handleFileEvent( + event: string, + filePath: string, + stats?: fs.Stats + ): Promise { + this.logger.info(`File event [${event}]: ${filePath}`); + if (event === "add" && stats) { + setTimeout(async () => { + const newStats = await fs.promises.stat(filePath); + if (newStats.mtime.getTime() === stats.mtime.getTime()) { + this.logger.info( + `Stable file detected: ${filePath}, importing.` + ); + const folderData: IFolderData = await this.broker.call( + "library.walkFolders", + { basePathToWalk: filePath } + ); + // this would have to be a call to importDownloadedComic + await this.broker.call("importqueue.processImport", { + fileObject: { + filePath, + fileSize: folderData[0].fileSize, + }, + }); + } + }, 3000); + } + this.broker.broadcast(event, { path: filePath }); + } } diff --git a/services/graphql.service.ts b/services/graphql.service.ts new file mode 100644 index 0000000..926355a --- /dev/null +++ b/services/graphql.service.ts @@ -0,0 +1,116 @@ +// services/graphql.service.ts +import { gql as ApolloMixin } from "@ltv/moleculer-apollo-server-mixin"; +import { print } from "graphql"; +import { typeDefs } from "../models/graphql/typedef"; +import { ServiceSchema } from "moleculer"; + +/** + * Interface representing the structure of an ElasticSearch result. + */ +interface SearchResult { + hits: { + total: { value: number }; + hits: any[]; + }; +} + +/** + * GraphQL Moleculer Service exposing typed resolvers via @ltv/moleculer-apollo-server-mixin. + * Includes resolver for fetching comics marked as "wanted". + */ +const GraphQLService: ServiceSchema = { + name: "graphql", + mixins: [ApolloMixin], + + actions: { + /** + * Resolver for fetching comics marked as "wanted" in ElasticSearch. + * + * Queries the `search.issue` Moleculer action using a filtered ES query + * that matches issues or volumes with a `wanted` flag. + * + * @param {number} [limit=25] - Maximum number of results to return. + * @param {number} [offset=0] - Starting index for paginated results. + * @returns {Promise<{ total: number, comics: any[] }>} - Total number of matches and result set. + * + * @example + * query { + * wantedComics(limit: 10, offset: 0) { + * total + * comics { + * _id + * _source { + * title + * } + * } + * } + * } + */ + wantedComics: { + params: { + limit: { + type: "number", + integer: true, + min: 1, + optional: true, + }, + offset: { + type: "number", + integer: true, + min: 0, + optional: true, + }, + }, + async handler(ctx) { + const { limit = 25, offset = 0 } = ctx.params; + + const eSQuery = { + bool: { + should: [ + { exists: { field: "wanted.issues" } }, + { exists: { field: "wanted.volume" } }, + ], + minimum_should_match: 1, + }, + }; + + const result = (await ctx.broker.call("search.issue", { + query: eSQuery, + pagination: { size: limit, from: offset }, + type: "wanted", + trigger: "wantedComicsGraphQL", + })) as SearchResult; + + return { + data: { + wantedComics: { + total: result?.hits?.total?.value || 0, + comics: + result?.hits?.hits.map((hit) => hit._source) || + [], + }, + }, + }; + }, + }, + }, + + settings: { + apolloServer: { + typeDefs: print(typeDefs), // If typeDefs is AST; remove print if it's raw SDL string + resolvers: { + Query: { + wantedComics: "graphql.wantedComics", + }, + }, + path: "/graphql", + playground: true, + introspection: true, + context: ({ ctx }: any) => ({ + broker: ctx.broker, + }), + }, + }, +}; + +export default GraphQLService;