Skip to content

Commit

Permalink
Initial commit for the fetch-hooks package
Browse files Browse the repository at this point in the history
  • Loading branch information
vierbergenlars committed Mar 28, 2024
1 parent 0fea66c commit dd2c010
Show file tree
Hide file tree
Showing 17 changed files with 523 additions and 0 deletions.
66 changes: 66 additions & 0 deletions packages/fetch-hooks/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# `@contentgrid/fetch-hooks`

Insert hooks around `fetch()`, to centralize cross-functional behaviors.

## Usage

Compose cross-functional hooks around `fetch()`, without creating a custom client wrapping fetch.

Automatically adding an additional header to all requests is as easy as:

```typescript
import { setHeader } from "@contentgrid/fetch-hooks/request";

const exampleAuthorizationHook = setHeader("Authorization", ({request}) => {
if(request.url.startsWith("https://example.com/")) {
return "Bearer my-bearer-token"
}
return null; // Do not set the header
});

const myFetch = exampleAuthorizationHook(fetch);


myFetch("https://example.com/abc") // Authorization header automatically added
.then([...]);

myFetch("https://example.org/zzzz") // Different domain, no Authorization header added
.then([...]);

```

## Writing hooks

Next to the built-in hooks, it is also possible to create your own hooks.

```typescript
import createFetchHook from "@contentgrid/fetch-hooks";

const requireJsonHook = createFetchHook(({request, next}) => {

// Do something fun with the request, before sending it off to the next hook
// For example, setting an accept header when we have none
if(!request.headers.has("accept")) {
request.headers.set("accept", "application/json, application/*+json;q=0.9, */*;q=0.1")
}

const response = await next(); // Forward the modified request to the next hook

// Do something evil with the response before returning it
// For example, rejecting non-json responses
const contentType = response.headers.get("content-type");
if(contentType !== "application/json" && !contentType.matches(/^application\/[^+]+\+json$/)) {
throw new Error("We wants a JSON content-type");
}

return response;
});

const myFetch = compose(
requireJsonHook,
exampleAuthorizationHook
)(fetch);

// Go on to fetch your data

```
35 changes: 35 additions & 0 deletions packages/fetch-hooks/__tests__/compose.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import fetchMock from 'fetch-mock';
import { test, expect } from "@jest/globals";
import type { Fetch } from '../src/hook/core';
import { compose } from '../src/compose';
import { appendHeader } from '../src/request';

test("compose", async () => {
const fakeFetch = fetchMock.sandbox();
global.Request = fakeFetch.config.Request as any;

const testHeader = "X-Test";

fakeFetch.get("http://localhost/", async (_url, {headers}) => {
const h = new Headers(headers);
return {
test: h.get(testHeader)
}
})

const fetchHook = compose(
appendHeader(testHeader, "1"),
appendHeader(testHeader, "2"),
appendHeader(testHeader, "3")
);

const hookedFetch = fetchHook(fakeFetch as Fetch);

const response = await hookedFetch("http://localhost/");

expect(response.ok).toBe(true);
expect(await response.json()).toEqual({
test: "1,2,3"
})

})
100 changes: 100 additions & 0 deletions packages/fetch-hooks/__tests__/hook.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import fetchMock from 'fetch-mock';
import { test, expect, describe } from "@jest/globals";
import type { Fetch } from '../src/hook/core';
import createHook from '../src/hook';
import { DuplicateInvocationError } from '../src/hook/invocation';

describe("hook", () => {
const fakeFetch = fetchMock.sandbox();
global.Request = fakeFetch.config.Request as any;

fakeFetch.post("http://localhost/length", async (_url, {headers, body}) => {
const h = new Headers(headers)
if(!h.has("X-Loopback")) {
return 402;
}
try {
// This is fetchMock being annoying and having mismatched types between what body actually is, and what it pretends to be
const b = JSON.parse((await body) as string);
return {
s: b.s,
length: b.s.length
}
} catch (e) {
console.error(e);
return 400;
}
})

test("simple hook", async () => {
const fetchHook = createHook(({ request, next }) => {
if (request.url.startsWith("http://localhost/")) {
request.headers.set("X-Loopback", "true")
}
return next();
});

const hookedFetch = fetchHook(fakeFetch as Fetch);


const unhookedResponse = await fakeFetch("http://localhost/length", {
method: "POST",
body: JSON.stringify({ s: "def" })
});

expect(unhookedResponse.status).toEqual(402);

const hookedResponse = await hookedFetch("http://localhost/length", {
method: "POST",
body: JSON.stringify({ s: "def" })
});

expect(hookedResponse.status).toEqual(200);

})

test("sending a new request", async () => {
const rewriteHook = createHook(({ request, next }) => {
const newRequest = new Request("http://localhost/length", {
headers: {
"X-Loopback": "true"
},
method: "POST",
body: request.body
})
return next(newRequest);
});

const hookedFetch = rewriteHook(fakeFetch as Fetch);

const hookedResponse = await hookedFetch("http://example.com/abc", {
method: "POST",
body: JSON.stringify({ s: "def" })
});

expect(hookedResponse.status).toEqual(200);
})

test("calling next() multiple times is forbidden", () => {

const brokenHook = createHook(async ({ next }) => {
const response = await next();
if(!response.ok) {
return await next();
}
return response;
});

const hookedFetch = brokenHook(fakeFetch as Fetch);

const hookedResponsePromise = hookedFetch("http://localhost/length", {
method: "POST",
body: JSON.stringify({ s: "def" })
});

expect(hookedResponsePromise)
.rejects
.toThrowError(new DuplicateInvocationError("FetchHookInvocation#next()"))
})

})
53 changes: 53 additions & 0 deletions packages/fetch-hooks/__tests__/request.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import fetchMock from 'fetch-mock';
import { describe, test, expect } from "@jest/globals";
import { Fetch } from '../src/hook/core';
import { setHeader } from '../src/request';

