Skip to content

Commit

Permalink
ability to return multiple content types / bodies
Browse files Browse the repository at this point in the history
Counterfact will use the one that best matches the request's accept header.

If there are no matches it will return a 406 error.
  • Loading branch information
pmcelhaney committed Aug 3, 2022
1 parent 4140581 commit 25dcd45
Show file tree
Hide file tree
Showing 7 changed files with 222 additions and 16 deletions.
5 changes: 5 additions & 0 deletions .changeset/modern-ducks-taste.md
@@ -0,0 +1,5 @@
---
"counterfact": minor
---

ability to respond with multiple content types, return the best match for the request's accept header
1 change: 1 addition & 0 deletions .eslintrc.cjs
Expand Up @@ -83,6 +83,7 @@ module.exports = {

"no-magic-numbers": ["off"],
"id-length": ["off"],
"max-lines": "off",
},
},

Expand Down
22 changes: 10 additions & 12 deletions docs/usage.md
Expand Up @@ -6,8 +6,6 @@ These features do not depend on TypeScript or the code generator. In order to ge

## Hello World

[ Depends on #https://github.com/pmcelhaney/counterfact/issues/132 and https://github.com/pmcelhaney/counterfact/issues/128 ]

To get started, create a directory called `paths` and under that a file called `hello.js`.

```js
Expand Down Expand Up @@ -426,7 +424,7 @@ The `GET()` and `POST()` functions are now simplified. The business logic has be

## Multiple Content Types

Sometimes an endpoint can return a response body with one of multiple content types. Which one is sent depends on which ones the client reports in its `Accepts:` header.
Sometimes an endpoint can return a response body with one of multiple content types. It returns the best match per the client's `Accept:` header.

```js
export function GET({ path, query }) {
Expand All @@ -439,20 +437,20 @@ export function GET({ path, query }) {
"X-Unit": query.unit,
},

contentType: [
[
"application/json",
{
content: [
{
type: "application/json",
body: {
city: path.city,
temperature: context.getTemperature(path.city, query.unit),
},
],
[
"text/plain",
`It is ${context.getTemperature(path.city, query.unit)} in ${
},
{
type: "text/plain",
body: `It is ${context.getTemperature(path.city, query.unit)} in ${
path.city
}.`,
],
},
],
};
}
Expand Down
1 change: 1 addition & 0 deletions package.json
Expand Up @@ -53,6 +53,7 @@
"supertest": "6.2.4"
},
"dependencies": {
"@hapi/accept": "^6.0.0",
"@types/json-schema": "^7.0.11",
"chokidar": "^3.5.3",
"fs-extra": "^10.1.0",
Expand Down
79 changes: 76 additions & 3 deletions src/dispatcher.js
@@ -1,3 +1,5 @@
import Accept from "@hapi/accept";

import { Tools } from "./tools.js";

export class Dispatcher {
Expand All @@ -8,7 +10,7 @@ export class Dispatcher {
}

request({ method, path, headers, body, query }) {
return this.wrapResponse(
const normalizedResponse = this.normalizeResponse(
this.registry.endpoint(
method,
path
Expand All @@ -19,11 +21,22 @@ export class Dispatcher {
body,
query,
headers,
})
}),
headers?.accept ?? "*/*"
);

if (
!Accept.mediaTypes(headers?.accept ?? "*/*").some((type) =>
this.isMediaType(normalizedResponse.contentType, type)
)
) {
return { status: 406, body: Accept.mediaTypes(headers?.accept ?? "*/*") };
}

return normalizedResponse;
}

wrapResponse(response) {
normalizeResponse(response, acceptHeader) {
if (typeof response === "string") {
return {
status: 200,
Expand All @@ -33,6 +46,66 @@ export class Dispatcher {
};
}

if (response.content) {
const content = this.selectContent(acceptHeader, response.content);

if (!content) {
return {
status: 406,
};
}

const normalizedResponse = {
...response,
contentType: content.type,
body: content.body,
content: undefined,
};

delete normalizedResponse.content;

return normalizedResponse;
}

return response;
}

selectContent(acceptHeader, content) {
const preferredMediaTypes = Accept.mediaTypes(acceptHeader);

for (const mediaType of preferredMediaTypes) {
const contentItem = content.find((item) =>
this.isMediaType(item.type, mediaType)
);

if (contentItem) {
return contentItem;
}
}

return {
type: preferredMediaTypes,
body: "no match",
};
}

isMediaType(type, pattern) {
if (pattern === "*/*") {
return true;
}

const [baseType, subType] = type.split("/");

const [patternType, patternSubType] = pattern.split("/");

if (baseType === patternType) {
return subType === patternSubType || patternSubType === "*";
}

if (subType === patternSubType) {
return baseType === patternType || patternType === "*";
}

return false;
}
}
110 changes: 109 additions & 1 deletion test/dispatcher.test.js
Expand Up @@ -45,6 +45,114 @@ describe("a dispatcher", () => {
});
});

it("finds the best content item for an accept header", () => {
const dispatcher = new Dispatcher();

const html = dispatcher.selectContent("text/html", [
{
type: "text/plain",
body: "hello",
},
{
type: "text/html",
body: "<h1>hello</h1>",
},
]);

expect(html.type).toBe("text/html");
});

it("returns HTTP 406 if it can't return content matching the accept header", async () => {
const registry = new Registry();

registry.add("/hello", {
GET() {
return {
status: 200,
contentType: "text/plain",
body: "I am not JSON",
};
},
});

const dispatcher = new Dispatcher(registry);
const response = await dispatcher.request({
method: "GET",
path: "/hello",

headers: {
accept: "application/json",
},
});

expect(response.status).toBe(406);
});

it.each([
["text/html", ["text/html"], "text/html"],
["text/plain", ["text/html", "text/plain"], "text/plain"],
["text/*", ["text/html", "text/plain"], "text/html"],
["*/json", ["text/html", "application/json"], "application/json"],
[
"*/html;q=0.1,*/json;q=0.2",
["text/html", "application/json"],
"application/json",
],
])(
'given accept header "%s" and content types: %s, select %s',
(acceptHeader, types, expected) => {
const dispatcher = new Dispatcher();

const content = types.map((type) => ({ type }));

expect(dispatcher.selectContent(acceptHeader, content).type).toBe(
expected
);
}
);

it("selects the best content item matching the Accepts header", async () => {
const registry = new Registry();

registry.add("/hello", {
GET() {
return {
status: 200,

headers: {},

content: [
{
type: "text/plain",
body: "Hello, world!",
},
{
type: "text/html",
body: "<h1>Hello, world!</h1>",
},
],
};
},
});

const dispatcher = new Dispatcher(registry);
const html = await dispatcher.request({
method: "GET",
path: "/hello",

headers: {
accept: "text/html",
},
});

expect(html).toStrictEqual({
status: 200,
headers: {},
contentType: "text/html",
body: "<h1>Hello, world!</h1>",
});
});

it("passes the request body", async () => {
const registry = new Registry();

Expand Down Expand Up @@ -212,7 +320,7 @@ describe("a dispatcher", () => {
});
});

describe("given a in invalid path", () => {
describe("given an invalid path", () => {
it("returns a 404 when the route is not found", () => {
const registry = new Registry();

Expand Down
20 changes: 20 additions & 0 deletions yarn.lock
Expand Up @@ -642,6 +642,26 @@
minimatch "^3.1.2"
strip-json-comments "^3.1.1"

"@hapi/accept@^6.0.0":
version "6.0.0"
resolved "https://registry.yarnpkg.com/@hapi/accept/-/accept-6.0.0.tgz#360d6a12c7597489b19ad7830b41e3c38fe8c8c4"
integrity sha512-aG/Ml4kSBWCVmWvR8N8ULRuB385D8K/3OI7lquZQruH11eM7sHR5Nha30BbDzijJHtyV7Vwc6MlMwNfwb70ISg==
dependencies:
"@hapi/boom" "^10.0.0"
"@hapi/hoek" "^10.0.0"

"@hapi/boom@^10.0.0":
version "10.0.0"
resolved "https://registry.yarnpkg.com/@hapi/boom/-/boom-10.0.0.tgz#3624831d0a26b3378423b246f50eacea16e04a08"
integrity sha512-1YVs9tLHhypBqqinKQRqh7FUERIolarQApO37OWkzD+z6y6USi871Sv746zBPKcIOBuI6g6y4FrwX87mmJ90Gg==
dependencies:
"@hapi/hoek" "10.x.x"

"@hapi/hoek@10.x.x", "@hapi/hoek@^10.0.0":
version "10.0.1"
resolved "https://registry.yarnpkg.com/@hapi/hoek/-/hoek-10.0.1.tgz#ee9da297fabc557e1c040a0f44ee89c266ccc306"
integrity sha512-CvlW7jmOhWzuqOqiJQ3rQVLMcREh0eel4IBnxDx2FAcK8g7qoJRQK4L1CPBASoCY6y8e6zuCy3f2g+HWdkzcMw==

"@html-eslint/eslint-plugin@^0.13.2":
version "0.13.2"
resolved "https://registry.yarnpkg.com/@html-eslint/eslint-plugin/-/eslint-plugin-0.13.2.tgz#5e7e6242b912c740467266671026e06019325249"
Expand Down

0 comments on commit 25dcd45

Please sign in to comment.