-
Notifications
You must be signed in to change notification settings - Fork 1
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Initial commit for the fetch-hooks package
- Loading branch information
1 parent
0fea66c
commit dd2c010
Showing
17 changed files
with
523 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
|
||
``` |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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" | ||
}) | ||
|
||
}) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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()")) | ||
}) | ||
|
||
}) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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}" | ||
}) | ||
|
||
}) | ||
}) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
export { default } from "../../config/babel.config.mjs"; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
module.exports = require('../../config/jest.config.js'); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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" | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
export { default } from "../../config/rollup.config.mjs"; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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))); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} |
Oops, something went wrong.