describe("setHeader", () => {
const fakeFetch = fetchMock.sandbox();
global.Request = fakeFetch.config.Request as any;

const testHeader = "X-Test";

fakeFetch.get("http://localhost/", async (_url, { headers }) => {
const h = new Headers(headers);
return {
test: h.get(testHeader)
}
})

test("direct value", async () => {

const hookedFetch = setHeader(testHeader, "1")(fakeFetch as Fetch);

const response = await hookedFetch("http://localhost/");

expect(response.ok).toBe(true);
expect(await response.json()).toEqual({
test: "1"
})
})

test("value derived from request", async () => {
const hookedFetch = setHeader(testHeader, ({ request }) => request.url)(fakeFetch as Fetch);

const response = await hookedFetch("http://localhost/");

expect(response.ok).toBe(true);
expect(await response.json()).toEqual({
test: "http://localhost/"
})
})

test("value fetched with nested fetch", async () => {
const hookedFetch = setHeader(testHeader, async ({ request }) => (await fakeFetch(request)).text())(fakeFetch as Fetch);

const response = await hookedFetch("http://localhost/");

expect(response.ok).toBe(true);
expect(await response.json()).toEqual({
test: "{\"test\":null}"
})

})
})
1 change: 1 addition & 0 deletions packages/fetch-hooks/babel.config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default } from "../../config/babel.config.mjs";
1 change: 1 addition & 0 deletions packages/fetch-hooks/jest.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
module.exports = require('../../config/jest.config.js');
49 changes: 49 additions & 0 deletions packages/fetch-hooks/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
{
"name": "@contentgrid/fetch-hooks",
"version": "0.0.1-alpha.0",
"description": "Insert hooks before/after fetch()",
"keywords": ["fetch", "typescript", "hooks"],
"main": "./build/index.js",
"module": "./build/index.mjs",
"types": "./build/index.d.ts",
"exports": {
".": {
"types": "./build/index.d.ts",
"import": "./build/index.mjs",
"default": "./build/index.js"
},
"./request": {
"types": "./build/request.d.ts",
"import": "./build/request.mjs",
"default": "./build/request.js"
},
"./value-provider": {
"types": "./build/value-provider.d.ts",
"import": "./build/value-provider.mjs",
"default": "./build/value-provider.js"
}
},
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.22.10",
"tslib": "^2.6.1"
},
"scripts": {
"prepare": "rollup -c",
"test": "jest"
},
"publishConfig": {
"access": "public"
},
"files": [
"build"
],
"repository": {
"type": "git",
"url": "git+https://github.com/xenit-eu/contentgrid-ts.git",
"directory": "packages/fetch-hooks"
},
"devDependencies": {
"fetch-mock": "^9.11.0"
}
}
1 change: 1 addition & 0 deletions packages/fetch-hooks/rollup.config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default } from "../../config/rollup.config.mjs";
18 changes: 18 additions & 0 deletions packages/fetch-hooks/src/compose.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import createHook, { FetchHook } from "./hook";

/**
* A hook that does nothing and just proceeds
*/
const nopHook = createHook(({ next }) => next());


/**
* Combine multiple fetch hooks in one
* @param functions - Hooks to compose into one hook function
*/
export function compose(...functions: readonly FetchHook[]): FetchHook {
if(functions.length === 0) {
return nopHook;
}
return functions.reduce((prev, curr) => (f) => prev(curr(f)));
}
44 changes: 44 additions & 0 deletions packages/fetch-hooks/src/hook/core.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { FetchHookInvocation, FetchHookInvocationImpl } from "./invocation";

export type Fetch = typeof fetch;

/**
* Hook function that can be applied on `fetch()` to return a new, hooked `fetch()` function
*/
export interface FetchHook {
(fetch: Fetch | HookedFetch): HookedFetch;
};

/**
* Definition of a fetch hooking function
*/
export type FetchHookDefinition = (invocation: FetchHookInvocation) => Promise<Response>;


const isHookedFetch = Symbol("fetch-hooks: isHookedFetch");

/**
* The fetch function that has hooks
*/
export interface HookedFetch extends Fetch {
/**
* Distinguishes a hooked fetch from the plain fetch function, so they can be distinguished
*/
readonly [isHookedFetch]: true;
}

/**
* Creates a hook from a definition
* @param hookDefinition - Hook definition
* @returns Hook function that can be applied to `fetch()`
*/
export default function createHook(hookDefinition: FetchHookDefinition): FetchHook {
return next => markHookedFetch((...args: Parameters<HookedFetch>) => hookDefinition(new FetchHookInvocationImpl(args, next)));
}

type InitialSettable<T> = { -readonly [k in keyof T]?: T[k] };
function markHookedFetch(fetch: Fetch): HookedFetch {
const writableFetch = fetch as InitialSettable<HookedFetch> ;
writableFetch[isHookedFetch] = true;
return writableFetch as HookedFetch;
}
Loading

0 comments on commit dd2c010

Please sign in to comment.