Skip to content

Commit

Permalink
fix: add chain tip etag generator (#255)
Browse files Browse the repository at this point in the history
* fix: add chain tip etag generator

* fix: check
  • Loading branch information
rafaelcr authored Aug 29, 2024
1 parent 2d04d0a commit 2b993cd
Show file tree
Hide file tree
Showing 5 changed files with 59 additions and 55 deletions.
3 changes: 2 additions & 1 deletion src/api/routes/ft.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import {
StacksAddressParam,
TokenQuerystringParams,
} from '../schemas';
import { handleTokenCache } from '../util/cache';
import { handleChainTipCache, handleTokenCache } from '../util/cache';
import { generateTokenErrorResponse, TokenErrorResponseSchema } from '../util/errors';
import { parseMetadataLocaleBundle } from '../util/helpers';

Expand All @@ -25,6 +25,7 @@ const IndexRoutes: FastifyPluginCallback<Record<never, never>, Server, TypeBoxTy
options,
done
) => {
fastify.addHook('preHandler', handleChainTipCache);
fastify.get(
'/ft',
{
Expand Down
2 changes: 2 additions & 0 deletions src/api/routes/status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,14 @@ import { FastifyPluginCallback } from 'fastify';
import { Server } from 'http';
import { ApiStatusResponse } from '../schemas';
import { SERVER_VERSION } from '@hirosystems/api-toolkit';
import { handleChainTipCache } from '../util/cache';

export const StatusRoutes: FastifyPluginCallback<
Record<never, never>,
Server,
TypeBoxTypeProvider
> = (fastify, options, done) => {
fastify.addHook('preHandler', handleChainTipCache);
fastify.get(
'/',
{
Expand Down
1 change: 1 addition & 0 deletions src/api/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -340,6 +340,7 @@ export const ApiStatusResponse = Type.Object(
queued: Type.Optional(Type.Integer({ examples: [512] })),
done: Type.Optional(Type.Integer({ examples: [12532] })),
failed: Type.Optional(Type.Integer({ examples: [11] })),
invalid: Type.Optional(Type.Integer({ examples: [20] })),
},
{ title: 'Api Job Count' }
)
Expand Down
79 changes: 25 additions & 54 deletions src/api/util/cache.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,25 @@
import { FastifyReply, FastifyRequest } from 'fastify';
import { SmartContractRegEx } from '../schemas';
import { logger } from '@hirosystems/api-toolkit';
import { CACHE_CONTROL_MUST_REVALIDATE, parseIfNoneMatchHeader } from '@hirosystems/api-toolkit';

/**
* A `Cache-Control` header used for re-validation based caching.
* * `public` == allow proxies/CDNs to cache as opposed to only local browsers.
* * `no-cache` == clients can cache a resource but should revalidate each time before using it.
* * `must-revalidate` == somewhat redundant directive to assert that cache must be revalidated, required by some CDNs
*/
const CACHE_CONTROL_MUST_REVALIDATE = 'public, no-cache, must-revalidate';
enum ETagType {
chainTip = 'chain_tip',
token = 'token',
}

export async function handleTokenCache(request: FastifyRequest, reply: FastifyReply) {
async function handleCache(type: ETagType, request: FastifyRequest, reply: FastifyReply) {
const ifNoneMatch = parseIfNoneMatchHeader(request.headers['if-none-match']);
const etag = await getTokenEtag(request);
let etag: string | undefined;
switch (type) {
case ETagType.chainTip:
// TODO: We should use the `index_block_hash` here instead of the `block_hash`, but we'll need
// a DB change for this.
etag = (await request.server.db.getChainTipBlockHeight()).toString();
break;
case ETagType.token:
etag = await getTokenEtag(request);
break;
}
if (etag) {
if (ifNoneMatch && ifNoneMatch.includes(etag)) {
await reply.header('Cache-Control', CACHE_CONTROL_MUST_REVALIDATE).code(304).send();
Expand All @@ -22,6 +29,14 @@ export async function handleTokenCache(request: FastifyRequest, reply: FastifyRe
}
}

export async function handleTokenCache(request: FastifyRequest, reply: FastifyReply) {
return handleCache(ETagType.token, request, reply);
}

export async function handleChainTipCache(request: FastifyRequest, reply: FastifyReply) {
return handleCache(ETagType.chainTip, request, reply);
}

export function setReplyNonCacheable(reply: FastifyReply) {
reply.removeHeader('Cache-Control');
reply.removeHeader('Etag');
Expand Down Expand Up @@ -52,47 +67,3 @@ async function getTokenEtag(request: FastifyRequest): Promise<string | undefined
return undefined;
}
}

/**
* Parses the etag values from a raw `If-None-Match` request header value.
* The wrapping double quotes (if any) and validation prefix (if any) are stripped.
* The parsing is permissive to account for commonly non-spec-compliant clients, proxies, CDNs, etc.
* E.g. the value:
* ```js
* `"a", W/"b", c,d, "e", "f"`
* ```
* Would be parsed and returned as:
* ```js
* ['a', 'b', 'c', 'd', 'e', 'f']
* ```
* @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-None-Match#syntax
* ```
* If-None-Match: "etag_value"
* If-None-Match: "etag_value", "etag_value", ...
* If-None-Match: *
* ```
* @param ifNoneMatchHeaderValue - raw header value
* @returns an array of etag values
*/
function parseIfNoneMatchHeader(ifNoneMatchHeaderValue: string | undefined): string[] | undefined {
if (!ifNoneMatchHeaderValue) {
return undefined;
}
// Strip wrapping double quotes like `"hello"` and the ETag validation-prefix like `W/"hello"`.
// The API returns compliant, strong-validation ETags (double quoted ASCII), but can't control what
// clients, proxies, CDNs, etc may provide.
const normalized = /^(?:"|W\/")?(.*?)"?$/gi.exec(ifNoneMatchHeaderValue.trim())?.[1];
if (!normalized) {
// This should never happen unless handling a buggy request with something like `If-None-Match: ""`,
// or if there's a flaw in the above code. Log warning for now.
logger.warn(`Normalized If-None-Match header is falsy: ${ifNoneMatchHeaderValue}`);
return undefined;
} else if (normalized.includes(',')) {
// Multiple etag values provided, likely irrelevant extra values added by a proxy/CDN.
// Split on comma, also stripping quotes, weak-validation prefixes, and extra whitespace.
return normalized.split(/(?:W\/"|")?(?:\s*),(?:\s*)(?:W\/"|")?/gi);
} else {
// Single value provided (the typical case)
return [normalized];
}
}
29 changes: 29 additions & 0 deletions tests/api/cache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,35 @@ describe('ETag cache', () => {
await db.close();
});

test('chain tip cache control', async () => {
const response = await fastify.inject({ method: 'GET', url: '/metadata/v1/' });
const json = response.json();
expect(json).toStrictEqual({
server_version: 'token-metadata-api v0.0.1 (test:123456)',
status: 'ready',
chain_tip: {
block_height: 1,
},
});
expect(response.headers.etag).not.toBeUndefined();
const etag = response.headers.etag;

const cached = await fastify.inject({
method: 'GET',
url: '/metadata/v1/',
headers: { 'if-none-match': etag },
});
expect(cached.statusCode).toBe(304);

await db.chainhook.updateChainTipBlockHeight(100);
const cached2 = await fastify.inject({
method: 'GET',
url: '/metadata/v1/',
headers: { 'if-none-match': etag },
});
expect(cached2.statusCode).toBe(200);
});

test('FT cache control', async () => {
await insertAndEnqueueTestContractWithTokens(
db,
Expand Down

0 comments on commit 2b993cd

Please sign in to comment.