Skip to content

Commit 8eee83d

Browse files
committed
WIP
1 parent 438a129 commit 8eee83d

File tree

9 files changed

+764
-269
lines changed

9 files changed

+764
-269
lines changed

.changeset/cool-steaks-lay.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"openapi-fetch": minor
3+
---
4+
5+
Add middleware support

docs/data/contributors.json

+1-1
Large diffs are not rendered by default.

docs/openapi-fetch/about.md

+14-3
Original file line numberDiff line numberDiff line change
@@ -18,17 +18,28 @@ description: openapi-fetch Project Goals, comparisons, and more
1818

1919
## Differences
2020

21+
### vs. Axios
22+
23+
[Axios](https://axios-http.com) doesn’t automatically typecheck against your OpenAPI schema. Further, there’s no easy way to do that. Axios does have more features than openapi-fetch such as request/responce interception and cancellation.
24+
25+
### vs. tRPC
26+
27+
[tRPC](https://trpc.io/) is meant for projects where both the backend and frontend are written in TypeScript (Node.js). openapi-fetch is universal, and can work with any backend that follows an OpenAPI 3.x schema.
28+
2129
### vs. openapi-typescript-fetch
2230

23-
This library is identical in purpose to [openapi-typescript-fetch](https://github.com/ajaishankar/openapi-typescript-fetch), but has the following differences:
31+
[openapi-typescript-fetch](https://github.com/ajaishankar/openapi-typescript-fetch) predates openapi-fetch, and is nearly identical in purpos, but differs mostly in syntax (so it’s more of an opinionated choice):
2432

2533
- This library has a built-in `error` type for `3xx`/`4xx`/`5xx` errors whereas openapi-typescript-fetch throws exceptions (requiring you to wrap things in `try/catch`)
2634
- This library has a more terse syntax (`get(…)`) wheras openapi-typescript-fetch requires chaining (`.path(…).method(…).create()`)
27-
- openapi-typescript-fetch supports middleware whereas this library doesn’t
2835

2936
### vs. openapi-typescript-codegen
3037

31-
This library is quite different from [openapi-typescript-codegen](https://github.com/ferdikoomen/openapi-typescript-codegen)
38+
[openapi-typescript-codegen](https://github.com/ferdikoomen/openapi-typescript-codegen) is a codegen library, which is fundamentally different from openapi-fetch’s “no codegen” approach. openapi-fetch uses static TypeScript typechecking that all happens at build time with no client weight and no performance hit to runtime. Traditional codegen generates hundreds (if not thousands) of different functions that all take up client weight and slow down runtime.
39+
40+
### vs. Swagger Codegen
41+
42+
Swagger Codegen is the original codegen project for Swagger/OpenAPI, and has the same problems of other codgen approaches of size bloat and runtime performance problems. Further, Swagger Codegen require the Java runtime to work, whereas openapi-typescript/openapi-fetch don’t as native Node.js projects.
3243

3344
## Contributors
3445

docs/openapi-fetch/api.md

+98
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ client.get("/my-url", options);
3737
| `bodySerializer` | BodySerializer | (optional) Provide a [bodySerializer](#bodyserializer) |
3838
| `parseAs` | `"json"` \| `"text"` \| `"arrayBuffer"` \| `"blob"` \| `"stream"` | (optional) Parse the response using [a built-in instance method](https://developer.mozilla.org/en-US/docs/Web/API/Response#instance_methods) (default: `"json"`). `"stream"` skips parsing altogether and returns the raw stream. |
3939
| `fetch` | `fetch` | Fetch instance used for requests (default: fetch from `createClient`) |
40+
| `middleware` | `Middleware[]` | [See docs](#middleware) |
4041
| (Fetch options) | | Any valid fetch option (`headers`, `mode`, `cache`, `signal`, …) ([docs](https://developer.mozilla.org/en-US/docs/Web/API/fetch#options)) |
4142

4243
### querySerializer
@@ -83,3 +84,100 @@ const { data, error } = await PUT("/submit", {
8384
},
8485
});
8586
```
87+
88+
## Middleware
89+
90+
As of `0.9.0` this library supports lightweight middleware. Middleware allows you to modify either the request, response, or both for all fetches.
91+
92+
You can declare middleware as an array of functions on [createClient](#create-client). Each middleware function will be **called twice**—once for the request, then again for the response. On request, they’ll be called in array order. On response, they’ll be called in reverse-array order. That way the first middleware gets the first “dibs” on request, and the final control over responses.
93+
94+
Within your middleware function, you’ll either need to check for `req` (request) or `res` (response) to handle each pass appropriately:
95+
96+
```ts
97+
createClient({
98+
middleware: [
99+
async function myMiddleware({
100+
req, // request (undefined for responses)
101+
res, // response (undefined for requests)
102+
options, // all options passed to openapi-fetch
103+
}) {
104+
if (req) {
105+
return new Request(req.url, {
106+
...req,
107+
headers: { ...req.headers, foo: "bar" },
108+
});
109+
} else if (res) {
110+
return new Response({
111+
...res,
112+
status: 200,
113+
});
114+
}
115+
},
116+
],
117+
});
118+
```
119+
120+
### Request pass
121+
122+
The request pass of each middleware provides `req` that’s a standard [Request](https://developer.mozilla.org/en-US/docs/Web/API/Request) instance, but has 2 additional properties:
123+
124+
| Name | Type | Description |
125+
| :----------- | :------: | :--------------------------------------------------------------- |
126+
| `schemaPath` | `string` | The OpenAPI pathname called (e.g. `/projects/{project_id}`) |
127+
| `params` | `Object` | The [params](#fetch-options) fetch option provided by the client |
128+
129+
### Response pass
130+
131+
The response pass returns a standard [Response](https://developer.mozilla.org/en-US/docs/Web/API/Response) instance with no modifications.
132+
133+
### Skipping middleware
134+
135+
If you want to skip the middleware under certain conditions, just `return` as early as possible:
136+
137+
```ts
138+
async function myMiddleware({ req }) {
139+
if (req.schemaPath !== "/projects/{project_id}") {
140+
return;
141+
}
142+
143+
//
144+
}
145+
```
146+
147+
This will leave the request/response unmodified, and pass things off to the next middleware handler (if any). There’s no internal callback or observer library needed.
148+
149+
### Handling statefulness
150+
151+
When using middleware, it’s important to remember 2 things:
152+
153+
- **Create new instances** when modifying (e.g. `new Response()`)
154+
- **Clone bodies** before accessing (e.g. `res.clone().json()`)
155+
156+
This is to account for the fact responses are [stateful](https://developer.mozilla.org/en-US/docs/Web/API/Response/bodyUsed), and if the stream is consumed in middleware [the client will throw an error](https://developer.mozilla.org/en-US/docs/Web/API/Response/clone).
157+
158+
<!-- prettier-ignore -->
159+
```ts
160+
async function myMiddleware({ req, res }) {
161+
// Example 1: modifying request
162+
if (req) {
163+
res.headers.foo = "bar"; // [!code --]
164+
return new Request(req.url, { // [!code ++]
165+
...req, // [!code ++]
166+
headers: { ...req.headers, foo: "bar" }, // [!code ++]
167+
}); // [!code ++]
168+
}
169+
170+
// Example 2: accessing response
171+
if (res) {
172+
const data = await res.json(); // [!code --]
173+
const data = await res.clone().json(); // [!code ++]
174+
}
175+
}
176+
```
177+
178+
### Other notes
179+
180+
- `querySerializer()` runs _before_ middleware
181+
- This is to save middleware from having to do annoying URL formatting. But remember middleware can access `req.params`
182+
- `bodySerializer()` runs _after_ middleware
183+
- There is some overlap with `bodySerializer()` and middleware. Probably best to use one or the other; not both together.

docs/scripts/update-contributors.js

+5-4
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,11 @@ async function main() {
182182
lastFetch: new Date().getTime(),
183183
};
184184
upsert(contributors[repo], userData);
185+
// write after every update (so failures are resumable)
186+
fs.writeFileSync(
187+
new URL("../data/contributors.json", import.meta.url),
188+
JSON.stringify(contributors),
189+
);
185190
console.log(`Updated old contributor data for ${username}`); // eslint-disable-line no-console
186191
fs.writeFileSync(
187192
new URL("../data/contributors.json", import.meta.url),
@@ -193,10 +198,6 @@ async function main() {
193198
}
194199
}),
195200
);
196-
fs.writeFileSync(
197-
new URL("../data/contributors.json", import.meta.url),
198-
JSON.stringify(contributors),
199-
);
200201
}
201202

202203
main();

packages/openapi-fetch/src/index.d.ts

+39
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ export interface ClientOptions extends Omit<RequestInit, "headers"> {
2323
querySerializer?: QuerySerializer<unknown>;
2424
/** global bodySerializer */
2525
bodySerializer?: BodySerializer<unknown>;
26+
/** middlewares */
27+
middleware?: Middleware[];
2628
headers?: HeadersOptions;
2729
}
2830

@@ -104,6 +106,43 @@ export type RequestOptions<T> = ParamsOption<T> &
104106
fetch?: ClientOptions["fetch"];
105107
};
106108

109+
export type MergedOptions<T = unknown> = {
110+
baseUrl: string;
111+
parseAs: ParseAs;
112+
querySerializer: QuerySerializer<T>;
113+
bodySerializer: BodySerializer<T>;
114+
fetch: typeof globalThis.fetch;
115+
};
116+
117+
export type MiddlewareRequestPayload = {
118+
type: "request";
119+
req: Request & {
120+
/** The original OpenAPI schema path (including curly braces) */
121+
schemaPath: string;
122+
/** OpenAPI parameters as provided from openapi-fetch */
123+
params: {
124+
query?: Record<string, unknown>;
125+
header?: Record<string, unknown>;
126+
path?: Record<string, unknown>;
127+
cookie?: Record<string, unknown>;
128+
};
129+
};
130+
res?: never;
131+
options: readonly MergedOptions;
132+
};
133+
export type MiddlewareResponsePayload = {
134+
type: "response";
135+
req?: never;
136+
res: Response;
137+
options: readonly MergedOptions;
138+
};
139+
export type MiddlewarePayload =
140+
| MiddlewareRequestPayload
141+
| MiddlewareResponsePayload;
142+
export type Middleware = (
143+
payload: MiddlewarePayload,
144+
) => Promise<Request | Response | undefined> | Promise<void>;
145+
107146
export default function createClient<Paths extends {}>(
108147
clientOptions?: ClientOptions,
109148
): {

packages/openapi-fetch/src/index.js

+73-27
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ export default function createClient(clientOptions) {
1212
fetch: baseFetch = globalThis.fetch,
1313
querySerializer: globalQuerySerializer,
1414
bodySerializer: globalBodySerializer,
15+
middleware,
1516
...baseOptions
1617
} = clientOptions ?? {};
1718
let baseUrl = baseOptions.baseUrl ?? "";
@@ -25,48 +26,93 @@ export default function createClient(clientOptions) {
2526
* @param {import('./index.js').FetchOptions<T>} fetchOptions
2627
*/
2728
async function coreFetch(url, fetchOptions) {
28-
const {
29+
let {
2930
fetch = baseFetch,
3031
headers,
31-
body: requestBody,
3232
params = {},
3333
parseAs = "json",
3434
querySerializer = globalQuerySerializer ?? defaultQuerySerializer,
3535
bodySerializer = globalBodySerializer ?? defaultBodySerializer,
3636
...init
3737
} = fetchOptions || {};
3838

39-
// URL
40-
const finalURL = createFinalURL(url, {
41-
baseUrl,
42-
params,
43-
querySerializer,
44-
});
45-
const finalHeaders = mergeHeaders(
46-
DEFAULT_HEADERS,
47-
clientOptions?.headers,
48-
headers,
49-
params.header,
39+
let request = new Request(
40+
createFinalURL(url, { baseUrl, params, querySerializer }),
41+
{
42+
redirect: "follow",
43+
...baseOptions,
44+
...init,
45+
headers: mergeHeaders(
46+
DEFAULT_HEADERS,
47+
clientOptions?.headers,
48+
headers,
49+
params.header,
50+
),
51+
},
5052
);
5153

52-
// fetch!
53-
/** @type {RequestInit} */
54-
const requestInit = {
55-
redirect: "follow",
56-
...baseOptions,
57-
...init,
58-
headers: finalHeaders,
54+
// middleware (request)
55+
const mergedOptions = {
56+
baseUrl,
57+
fetch,
58+
parseAs,
59+
querySerializer,
60+
bodySerializer,
5961
};
60-
61-
if (requestBody) {
62-
requestInit.body = bodySerializer(requestBody);
62+
if (Array.isArray(middleware)) {
63+
for (const m of middleware) {
64+
const req = new Request(request.url, request);
65+
req.schemaPath = url; // (re)attach original URL
66+
req.params = params; // (re)attach params
67+
const result = await m({
68+
type: "request",
69+
req,
70+
options: Object.freeze({ ...mergedOptions }),
71+
});
72+
if (result) {
73+
if (!(result instanceof Request)) {
74+
throw new Error(
75+
`Middleware must return new Request() when modifying the request`,
76+
);
77+
}
78+
request = result;
79+
}
80+
}
6381
}
82+
83+
// fetch!
84+
// if (init.body) {
85+
// request = new Request(request.url, {
86+
// ...request,
87+
// body: bodySerializer(init.body),
88+
// });
89+
// }
6490
// remove `Content-Type` if serialized body is FormData; browser will correctly set Content-Type & boundary expression
65-
if (requestInit.body instanceof FormData) {
66-
finalHeaders.delete("Content-Type");
67-
}
91+
// if (request.body instanceof FormData) {
92+
// request.headers.delete("Content-Type");
93+
// }
6894

69-
const response = await fetch(finalURL, requestInit);
95+
let response = await fetch(request);
96+
97+
// middleware (response)
98+
if (Array.isArray(middleware)) {
99+
// execute in reverse-array order (first priority gets last transform)
100+
for (let i = middleware.length - 1; i >= 0; i--) {
101+
const result = await middleware[i]({
102+
type: "response",
103+
res: response,
104+
options: Object.freeze({ ...mergedOptions }),
105+
});
106+
if (result) {
107+
if (!(result instanceof Response)) {
108+
throw new Error(
109+
`Middleware must return new Response() when modifying the response`,
110+
);
111+
}
112+
response = result;
113+
}
114+
}
115+
}
70116

71117
// handle empty content
72118
// note: we return `{}` because we want user truthy checks for `.data` or `.error` to succeed

0 commit comments

Comments
 (0)