Skip to content

Commit

Permalink
feat(types): change etag strategy interface
Browse files Browse the repository at this point in the history
  • Loading branch information
TomokiMiyauci committed Mar 21, 2023
1 parent b078f45 commit 813aad5
Show file tree
Hide file tree
Showing 7 changed files with 101 additions and 66 deletions.
55 changes: 33 additions & 22 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,11 +71,11 @@ For this reason, the default is to compute as a weak validator.

ETag computation strategy.

| Name | Type | Default | Description |
| --------- | ------------------------------------------------------------------ | ------------------ | --------------------------------------------------------------------- |
| strong | `boolean` | `false` | Whether the validator is strong or not. |
| algorithm | `"SHA-1"` | `"SHA-256"` | `"SHA-384"` | `"SHA-512"` | `"SHA-1"` | Hash algorithm. |
| headers | `string[]` | `["content-type"]` | Semantically significant header related with the representation data. |
| Name | Type | Default | Description |
| ------- | ---------- | ------------------ | --------------------------------------------------------------------- |
| strong | `boolean` | `false` | Whether the validator is strong or not. |
| headers | `string[]` | `["content-type"]` | Semantically significant header related with the representation data. |
| digest | `Digest` | SHA-1 digest | Compute digest. |

### Strong

Expand Down Expand Up @@ -103,47 +103,58 @@ const response = await middleware(request, handler);
assertEquals(response.headers.get("etag"), `"<hex:SHA-1:Content-Type::body>"`);
```

### Algorithm
### Headers

Additional metadata to uniquely identify representation data.

Default is `["content-type"]`.

The strong validator requires uniqueness to include metadata such as
`Content-Type` in addition to the body text.

Specifies the algorithm of the hash function. Default is `SHA-1`.
By adding a header, a hash value is computed from the stream of the body and the
specified header.

```ts
import { etag } from "https://deno.land/x/etag_middleware@$VERSION/mod.ts";
import { assertEquals } from "https://deno.land/std/testing/asserts.ts";

const middleware = etag({ algorithm: "SHA-256" });
const middleware = etag({ headers: [] });
declare const request: Request;
declare const handler: () => Response;

const response = await middleware(request, handler);
assertEquals(
response.headers.get("etag"),
`W/"<hex:SHA-256:Content-Type::body>"`,
);
assertEquals(response.headers.get("etag"), `W/"<hex:SHA-256:body>"`);
```

### Headers

Additional metadata to uniquely identify representation data.
### Digest

Default is `["content-type"]`.
Specifies digest function. Default is `digestSha1`.

The strong validator requires uniqueness to include metadata such as
`Content-Type` in addition to the body text.
```ts
function digestSha1(data: ArrayBuffer): Promise<ArrayBuffer> {
return crypto.subtle.digest("SHA-1", data);
}
```

By adding a header, a hash value is computed from the stream of the body and the
specified header.
`data` is an `ArrayBuffer` that concatenates the headers specified in
[headers](#headers) and body.

```ts
import { etag } from "https://deno.land/x/etag_middleware@$VERSION/mod.ts";
import { assertEquals } from "https://deno.land/std/testing/asserts.ts";

const middleware = etag({ headers: [] });
const middleware = etag({
digest: (data) => crypto.subtle.digest("SHA-256", data),
});
declare const request: Request;
declare const handler: () => Response;

