Skip to content

Commit

Permalink
feat(middleware): add etag middleware factory
Browse files Browse the repository at this point in the history
  • Loading branch information
TomokiMiyauci committed Mar 20, 2023
1 parent 7ac8005 commit 0c26565
Show file tree
Hide file tree
Showing 10 changed files with 319 additions and 2 deletions.
12 changes: 12 additions & 0 deletions _dev_deps.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
export {
assert,
assertEquals,
assertThrows,
} from "https://deno.land/std@0.180.0/testing/asserts.ts";
export { describe, it } from "https://deno.land/std@0.180.0/testing/bdd.ts";
export {
assertSpyCalls,
spy,
} from "https://deno.land/std@0.180.0/testing/mock.ts";
export { equalsResponse } from "https://deno.land/x/http_utils@1.0.0-beta.13/response.ts";
export { RepresentationHeader } from "https://deno.land/x/http_utils@1.0.0-beta.13/header.ts";
12 changes: 12 additions & 0 deletions deps.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
// Copyright 2023-latest the httpland authors. All rights reserved. MIT license.
// This module is browser compatible.

export { toHashString } from "https://deno.land/std@0.180.0/crypto/to_hash_string.ts";
export { isString } from "https://deno.land/x/isx@1.0.0-beta.24/mod.ts";
export {
type Handler,
type Middleware,
} from "https://deno.land/x/http_middleware@1.0.0/mod.ts";
export {
RepresentationHeader,
} from "https://deno.land/x/http_utils@1.0.0-beta.13/header.ts";
4 changes: 2 additions & 2 deletions etag.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import { ETag } from "./types.ts";
import { quote, weakPrefix } from "./utils.ts";
import { quoted, weakPrefix } from "./utils.ts";

export type WeakETagFormat = `W/"${string}"`;
export type StrongETagFormat = `"${string}"`;
export type ETagFormat = StrongETagFormat | WeakETagFormat;

