Skip to content

Commit b174dd6

Browse files
committed
WIP
1 parent 21fb33c commit b174dd6

File tree

9 files changed

+1583
-277
lines changed

9 files changed

+1583
-277
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

+787-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
@@ -150,3 +151,100 @@ openapi-fetch supports path serialization as [outlined in the 3.1 spec](https://
150151
| `/users/{.id*}` | label (exploded) | `/users/.5` | `/users/.3.4.5` | `/users/.role=admin.firstName=Alex` |
151152
| `/users/{;id}` | matrix | `/users/;id=5` | `/users/;id=3,4,5` | `/users/;id=role,admin,firstName,Alex` |
152153
| `/users/{;id*}` | matrix (exploded) | `/users/;id=5` | `/users/;id=3;id=4;id=5` | `/users/;role=admin;firstName=Alex` |
154+
155+
## Middleware
156+
157+
As of `0.9.0` this library supports lightweight middleware. Middleware allows you to modify either the request, response, or both for all fetches.
158+
159+
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.
160+
161+
Within your middleware function, you’ll either need to check for `req` (request) or `res` (response) to handle each pass appropriately:
162+
163+
```ts
164+
createClient({
165+
middleware: [
166+
async function myMiddleware({
167+
req, // request (undefined for responses)
168+
res, // response (undefined for requests)
169+
options, // all options passed to openapi-fetch
170+
}) {
171+
if (req) {
172+
return new Request(req.url, {
173+
...req,
174+
headers: { ...req.headers, foo: "bar" },
175+
});
176+
} else if (res) {
177+
return new Response({
178+
...res,
179+
status: 200,
180+
});
181+
}
182+
},
183+
],
184+
});
185+
```
186+
187+
### Request pass
188+
189+
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:
190+
191+
| Name | Type | Description |
192+
| :----------- | :------: | :--------------------------------------------------------------- |
193+
| `schemaPath` | `string` | The OpenAPI pathname called (e.g. `/projects/{project_id}`) |
194+
| `params` | `Object` | The [params](#fetch-options) fetch option provided by the client |
195+
196+
### Response pass
197+
198+
The response pass returns a standard [Response](https://developer.mozilla.org/en-US/docs/Web/API/Response) instance with no modifications.
199+
200+
### Skipping middleware
201+
202+
If you want to skip the middleware under certain conditions, just `return` as early as possible:
203+
204+
```ts
205+
async function myMiddleware({ req }) {
206+
if (req.schemaPath !== "/projects/{project_id}") {
207+
return;
208+
}
209+
210+
//
211+
}
212+
```
213+
214+
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.
215+
216+
### Handling statefulness
217+
218+
When using middleware, it’s important to remember 2 things:
219+
220+
- **Create new instances** when modifying (e.g. `new Response()`)
221+
- **Clone bodies** before accessing (e.g. `res.clone().json()`)
222+
223+
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).
224+
225+
<!-- prettier-ignore -->
226+
```ts
227+
async function myMiddleware({ req, res }) {
228+
// Example 1: modifying request
229+
if (req) {
230+
res.headers.foo = "bar"; // [!code --]
231+
return new Request(req.url, { // [!code ++]
232+
...req, // [!code ++]
233+
headers: { ...req.headers, foo: "bar" }, // [!code ++]
234+
}); // [!code ++]
235+
}
236+
237+
// Example 2: accessing response
238+
if (res) {
239+
const data = await res.json(); // [!code --]
240+
const data = await res.clone().json(); // [!code ++]
241+
}
242+
}
243+
```
244+
245+
### Other notes
246+
247+
- `querySerializer()` runs _before_ middleware
248+
- This is to save middleware from having to do annoying URL formatting. But remember middleware can access `req.params`
249+
- `bodySerializer()` runs _after_ middleware
250+
- 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> | QuerySerializerOptions;
2424
/** global bodySerializer */
2525
bodySerializer?: BodySerializer<unknown>;
26+
/** middlewares */
27+
middleware?: Middleware[];
2628
headers?: HeadersOptions;
2729
}
2830

@@ -130,6 +132,43 @@ export type RequestOptions<T> = ParamsOption<T> &
130132
fetch?: ClientOptions["fetch"];
131133
};
132134

