Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
143 changes: 113 additions & 30 deletions api/mods/index.js
Original file line number Diff line number Diff line change
@@ -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;
182 changes: 182 additions & 0 deletions api/mods/index.test.js
Original file line number Diff line number Diff line change
@@ -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"));
});
Loading