diff --git a/src/api/routes/ft.ts b/src/api/routes/ft.ts index a73f732b..2c54e089 100644 --- a/src/api/routes/ft.ts +++ b/src/api/routes/ft.ts @@ -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'; @@ -25,6 +25,7 @@ const IndexRoutes: FastifyPluginCallback, Server, TypeBoxTy options, done ) => { + fastify.addHook('preHandler', handleChainTipCache); fastify.get( '/ft', { diff --git a/src/api/routes/status.ts b/src/api/routes/status.ts index f9103cb1..78986815 100644 --- a/src/api/routes/status.ts +++ b/src/api/routes/status.ts @@ -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, Server, TypeBoxTypeProvider > = (fastify, options, done) => { + fastify.addHook('preHandler', handleChainTipCache); fastify.get( '/', { diff --git a/src/api/schemas.ts b/src/api/schemas.ts index f83e4569..f9cabc7c 100644 --- a/src/api/schemas.ts +++ b/src/api/schemas.ts @@ -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' } ) diff --git a/src/api/util/cache.ts b/src/api/util/cache.ts index b2f1eb17..0c2a7478 100644 --- a/src/api/util/cache.ts +++ b/src/api/util/cache.ts @@ -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(); @@ -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'); @@ -52,47 +67,3 @@ async function getTokenEtag(request: FastifyRequest): Promise { 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,