Skip to content

Commit

Permalink
feat: add http/etag (#3245)
Browse files Browse the repository at this point in the history
  • Loading branch information
kitsonk authored Apr 12, 2023
1 parent 7d63d59 commit 016b452
Show file tree
Hide file tree
Showing 6 changed files with 366 additions and 48 deletions.
212 changes: 212 additions & 0 deletions http/etag.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
// Copyright 2018-2023 the Deno authors. All rights reserved. MIT license.

/** Provides functions for dealing with and matching ETags, including
* {@linkcode calculate} to calculate an etag for a given entity,
* {@linkcode ifMatch} for validating if an ETag matches against a `If-Match`
* header and {@linkcode ifNoneMatch} for validating an Etag against an
* `If-None-Match` header.
*
* See further information on the `ETag` header on
* [MDN](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/ETag).
*
* @module
*/

import { encode as base64Encode } from "../encoding/base64.ts";

/** Just the part of `Deno.FileInfo` that is required to calculate an `ETag`,
* so partial or user generated file information can be passed. */
export interface FileInfo {
mtime: Date | null;
size: number;
}

type Entity = string | Uint8Array | FileInfo;

const encoder = new TextEncoder();

const DEFAULT_ALGORITHM: AlgorithmIdentifier = "SHA-256";
const ENV_PERM_STATUS =
Deno.permissions.querySync?.({ name: "env", variable: "DENO_DEPLOYMENT_ID" })
.state ?? "granted"; // for deno deploy
const DENO_DEPLOYMENT_ID = ENV_PERM_STATUS === "granted"
? Deno.env.get("DENO_DEPLOYMENT_ID")
: undefined;
const HASHED_DENO_DEPLOYMENT_ID = DENO_DEPLOYMENT_ID
? crypto.subtle.digest(DEFAULT_ALGORITHM, encoder.encode(DENO_DEPLOYMENT_ID))
.then((hash) =>
`${DENO_DEPLOYMENT_ID.length.toString(16)}-${
base64Encode(hash).substring(0, 27)
}`
)
: undefined;

export interface ETagOptions {
/** A digest algorithm to use to calculate the etag. Defaults to
* `"FNV32A"`. */
algorithm?: AlgorithmIdentifier;

/** Override the default behavior of calculating the `ETag`, either forcing
* a tag to be labelled weak or not. */
weak?: boolean;
}

function isFileInfo(value: unknown): value is FileInfo {
return Boolean(
value && typeof value === "object" && "mtime" in value && "size" in value,
);
}

async function calcEntity(
entity: string | Uint8Array,
{ algorithm = DEFAULT_ALGORITHM }: ETagOptions,
) {
// a short circuit for zero length entities
if (entity.length === 0) {
return `0-47DEQpj8HBSa+/TImW+5JCeuQeR`;
}

if (typeof entity === "string") {
entity = encoder.encode(entity);
}

const hash = base64Encode(await crypto.subtle.digest(algorithm, entity))
.substring(0, 27);

return `${entity.length.toString(16)}-${hash}`;
}

async function calcFileInfo(
fileInfo: FileInfo,
{ algorithm = DEFAULT_ALGORITHM }: ETagOptions,
) {
if (fileInfo.mtime) {
const hash = base64Encode(
await crypto.subtle.digest(
algorithm,
encoder.encode(fileInfo.mtime.toJSON()),
),
).substring(0, 27);
return `${fileInfo.size.toString(16)}-${hash}`;
}
return HASHED_DENO_DEPLOYMENT_ID;
}

/** Calculate an ETag for an entity. When the entity is a specific set of data
* it will be fingerprinted as a "strong" tag, otherwise if it is just file
* information, it will be calculated as a weak tag.
*
* ```ts
* import { calculate } from "https://deno.land/std@$STD_VERSION/http/etag.ts";
* import { assert } from "https://deno.land/std@$STD_VERSION/testing/asserts.ts"
*
* const body = "hello deno!";
*
* const etag = await calculate(body);
* assert(etag);
*
* const res = new Response(body, { headers: { etag } });
* ```
*/
export async function calculate(
entity: Entity,
options: ETagOptions = {},
): Promise<string | undefined> {
const weak = options.weak ?? isFileInfo(entity);
const tag =
await (isFileInfo(entity)
? calcFileInfo(entity, options)
: calcEntity(entity, options));

return tag ? weak ? `W/"${tag}"` : `"${tag}"` : undefined;
}

/** A helper function that takes the value from the `If-Match` header and a
* calculated etag for the target. By using strong comparison, return `true` if
* the values match, otherwise `false`.
*
* See MDN's [`If-Match`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-Match)
* article for more information on how to use this function.
*
* ```ts
* import {
* calculate,
* ifMatch,
* } from "https://deno.land/std@$STD_VERSION/http/etag.ts";
* import { serve } from "https://deno.land/std@$STD_VERSION/http/server.ts";
* import { assert } from "https://deno.land/std@$STD_VERSION/testing/asserts.ts"
*
* const body = "hello deno!";
*
* await serve(async (req) => {
* const ifMatchValue = req.headers.get("if-match");
* const etag = await calculate(body);
* assert(etag);
* if (!ifMatchValue || ifMatch(ifMatchValue, etag)) {
* return new Response(body, { status: 200, headers: { etag } });
* } else {
* return new Response(null, { status: 412, statusText: "Precondition Failed"});
* }
* });
* ```
*/
export function ifMatch(
value: string | null,
etag: string | undefined,
): boolean {
// Weak tags cannot be matched and return false.
if (!value || !etag || etag.startsWith("W/")) {
return false;
}
if (value.trim() === "*") {
return true;
}
const tags = value.split(/\s*,\s*/);
return tags.includes(etag);
}

/** A helper function that takes the value from the `If-None-Match` header and
* a calculated etag for the target entity and returns `false` if the etag for
* the entity matches the supplied value, otherwise `true`.
*
* See MDN's [`If-None-Match`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-None-Match)
* article for more information on how to use this function.
*
* ```ts
* import {
* calculate,
* ifNoneMatch,
* } from "https://deno.land/std@$STD_VERSION/http/etag.ts";
* import { serve } from "https://deno.land/std@$STD_VERSION/http/server.ts";
* import { assert } from "https://deno.land/std@$STD_VERSION/testing/asserts.ts"
*
* const body = "hello deno!";
*
* await serve(async (req) => {
* const ifNoneMatchValue = req.headers.get("if-none-match");
* const etag = await calculate(body);
* assert(etag);
* if (!ifNoneMatch(ifNoneMatchValue, etag)) {
* return new Response(null, { status: 304, headers: { etag } });
* } else {
* return new Response(body, { status: 200, headers: { etag } });
* }
* });
* ```
*/
export function ifNoneMatch(
value: string | null,
etag: string | undefined,
): boolean {
if (!value || !etag) {
return true;
}
if (value.trim() === "*") {
return false;
}
etag = etag.startsWith("W/") ? etag.slice(2) : etag;
const tags = value.split(/\s*,\s*/).map((tag) =>
tag.startsWith("W/") ? tag.slice(2) : tag
);
return !tags.includes(etag);
}
129 changes: 129 additions & 0 deletions http/etag_test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
// Copyright 2018-2023 the Deno authors. All rights reserved. MIT license.

import { assert, assertEquals } from "../testing/asserts.ts";

import { calculate, ifMatch, ifNoneMatch } from "./etag.ts";

const encoder = new TextEncoder();

Deno.test({
name: "etag - calculate - string - empty",
async fn() {
const actual = await calculate("");
assertEquals(actual, `"0-47DEQpj8HBSa+/TImW+5JCeuQeR"`);
},
});

Deno.test({
name: "etag - calculate - string",
async fn() {
const actual = await calculate("hello deno");
assertEquals(actual, `"a-YdfmHmj2RiwOVqJupcf3PLK9PuJ"`);
},
});

Deno.test({
name: "etag - calculate - Uint8Array - empty",
async fn() {
const actual = await calculate(new Uint8Array());
assertEquals(actual, `"0-47DEQpj8HBSa+/TImW+5JCeuQeR"`);
},
});

Deno.test({
name: "etag - calculate - Uint8Array",
async fn() {
const actual = await calculate(encoder.encode("hello deno"));
assertEquals(actual, `"a-YdfmHmj2RiwOVqJupcf3PLK9PuJ"`);
},
});

Deno.test({
name: "etag - calculate - Deno.FileInfo",
async fn() {
const fixture: Deno.FileInfo = {
isFile: true,
isDirectory: false,
isSymlink: false,
size: 1024,
mtime: new Date(Date.UTC(96, 1, 2, 3, 4, 5, 6)),
atime: null,
birthtime: null,
dev: 0,
ino: null,
mode: null,
nlink: null,
uid: null,
gid: null,
rdev: null,
blksize: null,
blocks: null,
};
const actual = await calculate(fixture);
assertEquals(actual, `W/"400-H0YzXysQPV20qNisAZMuvAEVuHV"`);
},
});

Deno.test({
name: "etag - ifMatch",
async fn() {
assert(!ifMatch(`"abcdefg"`, await calculate("hello deno")));
assert(
ifMatch(`"a-YdfmHmj2RiwOVqJupcf3PLK9PuJ"`, await calculate("hello deno")),
);
assert(
ifMatch(
`"abcdefg", "a-YdfmHmj2RiwOVqJupcf3PLK9PuJ"`,
await calculate("hello deno"),
),
);
assert(ifMatch("*", await calculate("hello deno")));
assert(
!ifMatch(
"*",
await calculate({
size: 1024,
mtime: new Date(Date.UTC(96, 1, 2, 3, 4, 5, 6)),
}),
),
);
},
});

Deno.test({
name: "etag - ifNoneMatch",
async fn() {
assert(ifNoneMatch(`"abcdefg"`, await calculate("hello deno")));
assert(
!ifNoneMatch(
`"a-YdfmHmj2RiwOVqJupcf3PLK9PuJ"`,
await calculate("hello deno"),
),
);
assert(
!ifNoneMatch(
`"abcdefg", "a-YdfmHmj2RiwOVqJupcf3PLK9PuJ"`,
await calculate("hello deno"),
),
);
assert(!ifNoneMatch("*", await calculate("hello deno")));
assert(
!ifNoneMatch(
`W/"400-H0YzXysQPV20qNisAZMuvAEVuHV"`,
await calculate({
size: 1024,
mtime: new Date(Date.UTC(96, 1, 2, 3, 4, 5, 6)),
}),
),
);
assert(
!ifNoneMatch(
`"400-H0YzXysQPV20qNisAZMuvAEVuHV"`,
await calculate({
size: 1024,
mtime: new Date(Date.UTC(96, 1, 2, 3, 4, 5, 6)),
}),
),
);
},
});
Loading

0 comments on commit 016b452

Please sign in to comment.