Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: add chain tip etag generator #255

Merged
merged 2 commits into from
Aug 29, 2024
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
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 @@
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 @@
options,
done
) => {
fastify.addHook('preHandler', handleChainTipCache);

Check warning on line 28 in src/api/routes/ft.ts

View check run for this annotation

Codecov / codecov/patch

src/api/routes/ft.ts#L28

Added line #L28 was not covered by tests
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 { 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);

Check warning on line 13 in src/api/routes/status.ts

View check run for this annotation

Codecov / codecov/patch

src/api/routes/status.ts#L13

Added line #L13 was not covered by tests
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) {

Check warning on line 10 in src/api/util/cache.ts

View check run for this annotation

Codecov / codecov/patch

src/api/util/cache.ts#L10

Added line #L10 was not covered by tests
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;
}

Check warning on line 22 in src/api/util/cache.ts

View check run for this annotation

Codecov / codecov/patch

src/api/util/cache.ts#L12-L22

Added lines #L12 - L22 were not covered by tests
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: FastifyReply) {
return handleCache(ETagType.token, request, reply);
}

Check warning on line 34 in src/api/util/cache.ts

View check run for this annotation

Codecov / codecov/patch

src/api/util/cache.ts#L33-L34

Added lines #L33 - L34 were not covered by tests

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

Check warning on line 38 in src/api/util/cache.ts

View check run for this annotation

Codecov / codecov/patch

src/api/util/cache.ts#L37-L38

Added lines #L37 - L38 were not covered by tests

export function setReplyNonCacheable(reply: FastifyReply) {
reply.removeHeader('Cache-Control');
reply.removeHeader('Etag');
Expand Down Expand Up @@ -52,47 +67,3 @@
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 {
tippenein marked this conversation as resolved.
Show resolved Hide resolved
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
Loading