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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ Optional: `PORT` (3000), `BIND` (`0.0.0.0`), `CACHE_TTL_MS` (default 300000), `C
- `GET`/`HEAD /` — answered locally with 302 `Location: swagger`; HEAD has an empty body
- `GET`/`HEAD /version` — answered locally (JSON, or HTML when `Accept` includes `text/html`); HEAD has an empty body
- `GET`/`HEAD /swagger`, `/swagger/`, `/swagger-ui`, `/swagger-ui/`, `/swagger-json`, `/swagger-json/` — filtered swagger snapshot from the upstream HTTP backend; an empty snapshot returns 503 locally
- GET/HEAD cache (default 5 minutes) for the public prefixes `/v1/asset`, `/v1/fiat`, `/v1/country`, `/v1/language`, `/v1/statistic`, `/v1/coin`, `/v1/setting`, `/v1/bank`, `/v1/app` (no `Authorization`). Nested paths under these prefixes are listed. HEAD follows the same local rules as GET and has an empty body.
- GET/HEAD cache (default 5 minutes) for the public prefixes `/v1/asset`, `/v1/fiat`, `/v1/country`, `/v1/language`, `/v1/statistic`, `/v1/coin`, `/v1/setting`, `/v1/bank`, `/v1/app` (no `Authorization`). Nested paths under these prefixes are listed. Nested `GET`/`HEAD /v1/statistic/status` is answered from the cached list-root `status` object. HEAD follows the same local rules as GET and has an empty body.
- Optional Postgres reads for `GET`/`HEAD /v1/country` and `GET`/`HEAD /v1/language` when `SQL_HOST` is set
- An authenticated listed GET/HEAD never reads the unauthenticated GET cache. Without another local source it returns `503` `not served`; it is never forwarded.

Expand Down
2 changes: 1 addition & 1 deletion offered-routes.json
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,7 @@
"usedIn": [
{
"unidentified": true,
"note": "Prefix on the swagger allowlist. The root and nested paths are served from the background GET cache; a miss is 503 not served, never forwarded. No named call site in DFXswiss/packages or DFXswiss/services."
"note": "Prefix on the swagger allowlist. The root is served from the background GET cache. Nested GET /v1/statistic/status is answered from that cached list-root status object (not a separate backend fetch). A miss is 503 not served, never forwarded. No named call site in DFXswiss/packages or DFXswiss/services."
}
],
"e2e": [
Expand Down
20 changes: 20 additions & 0 deletions server.js
Original file line number Diff line number Diff line change
Expand Up @@ -93,12 +93,32 @@ function getCached(key) {
return cache.get(key) || null;
}

function embeddedStatisticStatus(body) {
try {
const raw = Buffer.isBuffer(body) ? body.toString('utf8') : String(body);
const json = JSON.parse(raw);
if (!json || typeof json !== 'object' || Array.isArray(json)) return null;
const nested = json.status;
if (!nested || typeof nested !== 'object' || Array.isArray(nested)) return null;
return nested;
} catch {
return null;
}
}

function putCache(key, status, headers, body) {
if (cache.size >= CACHE_MAX) {
const oldest = cache.keys().next().value;
if (oldest !== undefined) cache.delete(oldest);
}
cache.set(key, { status, headers, body, exp: Date.now() + TTL_MS });
if (key !== 'GET /v1/statistic' || status !== 200) return;
const nested = embeddedStatisticStatus(body);
if (!nested) {
cache.delete('GET /v1/statistic/status');
return;
}
putCache('GET /v1/statistic/status', 200, headers, Buffer.from(JSON.stringify(nested)));
}

function localVersion() {
Expand Down
73 changes: 72 additions & 1 deletion test/server.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,11 @@ async function main() {
'/v1/fiat': fiats,
'/': { root: 1 },
'/swagger-json': swagger,
'/v1/statistic': { ok: 1 },
'/v1/statistic': {
totalVolume: { buy: 1, sell: 2 },
totalRewards: { staking: 0, ref: 0 },
status: { buy: 'ONLINE', sell: 'ONLINE' },
},
'/v1/setting': { ok: 1 },
'/v1/setting/infoBanner': { banner: 1 },
'/v1/bank': { ok: 1 },
Expand Down Expand Up @@ -277,6 +281,7 @@ async function main() {
if (!isServedPath('/v1/asset') || !isServedPath(undefined)) fail('isServedPath');
if (!isServedPath('/v1/asset/1')) fail('isServedPath nested asset');
if (!isServedPath('/v1/setting/infoBanner')) fail('isServedPath nested setting');
if (!isServedPath('/v1/statistic/status')) fail('isServedPath nested statistic status');
if (!isServedPath('/v1/asset/{id}')) fail('isServedPath template');
if (isServedPath('/v1/assetfoo')) fail('isServedPath prefix boundary');
if (isServedPath('/v1/other')) fail('isServedPath outside listed prefixes');
Expand All @@ -288,6 +293,8 @@ async function main() {
if (!isKnownLocalRequest({ method: 'HEAD', url: '/v1/asset', headers: {} })) fail('known HEAD');
if (!isKnownLocalRequest({ method: 'GET', url: '/v1/asset/1', headers: {} })) fail('known asset id');
if (!isKnownLocalRequest({ method: 'GET', url: '/v1/setting/infoBanner', headers: {} })) fail('known infoBanner');
if (!isKnownLocalRequest({ method: 'GET', url: '/v1/statistic/status', headers: {} })) fail('known statistic status');
if (!isKnownLocalRequest({ method: 'HEAD', url: '/v1/statistic/status', headers: {} })) fail('known statistic status HEAD');
if (!isKnownLocalRequest({ method: 'GET', url: '/swagger-json', headers: { authorization: 'x' } })) fail('known swagger ignores auth');

if (!isCacheable({ method: 'GET', url: '/v1/asset', headers: {} })) fail('cache GET');
Expand Down Expand Up @@ -350,6 +357,51 @@ async function main() {
for (let i = 0; i < CACHE_MAX + 2; i++) putCache('k' + i, 200, {}, Buffer.from(String(i)));
if (cache.size > CACHE_MAX) fail('eviction');

cache.clear();
putCache('GET /v1/statistic', 200, { 'content-type': 'application/json' }, Buffer.from('{"ok":1}'));
if (getCached('GET /v1/statistic/status')) fail('statistic without status must not fan-out');
putCache('GET /v1/statistic', 200, { 'content-type': 'application/json' }, Buffer.from('not-json'));
if (getCached('GET /v1/statistic/status')) fail('invalid statistic json must not fan-out');
putCache('GET /v1/statistic', 200, { h: '1' }, Buffer.from('[]'));
if (getCached('GET /v1/statistic/status')) fail('array statistic must not fan-out');
putCache('GET /v1/statistic', 200, { h: '1' }, Buffer.from('null'));
if (getCached('GET /v1/statistic/status')) fail('null statistic must not fan-out');
putCache('GET /v1/statistic', 200, { h: '1' }, Buffer.from('{"status":null}'));
if (getCached('GET /v1/statistic/status')) fail('null status must not fan-out');
putCache('GET /v1/statistic', 200, { h: '1' }, Buffer.from('{"status":[]}'));
if (getCached('GET /v1/statistic/status')) fail('array status must not fan-out');
putCache('GET /v1/statistic', 200, { h: '1' }, Buffer.from('{"status":"ONLINE"}'));
if (getCached('GET /v1/statistic/status')) fail('string status must not fan-out');
putCache('GET /v1/statistic', 500, { h: '1' }, Buffer.from('{"status":{"buy":"ONLINE"}}'));
if (getCached('GET /v1/statistic/status')) fail('non-200 statistic must not fan-out');
putCache('GET /v1/asset', 200, { h: '1' }, Buffer.from('{"status":{"buy":"ONLINE"}}'));
if (getCached('GET /v1/statistic/status')) fail('non-statistic must not fan-out');
putCache('GET /v1/statistic', 200, { h: '1' }, '{"status":{"buy":"ONLINE","sell":"ONLINE"}}');
const fromString = getCached('GET /v1/statistic/status');
if (!fromString || fromString.status !== 200) fail('string statistic body must fan-out');
cache.delete('GET /v1/statistic/status');
const statusHeaders = { 'content-type': 'application/json', 'access-control-allow-origin': '*' };
putCache(
'GET /v1/statistic',
200,
statusHeaders,
Buffer.from(
JSON.stringify({
totalVolume: { buy: 1, sell: 2 },
totalRewards: { staking: 0, ref: 0 },
status: { buy: 'ONLINE', sell: 'ONLINE' },
}),
),
);
const nested = getCached('GET /v1/statistic/status');
if (!nested || nested.status !== 200) fail('statistic status fan-out');
const nestedBody = Buffer.isBuffer(nested.body) ? nested.body.toString('utf8') : String(nested.body);
if (nestedBody.indexOf('buy":"ONLINE') < 0 || nestedBody.indexOf('sell":"ONLINE') < 0) {
fail('statistic status body');
}
putCache('GET /v1/statistic', 200, { h: '1' }, Buffer.from('{"ok":1}'));
if (getCached('GET /v1/statistic/status')) fail('replacing statistic without status must drop fan-out');

const resJson = fakeRes();
sendJson(resJson, 200, { ok: 1 }, 'local');
if (resJson.status !== 200) fail('sendJson object');
Expand Down Expand Up @@ -488,6 +540,13 @@ async function main() {
if (seen.filter((row) => row.method === 'GET' && row.path === '/v1/asset/1').length !== assetIdBackendRequests) {
fail('parameterized GET must not be forwarded');
}
const statusMissSeen = seen.filter((row) => row.method === 'GET' && row.path === '/v1/statistic/status').length;
cache.delete('GET /v1/statistic/status');
got = await request(port, 'GET', '/v1/statistic/status');
if (got.status !== 503 || got.body.indexOf('not served') < 0) fail('statistic status miss must be local');
if (seen.filter((row) => row.method === 'GET' && row.path === '/v1/statistic/status').length !== statusMissSeen) {
fail('statistic status must not be forwarded');
}
cache.delete('GET /v1/setting/infoBanner');
const bannerBackendRequests = seen.filter((row) => row.method === 'GET' && row.path === '/v1/setting/infoBanner').length;
got = await request(port, 'GET', '/v1/setting/infoBanner');
Expand Down Expand Up @@ -535,6 +594,8 @@ async function main() {
putCache('GET /v1/statistic', 200, { 'content-type': 'application/json' }, Buffer.from('{"ok":1}'));
got = await request(port, 'GET', '/v1/statistic');
if (got.headers['x-front-api'] !== 'hit') fail('cache hit');
got = await request(port, 'GET', '/v1/statistic/status');
if (got.status !== 503 || got.body.indexOf('not served') < 0) fail('ok:1 statistic must not serve status');

setPool({
query: async () => ({
Expand Down Expand Up @@ -669,6 +730,7 @@ async function main() {
if (getCached('GET /v1/app')) fail('refreshCache must skip non-200');
if (getCached('GET /')) fail('refreshCache must not fill GET /');
if (!getCached('GET /v1/setting/infoBanner')) fail('refreshCache must fill listed nested swagger GET');
if (!getCached('GET /v1/statistic/status')) fail('refreshCache must fan-out statistic status');
if (getCached('GET /v1/other')) fail('refreshCache must not fill a path outside the allowlist');
got = await request(port, 'GET', '/');
if (got.status !== 302) fail('GET / must 302');
Expand All @@ -683,6 +745,15 @@ async function main() {
if (got.status !== 200 || got.body.indexOf('banner') < 0 || got.headers['x-front-api'] !== 'hit') {
fail('nested swagger GET must use background cache');
}
got = await request(port, 'GET', '/v1/statistic/status');
if (got.status !== 200 || got.body.indexOf('buy":"ONLINE') < 0 || got.body.indexOf('sell":"ONLINE') < 0) {
fail('statistic status from list root');
}
if (got.headers['x-front-api'] !== 'hit') fail('statistic status cache hit');
got = await request(port, 'HEAD', '/v1/statistic/status');
if (got.status !== 200 || got.body !== '' || got.headers['x-front-api'] !== 'hit') {
fail('HEAD statistic status must share GET cache');
}
got = await request(port, 'GET', '/v1/asset?x=1');
if (got.status !== 200 || got.headers['x-front-api'] !== 'hit') fail('query must hit path cache');
got = await request(port, 'HEAD', '/v1/asset');
Expand Down
2 changes: 2 additions & 0 deletions test/test-server.sh
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,8 @@ grep -Fq 'if (!res.destroyed) req.destroy();' "$server_js" || fail "max_response
grep -Fq "connection: 'close'" "$server_js" || fail "max_response_100: deadline 503 must close the connection"
grep -q 'function rejectUnserved' "$server_js" || fail "known_local: uncached known GETs must 503 not served"
grep -q 'refreshCache' "$server_js" || fail "known_local: GET cache must fill off the request path"
grep -Fq "GET /v1/statistic/status" "$server_js" || fail "known_local: statistic status must fan-out from the list root"
grep -q 'function embeddedStatisticStatus' "$server_js" || fail "known_local: statistic status fan-out helper missing"
grep -Fq "location: 'swagger'" "$server_js" || fail "known_local: GET / must 302 to swagger"
grep -Fq 'const roots = [...CACHE_PREFIXES];' "$server_js" || fail "known_local: background refresh must not include GET /"
grep -q 'function cacheRefreshPaths' "$server_js" || fail "known_local: GET cache refresh set must include roots and listed swagger paths"
Expand Down
Loading