diff --git a/README.md b/README.md index 369ad79..7758712 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/offered-routes.json b/offered-routes.json index 89c87be..bf0f0c7 100644 --- a/offered-routes.json +++ b/offered-routes.json @@ -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": [ diff --git a/server.js b/server.js index 92c4e5d..df0c9a7 100644 --- a/server.js +++ b/server.js @@ -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() { diff --git a/test/server.test.js b/test/server.test.js index d0990dd..f8a4eb3 100644 --- a/test/server.test.js +++ b/test/server.test.js @@ -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 }, @@ -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'); @@ -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'); @@ -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'); @@ -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'); @@ -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 () => ({ @@ -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'); @@ -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'); diff --git a/test/test-server.sh b/test/test-server.sh index bc0026f..b96c215 100755 --- a/test/test-server.sh +++ b/test/test-server.sh @@ -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"