const response = await middleware(request, handler);
assertEquals(response.headers.get("etag"), `W/"<hex:SHA-256:body>"`);
assertEquals(
response.headers.get("etag"),
`W/"<hex:SHA-256:Content-Type::body>"`,
);
```

## Effects
Expand Down
1 change: 0 additions & 1 deletion _dev_deps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,5 @@ export {
assertSpyCalls,
spy,
} from "https://deno.land/std@0.180.0/testing/mock.ts";
export { distinct } from "https://deno.land/std@0.180.0/collections/distinct.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";
13 changes: 5 additions & 8 deletions middleware_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {
RepresentationHeader,
} from "./_dev_deps.ts";

const opaqueTag = `"389222d4f438ac48dac58e8594ab32279bf5ed4b"`;
const opaqueTag = `"6b4f9b34cb268809e4863aea74e2c81b54c847dc"`;

describe("etag", () => {
it("should return response what include etag header", async () => {
Expand All @@ -31,7 +31,7 @@ describe("etag", () => {
});

it("should change to strong validator", async () => {
const middleware = etag({ weak: false });
const middleware = etag({ strong: true });
const response = await middleware(
new Request("test:"),
() => new Response(""),
Expand All @@ -48,21 +48,18 @@ describe("etag", () => {
);
});

it("should change hash algorithm", async () => {
const middleware = etag({ algorithm: "SHA-256" });
it("should change digest function", async () => {
const middleware = etag({ digest: () => new ArrayBuffer(0) });
const response = await middleware(
new Request("test:"),
() => new Response(""),
);

const opaqueTag =
`"186c92e7522aa3bb7bc51063c389b3b53e34d89ae00aa41e80956144bc188f5e"`;

assert(
await equalsResponse(
response,
new Response("", {
headers: { [RepresentationHeader.ETag]: "W/" + opaqueTag },
headers: { [RepresentationHeader.ETag]: `W/""` },
}),
true,
),
Expand Down
2 changes: 1 addition & 1 deletion mod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,4 @@

export { type Handler, type Middleware } from "./deps.ts";
export { etag } from "./middleware.ts";
export type { Algorithm, ETagStrategy } from "./types.ts";
export type { ETagStrategy } from "./types.ts";
18 changes: 10 additions & 8 deletions types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,21 +10,23 @@ export interface ComputeETag {

/** ETag computation strategy. */
export interface ETagStrategy {
/** Wether the etag is weak or not.
* @default true
/** Whether the validator is strong or not.
* @default false
*/
readonly weak: boolean;
readonly strong: boolean;

/** Hash algorithm.
* @default `SHA-1`
/** Compute digest.
* The default is SHA-1 digest.
*/
readonly algorithm: Algorithm;
readonly digest: Digest;

/** Semantically significant header related with the representation data.
* @default ["content-type"]
*/
readonly headers: readonly string[];
}

/** Hash algorithm */
export type Algorithm = "SHA-1" | "SHA-256" | "SHA-384" | "SHA-512";
/** Compute digest API. */
export interface Digest {
(data: ArrayBuffer): ArrayBuffer | Promise<ArrayBuffer>;
}
14 changes: 10 additions & 4 deletions utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,13 @@ const enum FailBy {
CalcHashString = "fail to calculate hash string",
}

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

export const DefaultStrategy: ETagStrategy = {
weak: true,
algorithm: "SHA-1",
strong: false,
digest: digestSha1,
headers: [RepresentationHeader.ContentType],
};

Expand All @@ -38,22 +42,24 @@ export async function computeETagByStrategy(
): Promise<ETag> {
const filteredHeaders = filterHeaders(response.headers, strategy.headers);
const headersStr = stringifyHeaders(filteredHeaders);
const hasHeader = !!headersStr;

const buffer = await response
.clone()
.arrayBuffer()
.catch(reason(FailBy.Fetch));
const data = concat(
new TextEncoder().encode(headersStr),
...hasHeader ? [new TextEncoder().encode("\n\n")] : [],
new Uint8Array(buffer),
);

const tag = await crypto.subtle.digest(strategy.algorithm, data)
const tag = await Promise.resolve(strategy.digest(data.buffer))
.catch(reason(FailBy.CalcHash))
.then(toHashString)
.catch(reason(FailBy.CalcHashString));

return { weak: strategy.weak, tag };
return { weak: !strategy.strong, tag };
}

export function strategy2ComputeETag(strategy: ETagStrategy): ComputeETag {
Expand Down
64 changes: 42 additions & 22 deletions utils_test.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,16 @@
import { ETagStrategy } from "./types.ts";
import {
computeETagByStrategy,
filterHeaders,
reason,
stringifyHeaders,
} from "./utils.ts";
import {
assert,
assertEquals,
assertSpyCalls,
assertThrows,
describe,
distinct,
it,
spy,
} from "./_dev_deps.ts";

describe("reason", () => {
Expand Down Expand Up @@ -87,29 +86,50 @@ describe("filterHeaders", () => {
});

describe("computeETagByStrategy", () => {
it("should return unique hash tag", async () => {
const table: [Response, ETagStrategy | undefined][] = [
[new Response(""), undefined],
[new Response(), undefined],
[new Response("a"), undefined],
[new Response("a"), { headers: [], algorithm: "SHA-1", weak: false }],
[new Response("a"), { headers: [], algorithm: "SHA-256", weak: false }],
[new Response("a"), { headers: [], algorithm: "SHA-384", weak: false }],
[new Response("a"), { headers: [], algorithm: "SHA-512", weak: true }],
];
it("should pass body only if the header is none", async () => {
const digest = spy((data: ArrayBuffer) => {
assertEquals(
new TextDecoder().decode(data),
"a",
);

return new ArrayBuffer(0);
});

const results = await Promise.all(
table.map(async ([response, strategy]) => {
const result = await computeETagByStrategy(response, strategy);
const etag = await computeETagByStrategy(new Response("a"), {
digest,
strong: false,
headers: [],
});

assertSpyCalls(digest, 1);
assertEquals(etag, { weak: true, tag: "" });
});

assertEquals(result.weak, strategy?.weak ?? true);
it("should pass header and body stream", async () => {
const digest = spy((data: ArrayBuffer) => {
assertEquals(
new TextDecoder().decode(data),
"content-type: text/plain;charset=UTF-8\n\na",
);

return result;
}),
);
return new ArrayBuffer(0);
});

const etag = await computeETagByStrategy(new Response("a"), {
digest,
strong: true,
headers: ["content-type"],
});

assertSpyCalls(digest, 1);
assertEquals(etag, { weak: false, tag: "" });
});

const tags = results.map(({ tag }) => tag);
it("should pass header and body stream by default", async () => {
const etag = await computeETagByStrategy(new Response("abc"));
const etagc = "52d3e27d9e12c76aa045b5d72bab675df54df141";

assert(distinct(tags).length === results.length);
assertEquals(etag, { weak: true, tag: etagc });
});
});

0 comments on commit 813aad5

Please sign in to comment.