135+
export type MergedOptions<T = unknown> = {
136+
baseUrl: string;
137+
parseAs: ParseAs;
138+
querySerializer: QuerySerializer<T>;
139+
bodySerializer: BodySerializer<T>;
140+
fetch: typeof globalThis.fetch;
141+
};
142+
143+
export type MiddlewareRequestPayload = {
144+
type: "request";
145+
req: Request & {
146+
/** The original OpenAPI schema path (including curly braces) */
147+
schemaPath: string;
148+
/** OpenAPI parameters as provided from openapi-fetch */
149+
params: {
150+
query?: Record<string, unknown>;
151+
header?: Record<string, unknown>;
152+
path?: Record<string, unknown>;
153+
cookie?: Record<string, unknown>;
154+
};
155+
};
156+
res?: never;
157+
options: readonly MergedOptions;
158+
};
159+
export type MiddlewareResponsePayload = {
160+
type: "response";
161+
req?: never;
162+
res: Response;
163+
options: readonly MergedOptions;
164+
};
165+
export type MiddlewarePayload =
166+
| MiddlewareRequestPayload
167+
| MiddlewareResponsePayload;
168+
export type Middleware = (
169+
payload: MiddlewarePayload,
170+
) => Promise<Request | Response | undefined> | Promise<void>;
171+
133172
export type ClientMethod<Paths extends {}, M> = <
134173
P extends PathsWithMethod<Paths, M>,
135174
I extends MaybeOptionalInit<Paths[P], M>,

packages/openapi-fetch/src/index.js

+73-27
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ export default function createClient(clientOptions) {
1414
fetch: baseFetch = globalThis.fetch,
1515
querySerializer: globalQuerySerializer,
1616
bodySerializer: globalBodySerializer,
17+
middleware,
1718
...baseOptions
1819
} = clientOptions ?? {};
1920
let baseUrl = baseOptions.baseUrl ?? "";
@@ -27,10 +28,9 @@ export default function createClient(clientOptions) {
2728
* @param {import('./index.js').FetchOptions<T>} fetchOptions
2829
*/
2930
async function coreFetch(url, fetchOptions) {
30-
const {
31+
let {
3132
fetch = baseFetch,
3233
headers,
33-
body: requestBody,
3434
params = {},
3535
parseAs = "json",
3636
querySerializer: requestQuerySerializer,
@@ -54,37 +54,83 @@ export default function createClient(clientOptions) {
5454
});
5555
}
5656

57-
// URL
58-
const finalURL = createFinalURL(url, {
59-
baseUrl,
60-
params,
61-
querySerializer,
62-
});
63-
const finalHeaders = mergeHeaders(
64-
DEFAULT_HEADERS,
65-
clientOptions?.headers,
66-
headers,
67-
params.header,
57+
let request = new Request(
58+
createFinalURL(url, { baseUrl, params, querySerializer }),
59+
{
60+
redirect: "follow",
61+
...baseOptions,
62+
...init,
63+
headers: mergeHeaders(
64+
DEFAULT_HEADERS,
65+
clientOptions?.headers,
66+
headers,
67+
params.header,
68+
),
69+
},
6870
);
6971

70-
// fetch!
71-
/** @type {RequestInit} */
72-
const requestInit = {
73-
redirect: "follow",
74-
...baseOptions,
75-
...init,
76-
headers: finalHeaders,
72+
// middleware (request)
73+
const mergedOptions = {
74+
baseUrl,
75+
fetch,
76+
parseAs,
77+
querySerializer,
78+
bodySerializer,
7779
};
78-
79-
if (requestBody) {
80-
requestInit.body = bodySerializer(requestBody);
80+
if (Array.isArray(middleware)) {
81+
for (const m of middleware) {
82+
const req = new Request(request.url, request);
83+
req.schemaPath = url; // (re)attach original URL
84+
req.params = params; // (re)attach params
85+
const result = await m({
86+
type: "request",
87+
req,
88+
options: Object.freeze({ ...mergedOptions }),
89+
});
90+
if (result) {
91+
if (!(result instanceof Request)) {
92+
throw new Error(
93+
`Middleware must return new Request() when modifying the request`,
94+
);
95+
}
96+
request = result;
97+
}
98+
}
8199
}
100+
101+
// fetch!
102+
// if (init.body) {
103+
// request = new Request(request.url, {
104+
// ...request,
105+
// body: bodySerializer(init.body),
106+
// });
107+
// }
82108
// remove `Content-Type` if serialized body is FormData; browser will correctly set Content-Type & boundary expression
83-
if (requestInit.body instanceof FormData) {
84-
finalHeaders.delete("Content-Type");
85-
}
109+
// if (request.body instanceof FormData) {
110+
// request.headers.delete("Content-Type");
111+
// }
86112

87-
const response = await fetch(finalURL, requestInit);
113+
let response = await fetch(request);
114+
115+
// middleware (response)
116+
if (Array.isArray(middleware)) {
117+
// execute in reverse-array order (first priority gets last transform)
118+
for (let i = middleware.length - 1; i >= 0; i--) {
119+
const result = await middleware[i]({
120+
type: "response",
121+
res: response,
122+
options: Object.freeze({ ...mergedOptions }),
123+
});
124+
if (result) {
125+
if (!(result instanceof Response)) {
126+
throw new Error(
127+
`Middleware must return new Response() when modifying the response`,
128+
);
129+
}
130+
response = result;
131+
}
132+
}
133+
}
88134

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

0 commit comments

Comments
 (0)