/** Serialize {@link ETag} into string. */
export function stringify(etag: ETag): ETagFormat {
const opaqueTag = quote(etag.tag);
const opaqueTag = quoted(etag.tag);
const etagFormat = etag.weak ? weakPrefix(opaqueTag) : opaqueTag;

return etagFormat;
Expand Down
33 changes: 33 additions & 0 deletions middleware.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
// Copyright 2023-latest the httpland authors. All rights reserved. MIT license.
// This module is browser compatible.

import { type Middleware } from "./deps.ts";
import { withEtag } from "./transform.ts";
import { weakETag } from "./utils.ts";
import { CalculateETag } from "./types.ts";

/** Create `ETag` header middleware.
*
* @example
* ```ts
* import { etag } from "https://deno.land/x/http_etag@$VERSION/mod.ts";
* import { assertEquals } from "https://deno.land/std/testing/asserts.ts";
*
* const middleware = etag();
* const response = await middleware(
* new Request("http://localhost"),
* (request) => new Response("ok"),
* );
*
* assertEquals(response.headers.get("etag"), `"<body:SHA-1>"`);
* ```
*/
export function etag(calculateETag?: CalculateETag): Middleware {
const calculate = calculateETag ?? weakETag;

return async (request, next) => {
const response = await next(request);

return withEtag(response, calculate);
};
}
6 changes: 6 additions & 0 deletions mod.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
// Copyright 2023-latest the httpland authors. All rights reserved. MIT license.
// This module is browser compatible.

export { type Handler, type Middleware } from "./deps.ts";
export { etag } from "./middleware.ts";
export type { CalculateETag, ETag } from "./types.ts";
31 changes: 31 additions & 0 deletions transform.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
// Copyright 2023-latest the httpland authors. All rights reserved. MIT license.
// This module is browser compatible.

import { isString, RepresentationHeader } from "./deps.ts";
import { stringify } from "./etag.ts";
import type { CalculateETag } from "./types.ts";

/**
* @param response Response
* @param calculate Function to calculate hash values. The data is passed the actual response body value.
* @returns
*/
export async function withEtag(
response: Response,
calculate: CalculateETag,
): Promise<Response> {
if (
!response.ok ||
!response.body ||
response.bodyUsed ||
response.headers.has(RepresentationHeader.ETag)
) return response;

const etagLike = await calculate(response.clone());
const etag = isString(etagLike) ? etagLike : stringify(etagLike);
const res = response.clone();

res.headers.set(RepresentationHeader.ETag, etag);

return res;
}
110 changes: 110 additions & 0 deletions transform_test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import {
assert,
assertEquals,
assertSpyCalls,
describe,
equalsResponse,
it,
RepresentationHeader,
spy,
} from "./_dev_deps.ts";
import { withEtag } from "./transform.ts";

describe("withEtag", () => {
it("should return with etag header field if the response does not have etag field", async () => {
const digest = spy(() => `""`);
const initResponse = new Response("ok");
const response = await withEtag(initResponse, digest);

assertSpyCalls(digest, 1);
assert(
await equalsResponse(
response,
new Response("ok", { headers: { [RepresentationHeader.ETag]: `""` } }),
true,
),
);
});

it("should process custom digest what return strong etag object", async () => {
const digest = spy(() => ({ weak: false, tag: "abc" }));
const initResponse = new Response("ok");
const response = await withEtag(initResponse, digest);

assertSpyCalls(digest, 1);
assert(
await equalsResponse(
response,
new Response("ok", {
headers: {
[RepresentationHeader.ETag]: `"abc"`,
},
}),
true,
),
);
});

it("should process custom digest what return weak etag object", async () => {
const digest = spy(() => ({ weak: true, tag: "abc" }));
const initResponse = new Response("ok");
const response = await withEtag(initResponse, digest);

assertSpyCalls(digest, 1);
assert(
await equalsResponse(
response,
new Response("ok", {
headers: {
[RepresentationHeader.ETag]: `W/"abc"`,
},
}),
true,
),
);
});

it("should return same response if the response status code is not 2xx", async () => {
const initResponse = new Response("", { status: 404 });
const digest = spy(() => "");

const response = await withEtag(initResponse, digest);

assertSpyCalls(digest, 0);
assertEquals(initResponse, response);
});

it("should return same response if the response body does not exist", async () => {
const initResponse = new Response();
const digest = spy(() => "");

const response = await withEtag(initResponse, digest);

assertSpyCalls(digest, 0);
assertEquals(initResponse, response);
});

it("should return same response if the response has been read", async () => {
const initResponse = new Response("ok");
const digest = spy(() => "");

await initResponse.text();
const response = await withEtag(initResponse, digest);

assert(initResponse.bodyUsed);
assertSpyCalls(digest, 0);
assertEquals(initResponse, response);
});

it("should return same response if the response has etag header", async () => {
const initResponse = new Response("ok", {
headers: { [RepresentationHeader.ETag]: "" },
});
const digest = spy(() => "");

const response = await withEtag(initResponse, digest);

assertSpyCalls(digest, 0);
assertEquals(initResponse, response);
});
});
15 changes: 15 additions & 0 deletions types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
// Copyright 2023-latest the httpland authors. All rights reserved. MIT license.
// This module is browser compatible.

/** Function to calculate `<entity-tag>`. */
export interface CalculateETag {
(response: Response): string | ETag | Promise<string | ETag>;
}

export interface ETag {
/** Whether this is weak etag or not. */
readonly weak: boolean;

/** Representation of [`<etagc>`](https://www.rfc-editor.org/rfc/rfc9110.html#section-8.8.3-2). */
readonly tag: string;
}
50 changes: 50 additions & 0 deletions utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
// Copyright 2023-latest the httpland authors. All rights reserved. MIT license.
// This module is browser compatible.

import { toHashString } from "./deps.ts";

/** Catch error utility. */
export function reason(
message: string,
error: ErrorConstructor = Error,
): (cause: unknown) => never {
return (cause) => {
throw error(message, { cause });
};
}

/** Return quoted string. */
export function quoted<T extends string>(input: T): `"${T}"`;
export function quoted(input: string): string;
export function quoted(input: string): string {
return `"${input}"`;
}

export function weakPrefix<T extends string>(input: T): `W/${T}`;
export function weakPrefix(input: string): `W/${string}`;
export function weakPrefix(input: string): `W/${string}` {
return `W/${input}`;
}

export function weakETag(response: Response): Promise<string> {
return response
.clone()
.arrayBuffer()
.catch(reason(FailBy.Fetch))
.then(digestSha1)
.catch(reason(FailBy.CalcHash))
.then(toHashString)
.catch(reason(FailBy.CalcHashString))
.then(quoted)
.then(weakPrefix);
}

export function digestSha1(data: ArrayBuffer): Promise<ArrayBuffer> {
return crypto.subtle.digest("sha-1", data);
}

const enum FailBy {
Fetch = "fail to fetch resource",
CalcHash = "fail to calculate hash",
CalcHashString = "fail to calculate hash string",
}
48 changes: 48 additions & 0 deletions utils_test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { quoted, reason, weakETag, weakPrefix } from "./utils.ts";
import {
assert,
assertEquals,
assertThrows,
describe,
it,
} from "./_dev_deps.ts";

describe("reason", () => {
it("should throw error", () => {
assertThrows(() => reason("test")(Error()));
});
});

describe("weakPrefix", () => {
it("should return string", () => {
assertEquals(weakPrefix(""), "W/");
assertEquals(weakPrefix("a"), "W/a");
});
});

describe("quoted", () => {
it("should return quoted string", () => {
assertEquals(quoted(""), `""`);
assertEquals(quoted("a"), `"a"`);
});
});

describe("weakTag", () => {
it("should return weak etag", async () => {
const response = new Response("");
assertEquals(
await weakETag(response),
`W/"da39a3ee5e6b4b0d3255bfef95601890afd80709"`,
);
assert(!response.bodyUsed);
});

it("should throw error if the response has been read", async () => {
const response = new Response("");

await response.text();

assert(response.bodyUsed);
assertThrows(() => weakETag(response));
});
});

0 comments on commit 0c26565

Please sign in to comment.