Skip to content

Commit 4c9bfa3

Browse files
committed
Implement middleware
1 parent 97b5c13 commit 4c9bfa3

File tree

8 files changed

+173
-23
lines changed

8 files changed

+173
-23
lines changed

examples/next/app/page.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ export default function Home() {
1919
api
2020
.get('getUserById', {
2121
params: { id: 1 },
22+
query: {
23+
name: 'John Doe',
24+
},
2225
})
2326
.then((res) => {
2427
console.log(res)

examples/next/openapi.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,14 @@
6565
"schema": {
6666
"type": "integer"
6767
}
68+
},
69+
{
70+
"name": "name",
71+
"in": "query",
72+
"required": true,
73+
"schema": {
74+
"type": "string"
75+
}
6876
}
6977
],
7078
"responses": {

packages/core/src/additional.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import type { Middleware } from './middleware'
2+
13
export type Additional<
24
T extends string,
35
Target extends object,
@@ -9,6 +11,8 @@ export type RequiredOptions<T extends object> = keyof T extends undefined
911
export type DevupApiRequestInit = Omit<RequestInit, 'body'> & {
1012
body?: object | RequestInit['body']
1113
params?: Record<string, string | number | boolean | null | undefined>
14+
query?: Record<string, string | number | boolean | null | undefined>
15+
middleware?: Middleware[]
1216
}
1317

1418
// biome-ignore lint/suspicious/noExplicitAny: any is used to allow for flexibility in the type

packages/core/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
export * from './additional'
22
export * from './api-struct'
3+
export * from './middleware'
34
export * from './options'
45
export * from './url-map'
56
export * from './utils'

packages/core/src/middleware.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import type { DevupApiRequestInit } from './additional'
2+
import type { PromiseOr } from './utils'
3+
4+
export interface MiddlewareCallbackParams {
5+
request: Request
6+
schemaPath: string
7+
params?: Record<string, unknown>
8+
query?: Record<string, unknown>
9+
headers?: DevupApiRequestInit['headers']
10+
body?: DevupApiRequestInit['body']
11+
}
12+
13+
type MiddlewareOnRequest = (
14+
params: MiddlewareCallbackParams,
15+
) => PromiseOr<undefined | Request | Response>
16+
type MiddlewareOnResponse = (
17+
params: MiddlewareCallbackParams & { response: Response },
18+
) => PromiseOr<undefined | Error | Response>
19+
type MiddlewareOnError = (
20+
params: MiddlewareCallbackParams & { error: unknown },
21+
) => PromiseOr<undefined | Error | Response>
22+
23+
export type Middleware =
24+
| {
25+
onRequest: MiddlewareOnRequest
26+
onResponse?: MiddlewareOnResponse
27+
onError?: MiddlewareOnError
28+
}
29+
| {
30+
onRequest?: MiddlewareOnRequest
31+
onResponse: MiddlewareOnResponse
32+
onError?: MiddlewareOnError
33+
}
34+
| {
35+
onRequest?: MiddlewareOnRequest
36+
onResponse?: MiddlewareOnResponse
37+
onError: MiddlewareOnError
38+
}

packages/core/src/utils.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,5 @@ export type ConditionalKeys<T, F = string> = keyof T extends undefined
44
export type ConditionalScope<T, K extends string> = K extends keyof T
55
? T[K]
66
: object
7+
8+
export type PromiseOr<T> = Promise<T> | T

packages/fetch/src/__tests__/api.test.ts

Lines changed: 25 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
/** biome-ignore-all lint/suspicious/noExplicitAny: any is used to allow for flexibility in the type */
12
import { afterEach, beforeEach, expect, mock, test } from 'bun:test'
23
import { DevupApi } from '../api'
34

@@ -24,7 +25,7 @@ test.each([
2425
['http://localhost:3000', 'http://localhost:3000'],
2526
['http://localhost:3000/', 'http://localhost:3000'],
2627
] as const)('constructor removes trailing slash: %s -> %s', (baseUrl, expected) => {
27-
const api = new DevupApi(baseUrl)
28+
const api = new DevupApi(baseUrl, undefined, 'openapi.json')
2829
expect(api.getBaseUrl()).toBe(expected)
2930
})
3031

@@ -36,7 +37,11 @@ test.each([
3637
{ headers: { Authorization: 'Bearer token' } },
3738
],
3839
] as const)('constructor accepts defaultOptions: %s -> %s', (defaultOptions, expected) => {
39-
const api = new DevupApi('https://api.example.com', defaultOptions)
40+
const api = new DevupApi(
41+
'https://api.example.com',
42+
defaultOptions,
43+
'openapi.json',
44+
)
4045
expect(api.getDefaultOptions()).toEqual(expected)
4146
})
4247

@@ -47,7 +52,7 @@ test.each([
4752
{ headers: { 'Content-Type': 'application/json' } },
4853
],
4954
] as const)('setDefaultOptions updates defaultOptions: %s -> %s', (options, expected) => {
50-
const api = new DevupApi('https://api.example.com')
55+
const api = new DevupApi('https://api.example.com', undefined, 'openapi.json')
5156
api.setDefaultOptions(options)
5257
expect(api.getDefaultOptions()).toEqual(expected)
5358
})
@@ -64,10 +69,10 @@ test.each([
6469
['PATCH', 'patch'],
6570
['PATCH', 'PATCH'],
6671
] as const)('HTTP method %s calls request with correct method', async (expectedMethod, methodName) => {
67-
const api = new DevupApi('https://api.example.com')
72+
const api = new DevupApi('https://api.example.com', undefined, 'openapi.json')
6873
const mockFetch = globalThis.fetch as unknown as ReturnType<typeof mock>
6974

70-
await api[methodName]('/test' as never)
75+
await (api as any)[methodName]('/test' as never)
7176

7277
expect(mockFetch).toHaveBeenCalledTimes(1)
7378
const call = mockFetch.mock.calls[0]
@@ -79,7 +84,7 @@ test.each([
7984
})
8085

8186
test('request serializes plain object body to JSON', async () => {
82-
const api = new DevupApi('https://api.example.com')
87+
const api = new DevupApi('https://api.example.com', undefined, 'openapi.json')
8388
const mockFetch = globalThis.fetch as unknown as ReturnType<typeof mock>
8489

8590
await api.post(
@@ -100,7 +105,7 @@ test('request serializes plain object body to JSON', async () => {
100105
})
101106

102107
test('request does not serialize non-plain object body', async () => {
103-
const api = new DevupApi('https://api.example.com')
108+
const api = new DevupApi('https://api.example.com', undefined, 'openapi.json')
104109
const mockFetch = globalThis.fetch as unknown as ReturnType<typeof mock>
105110
const formData = new FormData()
106111
formData.append('file', 'test')
@@ -127,9 +132,13 @@ test('request does not serialize non-plain object body', async () => {
127132
})
128133

129134
test('request merges defaultOptions with request options', async () => {
130-
const api = new DevupApi('https://api.example.com', {
131-
headers: { 'X-Default': 'default-value' },
132-
})
135+
const api = new DevupApi(
136+
'https://api.example.com',
137+
{
138+
headers: { 'X-Default': 'default-value' },
139+
},
140+
'openapi.json',
141+
)
133142
const mockFetch = globalThis.fetch as unknown as ReturnType<typeof mock>
134143

135144
await api.get(
@@ -151,7 +160,7 @@ test('request merges defaultOptions with request options', async () => {
151160
})
152161

153162
test('request uses params to replace path parameters', async () => {
154-
const api = new DevupApi('https://api.example.com')
163+
const api = new DevupApi('https://api.example.com', undefined, 'openapi.json')
155164
const mockFetch = globalThis.fetch as unknown as ReturnType<typeof mock>
156165

157166
await api.get(
@@ -180,7 +189,7 @@ test('request returns response with data on success', async () => {
180189
),
181190
) as unknown as typeof fetch
182191

183-
const api = new DevupApi('https://api.example.com')
192+
const api = new DevupApi('https://api.example.com', undefined, 'openapi.json')
184193
const result = (await api.get('/test' as never)) as {
185194
data?: unknown
186195
error?: unknown
@@ -191,7 +200,7 @@ test('request returns response with data on success', async () => {
191200
if ('data' in result && result.data !== undefined) {
192201
expect(result.data).toEqual({ id: 1, name: 'test' })
193202
}
194-
expect('error' in result).toBe(false)
203+
expect(result.error).toBeUndefined()
195204
expect(result.response).toBeDefined()
196205
expect(result.response.ok).toBe(true)
197206
})
@@ -206,7 +215,7 @@ test('request returns response with error on failure', async () => {
206215
),
207216
) as unknown as typeof fetch
208217

209-
const api = new DevupApi('https://api.example.com')
218+
const api = new DevupApi('https://api.example.com', undefined, 'openapi.json')
210219
const result = (await api.get('/test' as never)) as {
211220
data?: unknown
212221
error?: unknown
@@ -217,7 +226,7 @@ test('request returns response with error on failure', async () => {
217226
if ('error' in result && result.error !== undefined) {
218227
expect(result.error).toEqual({ message: 'Not found' })
219228
}
220-
expect('data' in result).toBe(false)
229+
expect(result.data).toBeUndefined()
221230
expect(result.response).toBeDefined()
222231
expect(result.response.ok).toBe(false)
223232
})
@@ -231,7 +240,7 @@ test('request handles 204 No Content response', async () => {
231240
),
232241
) as unknown as typeof fetch
233242

234-
const api = new DevupApi('https://api.example.com')
243+
const api = new DevupApi('https://api.example.com', undefined, 'openapi.json')
235244
const result = await api.delete('/test' as never)
236245

237246
if ('data' in result) {

packages/fetch/src/api.ts

Lines changed: 92 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import type {
1717
DevupPutApiStruct,
1818
DevupPutApiStructKey,
1919
ExtractValue,
20+
Middleware,
2021
RequiredOptions,
2122
} from '@devup-api/core'
2223
import { convertResponse } from './response-converter'
@@ -40,6 +41,7 @@ export class DevupApi<S extends ConditionalKeys<DevupApiServers>> {
4041
private baseUrl: string
4142
private defaultOptions: DevupApiRequestInit
4243
private serverName: S
44+
private middleware: Middleware[]
4345

4446
constructor(
4547
baseUrl: string,
@@ -49,6 +51,7 @@ export class DevupApi<S extends ConditionalKeys<DevupApiServers>> {
4951
this.baseUrl = baseUrl.replace(/\/$/, '')
5052
this.defaultOptions = defaultOptions
5153
this.serverName = serverName as S
54+
this.middleware = []
5255
}
5356

5457
get<
@@ -221,7 +224,7 @@ export class DevupApi<S extends ConditionalKeys<DevupApiServers>> {
221224
} as DevupApiRequestInit & Omit<O, 'response' | 'error'>)
222225
}
223226

224-
request<
227+
async request<
225228
T extends DevupApiStructKey<S>,
226229
O extends Additional<T, ConditionalScope<DevupApiStruct, S>>,
227230
>(
@@ -233,9 +236,10 @@ export class DevupApi<S extends ConditionalKeys<DevupApiServers>> {
233236
DevupApiResponse<ExtractValue<O, 'response'>, ExtractValue<O, 'error'>>
234237
> {
235238
const { method, url } = getApiEndpointInfo(path, this.serverName)
239+
const { middleware = [], ...restOptions } = options[0] || {}
236240
const mergedOptions = {
237241
...this.defaultOptions,
238-
...options[0],
242+
...restOptions,
239243
}
240244
const requestOptions = {
241245
...mergedOptions,
@@ -244,7 +248,7 @@ export class DevupApi<S extends ConditionalKeys<DevupApiServers>> {
244248
if (requestOptions.body && isPlainObject(requestOptions.body)) {
245249
requestOptions.body = JSON.stringify(requestOptions.body)
246250
}
247-
const request = new Request(
251+
let request = new Request(
248252
getApiEndpoint(
249253
this.baseUrl,
250254
url,
@@ -259,11 +263,88 @@ export class DevupApi<S extends ConditionalKeys<DevupApiServers>> {
259263
),
260264
requestOptions as RequestInit,
261265
)
262-
return fetch(request).then((response) =>
263-
convertResponse(request, response),
264-
) as Promise<
265-
DevupApiResponse<ExtractValue<O, 'response'>, ExtractValue<O, 'error'>>
266+
267+
const finalMiddleware = [...this.middleware, ...middleware]
268+
269+
let tempResponse: Response | undefined
270+
271+
for (const middleware of finalMiddleware) {
272+
if (middleware.onRequest) {
273+
const result = await middleware.onRequest({
274+
request,
275+
schemaPath: path,
276+
params: requestOptions.params,
277+
query: requestOptions.query,
278+
headers: requestOptions.headers,
279+
body: requestOptions.body,
280+
})
281+
if (result) {
282+
if (result instanceof Request) {
283+
request = result
284+
} else if (result instanceof Response) {
285+
tempResponse = result
286+
break
287+
} else {
288+
throw new Error(
289+
'onRequest: must return new Request() or Response() when modifying the request',
290+
)
291+
}
292+
}
293+
}
294+
}
295+
296+
const ret = (await (tempResponse
297+
? convertResponse(request, tempResponse)
298+
: fetch(request).then((response) =>
299+
convertResponse(request, response),
300+
))) as DevupApiResponse<
301+
ExtractValue<O, 'response'>,
302+
ExtractValue<O, 'error'>
266303
>
304+
305+
let response = ret.response
306+
let error: unknown = ret.error
307+
308+
for (const middleware of finalMiddleware) {
309+
if (response && middleware.onResponse) {
310+
const result = await (response && middleware.onResponse
311+
? middleware.onResponse({
312+
request,
313+
schemaPath: path,
314+
params: requestOptions.params,
315+
query: requestOptions.query,
316+
headers: requestOptions.headers,
317+
body: requestOptions.body,
318+
response: ret.response,
319+
})
320+
: error && middleware.onError
321+
? middleware.onError({
322+
request,
323+
schemaPath: path,
324+
params: requestOptions.params,
325+
query: requestOptions.query,
326+
headers: requestOptions.headers,
327+
body: requestOptions.body,
328+
error: ret.error,
329+
})
330+
: undefined)
331+
if (result) {
332+
if (result instanceof Response) {
333+
response = result
334+
break
335+
} else if (result instanceof Error) {
336+
error = result
337+
break
338+
}
339+
}
340+
}
341+
}
342+
343+
return {
344+
data: ret.data,
345+
error: error,
346+
response,
347+
} as DevupApiResponse<ExtractValue<O, 'response'>, ExtractValue<O, 'error'>>
267348
}
268349

269350
setDefaultOptions(options: DevupApiRequestInit) {
@@ -277,4 +358,8 @@ export class DevupApi<S extends ConditionalKeys<DevupApiServers>> {
277358
getDefaultOptions() {
278359
return this.defaultOptions
279360
}
361+
362+
use(...middleware: Middleware[]) {
363+
this.middleware.push(...middleware)
364+
}
280365
}

0 commit comments

Comments
 (0)