diff --git a/api/mods/index.js b/api/mods/index.js index 7911774..3ab19b6 100644 --- a/api/mods/index.js +++ b/api/mods/index.js @@ -1,37 +1,120 @@ -module.exports = function (context, req) { - var https = require("https"); - - var domainName = context.bindingData.domainName; - var modId = context.bindingData.id; - var options = { - host: "api.nexusmods.com", - path: `/v1/games/${domainName}/mods/${modId}`, - headers: { apiKey: process.env.NEXUS_API_KEY }, - method: "GET", +const https = require("https"); +const { NodeCache } = require("@cacheable/node-cache"); + +const CACHE_TTL_SECONDS = 60 * 60; +const modCache = new NodeCache({ + stdTTL: CACHE_TTL_SECONDS, + checkperiod: 120, + maxKeys: 256, + useClones: true, +}); + +function createResponse(status, body, cacheStatus) { + return { + status, + body, + headers: { + "Content-Type": "application/json", + "X-Cache": cacheStatus, + }, }; +} - var response = ""; - const request = https.request(options, (res) => { - context.log(`statusCode: ${res.statusCode}`); +function createHandler({ cache = modCache, request = https.request } = {}) { + return function handler(context, req) { + const domainName = context.bindingData?.domainName + ?.toString() + .trim() + .toLowerCase(); + const modId = Number(context.bindingData?.id); + let completed = false; - res.on("data", (d) => { - response += d; - }); + const complete = (status, body, cacheStatus = "MISS") => { + if (completed) { + return; + } - res.on("end", (d) => { - context.res = { - status: res.statusCode, - body: JSON.parse(response), - headers: { "Content-Type": "application/json" }, - }; + completed = true; + context.res = createResponse(status, body, cacheStatus); context.done(); - }); - }); + }; + + if (!domainName || !Number.isInteger(modId) || modId <= 0) { + complete(400, { + error: "domainName and a positive integer id are required", + }); + return; + } + + const cacheKey = `${domainName}:${modId}`; + const cachedResponse = cache.get(cacheKey); + + if (cachedResponse !== undefined) { + context.log(`Cache hit: ${cacheKey}`); + complete(cachedResponse.status, cachedResponse.body, "HIT"); + return; + } + + context.log(`Cache miss: ${cacheKey}`); + + const options = { + host: "api.nexusmods.com", + path: `/v1/games/${encodeURIComponent(domainName)}/mods/${modId}`, + headers: { apiKey: process.env.NEXUS_API_KEY }, + method: "GET", + }; + + try { + const upstreamRequest = request(options, (upstreamResponse) => { + let responseBody = ""; + + context.log(`statusCode: ${upstreamResponse.statusCode}`); + + upstreamResponse.on("data", (chunk) => { + responseBody += chunk; + }); + + upstreamResponse.on("end", () => { + let parsedBody; + + try { + parsedBody = JSON.parse(responseBody); + } catch (error) { + context.log.error(error); + complete(502, { + error: "Nexus Mods API returned invalid JSON", + }); + return; + } + + const status = upstreamResponse.statusCode ?? 502; + + if (status === 200) { + try { + cache.set(cacheKey, { status, body: parsedBody }); + } catch (error) { + context.log.warn(`Unable to cache ${cacheKey}: ${error.message}`); + } + } + + complete(status, parsedBody); + }); + }); + + upstreamRequest.on("error", (error) => { + context.log.error(error); + complete(502, { error: "Unable to reach Nexus Mods API" }); + }); + + upstreamRequest.end(); + } catch (error) { + context.log.error(error); + complete(502, { error: "Unable to reach Nexus Mods API" }); + } + }; +} - request.on("error", (error) => { - context.log.error(error); - context.done(); - }); +const handler = createHandler(); +handler.createHandler = createHandler; - request.end(); -}; +module.exports = handler; diff --git a/api/mods/index.test.js b/api/mods/index.test.js new file mode 100644 index 0000000..5454928 --- /dev/null +++ b/api/mods/index.test.js @@ -0,0 +1,182 @@ +const assert = require("node:assert/strict"); +const { EventEmitter } = require("node:events"); +const { test } = require("node:test"); +const { NodeCache } = require("@cacheable/node-cache"); +const modsHandler = require("./index"); + +function createCache() { + return new NodeCache({ + stdTTL: 3600, + checkperiod: 0, + maxKeys: 256, + useClones: true, + }); +} + +function createRequestStub(responder) { + let callCount = 0; + + const request = (options, callback) => { + callCount += 1; + const outgoingRequest = new EventEmitter(); + + outgoingRequest.end = () => { + const response = + typeof responder === "function" + ? responder(options, callCount) + : responder; + + process.nextTick(() => { + if (response.error) { + outgoingRequest.emit("error", response.error); + return; + } + + const upstreamResponse = new EventEmitter(); + upstreamResponse.statusCode = response.status; + callback(upstreamResponse); + upstreamResponse.emit("data", Buffer.from(response.body)); + upstreamResponse.emit("end"); + }); + }; + + return outgoingRequest; + }; + + request.getCallCount = () => callCount; + return request; +} + +function invoke(handler, bindingData) { + return new Promise((resolve) => { + const logs = []; + const log = (...values) => logs.push(["info", ...values]); + log.error = (...values) => logs.push(["error", ...values]); + log.warn = (...values) => logs.push(["warn", ...values]); + + const context = { + bindingData, + log, + done: () => resolve({ response: context.res, logs }), + }; + + handler(context, {}); + }); +} + +test("caches a successful response for one hour", async () => { + const cache = createCache(); + const request = createRequestStub({ + status: 200, + body: JSON.stringify({ name: "A mod" }), + }); + const handler = modsHandler.createHandler({ cache, request }); + + const first = await invoke(handler, { domainName: "Witcher3", id: 1738 }); + const second = await invoke(handler, { domainName: "witcher3", id: 1738 }); + + assert.equal(request.getCallCount(), 1); + assert.equal(first.response.headers["X-Cache"], "MISS"); + assert.equal(second.response.headers["X-Cache"], "HIT"); + assert.deepEqual(second.response.body, { name: "A mod" }); + + const remainingTtl = cache.getTtl("witcher3:1738") - Date.now(); + assert.ok(remainingTtl > 3_595_000 && remainingTtl <= 3_600_000); +}); + +test("keeps cache entries isolated by domain and mod id", async () => { + const cache = createCache(); + const request = createRequestStub((options) => ({ + status: 200, + body: JSON.stringify({ path: options.path }), + })); + const handler = modsHandler.createHandler({ cache, request }); + + await invoke(handler, { domainName: "witcher3", id: 1738 }); + await invoke(handler, { domainName: "witcher3", id: 1643 }); + const cached = await invoke(handler, { domainName: "witcher3", id: 1738 }); + + assert.equal(request.getCallCount(), 2); + assert.equal(cached.response.headers["X-Cache"], "HIT"); + assert.equal(cached.response.body.path, "/v1/games/witcher3/mods/1738"); +}); + +test("does not cache non-200 responses", async () => { + const cache = createCache(); + const request = createRequestStub({ + status: 404, + body: JSON.stringify({ message: "Not found" }), + }); + const handler = modsHandler.createHandler({ cache, request }); + + const first = await invoke(handler, { domainName: "witcher3", id: 9999 }); + const second = await invoke(handler, { domainName: "witcher3", id: 9999 }); + + assert.equal(request.getCallCount(), 2); + assert.equal(first.response.status, 404); + assert.equal(second.response.headers["X-Cache"], "MISS"); + assert.equal(cache.get("witcher3:9999"), undefined); +}); + +test("does not cache malformed upstream responses", async () => { + const cache = createCache(); + const request = createRequestStub({ status: 200, body: "not json" }); + const handler = modsHandler.createHandler({ cache, request }); + + const first = await invoke(handler, { domainName: "witcher3", id: 1738 }); + const second = await invoke(handler, { domainName: "witcher3", id: 1738 }); + + assert.equal(request.getCallCount(), 2); + assert.equal(first.response.status, 502); + assert.equal(second.response.headers["X-Cache"], "MISS"); + assert.equal(cache.get("witcher3:1738"), undefined); +}); + +test("does not cache network errors", async () => { + const cache = createCache(); + const request = createRequestStub({ error: new Error("network failed") }); + const handler = modsHandler.createHandler({ cache, request }); + + const first = await invoke(handler, { domainName: "witcher3", id: 1738 }); + const second = await invoke(handler, { domainName: "witcher3", id: 1738 }); + + assert.equal(request.getCallCount(), 2); + assert.equal(first.response.status, 502); + assert.equal(second.response.headers["X-Cache"], "MISS"); + assert.equal(cache.get("witcher3:1738"), undefined); +}); + +test("rejects missing or invalid route values without calling Nexus", async () => { + const request = createRequestStub({ status: 200, body: "{}" }); + const handler = modsHandler.createHandler({ cache: createCache(), request }); + + const missingId = await invoke(handler, { domainName: "witcher3" }); + const invalidId = await invoke(handler, { domainName: "witcher3", id: 0 }); + const missingDomain = await invoke(handler, { id: 1738 }); + + assert.equal(request.getCallCount(), 0); + assert.equal(missingId.response.status, 400); + assert.equal(invalidId.response.status, 400); + assert.equal(missingDomain.response.status, 400); +}); + +test("returns successful responses when a bounded cache rejects a write", async () => { + const cache = { + get: () => undefined, + set: () => { + throw new Error("cache is full"); + }, + }; + const request = createRequestStub({ + status: 200, + body: JSON.stringify({ name: "A mod" }), + }); + const handler = modsHandler.createHandler({ cache, request }); + + const result = await invoke(handler, { domainName: "witcher3", id: 1738 }); + + assert.equal(result.response.status, 200); + assert.equal(result.response.headers["X-Cache"], "MISS"); + assert.deepEqual(result.response.body, { name: "A mod" }); + assert.ok(result.logs.some(([level]) => level === "warn")); +}); diff --git a/api/package-lock.json b/api/package-lock.json index 1a19e43..6fa3eb0 100644 --- a/api/package-lock.json +++ b/api/package-lock.json @@ -7,7 +7,73 @@ "": { "name": "api", "version": "1.0.0", + "dependencies": { + "@cacheable/node-cache": "3.1.1" + }, "devDependencies": {} + }, + "node_modules/@cacheable/node-cache": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@cacheable/node-cache/-/node-cache-3.1.1.tgz", + "integrity": "sha512-AFW2fw+Z0oupVmhQ0GKZc6v8UasaWrQ7ptOZv7e7avPCos62F2myeucdBbO8riBiCXx4TYfb+sv3hwXbaOwAqA==", + "license": "MIT", + "dependencies": { + "@cacheable/utils": "^2.5.0", + "hookified": "^2.1.0", + "keyv": "^5.6.0" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@cacheable/utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@cacheable/utils/-/utils-2.5.0.tgz", + "integrity": "sha512-buipgOVDkkPXNR5+xBpDw7Zk2n1EvU7qBJCNUcL7rhQ//kfpOXPAvQ511Os0vpLYJ1pZnvudNytkQt2hst3wqA==", + "license": "MIT", + "dependencies": { + "hashery": "^1.5.1", + "keyv": "^5.6.0" + } + }, + "node_modules/@keyv/serialize": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@keyv/serialize/-/serialize-1.1.1.tgz", + "integrity": "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==", + "license": "MIT" + }, + "node_modules/hashery": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/hashery/-/hashery-1.5.1.tgz", + "integrity": "sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ==", + "license": "MIT", + "dependencies": { + "hookified": "^1.15.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/hashery/node_modules/hookified": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-1.15.1.tgz", + "integrity": "sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==", + "license": "MIT" + }, + "node_modules/hookified": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-2.2.0.tgz", + "integrity": "sha512-p/LgFzRN5FeoD3DLS6bkUapeye6E4SI6yJs6KetENd18S+FBthqYq2amJUWpt5z0EQwwHemidjY5OqJGEKm5uA==", + "license": "MIT" + }, + "node_modules/keyv": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", + "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", + "license": "MIT", + "dependencies": { + "@keyv/serialize": "^1.1.1" + } } } } diff --git a/api/package.json b/api/package.json index 4342e1b..e02af95 100644 --- a/api/package.json +++ b/api/package.json @@ -4,8 +4,9 @@ "description": "", "scripts": { "start": "func start", - "test": "echo \"No tests yet...\"" + "test": "node --test mods/index.test.js" }, - "dependencies": {}, - "devDependencies": {} -} \ No newline at end of file + "dependencies": { + "@cacheable/node-cache": "3.1.1" + } +}