Skip to content

Commit

Permalink
feat: add fastify cache and schema helpers (#22)
Browse files Browse the repository at this point in the history
  • Loading branch information
rafaelcr authored Jun 28, 2024
1 parent a40e83e commit 49aa9e6
Show file tree
Hide file tree
Showing 5 changed files with 84 additions and 2 deletions.
7 changes: 5 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@
"@fastify/cors": "^8.0.0",
"@fastify/swagger": "^8.3.1",
"@fastify/type-provider-typebox": "^3.2.0",
"@sinclair/typebox": "^0.28.20",
"fastify": "^4.3.0",
"fastify-metrics": "^10.2.0",
"node-pg-migrate": "^6.2.2",
Expand Down
61 changes: 61 additions & 0 deletions src/fastify/cache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { FastifyReply } from 'fastify';
import { logger } from '../logger';

/**
* 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
*/
export const CACHE_CONTROL_MUST_REVALIDATE = 'public, no-cache, must-revalidate';

export async function setResponseNonCacheable(reply: FastifyReply) {
await reply.removeHeader('Cache-Control');
await reply.removeHeader('ETag');
}

/**
* 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
*/
export 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];
}
}
2 changes: 2 additions & 0 deletions src/fastify/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,4 @@
export * from './cache';
export * from './fastify';
export * from './openapi';
export * from './schemas';
15 changes: 15 additions & 0 deletions src/fastify/schemas.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { TSchema, Type } from '@sinclair/typebox';

export const Nullable = <T extends TSchema>(type: T) => Type.Union([type, Type.Null()]);
export const Optional = <T extends TSchema>(type: T) => Type.Optional(type);

export const PaginatedResponse = <T extends TSchema>(type: T, title: string) =>
Type.Object(
{
limit: Type.Integer({ examples: [20] }),
offset: Type.Integer({ examples: [0] }),
total: Type.Integer({ examples: [1] }),
results: Type.Array(type),
},
{ title }
);

0 comments on commit 49aa9e6

Please sign in to comment.