Skip to content

Commit 6628917

Browse files
committed
feat: typesafet api-client
1 parent 82f87a3 commit 6628917

File tree

6 files changed

+705
-35
lines changed

6 files changed

+705
-35
lines changed

index.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import type { PluginFn } from '@japa/runner/types'
1111
import { ApiClient } from './src/client.js'
1212
import { TestContext } from '@japa/runner/core'
13+
import type { ApiClientPluginOptions } from './src/types.js'
1314

1415
export { ApiClient }
1516
export { ApiRequest } from './src/request.js'
@@ -19,12 +20,22 @@ export { ApiResponse } from './src/response.js'
1920
* API client plugin registers an HTTP request client that
2021
* can be used for testing API endpoints.
2122
*/
22-
export function apiClient(options?: string | { baseURL?: string }): PluginFn {
23+
export function apiClient(options?: string | ApiClientPluginOptions): PluginFn {
2324
return function () {
25+
const normalizedOptions = typeof options === 'string' ? { baseURL: options } : options
26+
27+
if (normalizedOptions?.registry) {
28+
ApiClient.setRoutes(normalizedOptions.registry)
29+
}
30+
31+
if (normalizedOptions?.patternSerializer) {
32+
ApiClient.setPatternSerializer(normalizedOptions.patternSerializer)
33+
}
34+
2435
TestContext.getter(
2536
'client',
2637
function (this: TestContext) {
27-
return new ApiClient(typeof options === 'string' ? options : options?.baseURL, this.assert)
38+
return new ApiClient(normalizedOptions?.baseURL, this.assert)
2839
},
2940
true
3041
)

src/client.ts

Lines changed: 118 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,22 @@ import Macroable from '@poppinss/macroable'
1111
import type { Assert } from '@japa/assert'
1212

1313
import { ApiRequest } from './request.js'
14-
import { type SetupHandler, type TeardownHandler, type CookiesSerializer } from './types.js'
14+
import {
15+
type SetupHandler,
16+
type TeardownHandler,
17+
type CookiesSerializer,
18+
type InferBody,
19+
type InferResponse,
20+
type InferQuery,
21+
type RoutesRegistry,
22+
type PatternSerializer,
23+
type UserRoutesRegistry,
24+
type InferRouteBody,
25+
type InferRouteQuery,
26+
type InferRouteResponse,
27+
type InferRouteParams,
28+
type IsEmptyObject,
29+
} from './types.js'
1530

1631
/**
1732
* ApiClient exposes the API to make HTTP requests in context of
@@ -36,6 +51,18 @@ export class ApiClient extends Macroable {
3651

3752
static #customCookiesSerializer?: CookiesSerializer
3853

54+
/**
55+
* Routes registry for type-safe named routes
56+
*/
57+
static #routesRegistry?: RoutesRegistry
58+
59+
/**
60+
* Pattern serializer for converting patterns to URLs
61+
*/
62+
static #patternSerializer: PatternSerializer = (pattern, params) => {
63+
return pattern.replace(/:(\w+)/g, (_, key) => String(params[key] ?? ''))
64+
}
65+
3966
#baseUrl?: string
4067
#assert?: Assert
4168

@@ -104,6 +131,30 @@ export class ApiClient extends Macroable {
104131
return this
105132
}
106133

134+
/**
135+
* Register a routes registry for type-safe named routes
136+
*/
137+
static setRoutes(registry: RoutesRegistry) {
138+
this.#routesRegistry = registry
139+
return this
140+
}
141+
142+
/**
143+
* Register a custom pattern serializer
144+
*/
145+
static setPatternSerializer(serializer: PatternSerializer) {
146+
this.#patternSerializer = serializer
147+
return this
148+
}
149+
150+
/**
151+
* Clear the routes registry
152+
*/
153+
static clearRoutes() {
154+
this.#routesRegistry = undefined
155+
return this
156+
}
157+
107158
/**
108159
* Create an instance of the request
109160
*/
@@ -142,49 +193,101 @@ export class ApiClient extends Macroable {
142193
/**
143194
* Create an instance of the request for GET method
144195
*/
145-
get(endpoint: string) {
146-
return this.request(endpoint, 'GET')
196+
get<P extends string>(endpoint: P): ApiRequest<never, InferResponse<P>, InferQuery<P>> {
197+
return this.request(endpoint, 'GET') as ApiRequest<never, InferResponse<P>, InferQuery<P>>
147198
}
148199

149200
/**
150201
* Create an instance of the request for POST method
151202
*/
152-
post(endpoint: string) {
153-
return this.request(endpoint, 'POST')
203+
post<P extends string>(endpoint: P): ApiRequest<InferBody<P>, InferResponse<P>, InferQuery<P>> {
204+
return this.request(endpoint, 'POST') as ApiRequest<
205+
InferBody<P>,
206+
InferResponse<P>,
207+
InferQuery<P>
208+
>
154209
}
155210

156211
/**
157212
* Create an instance of the request for PUT method
158213
*/
159-
put(endpoint: string) {
160-
return this.request(endpoint, 'PUT')
214+
put<P extends string>(endpoint: P): ApiRequest<InferBody<P>, InferResponse<P>, InferQuery<P>> {
215+
return this.request(endpoint, 'PUT') as ApiRequest<
216+
InferBody<P>,
217+
InferResponse<P>,
218+
InferQuery<P>
219+
>
161220
}
162221

163222
/**
164223
* Create an instance of the request for PATCH method
165224
*/
166-
patch(endpoint: string) {
167-
return this.request(endpoint, 'PATCH')
225+
patch<P extends string>(endpoint: P): ApiRequest<InferBody<P>, InferResponse<P>, InferQuery<P>> {
226+
return this.request(endpoint, 'PATCH') as ApiRequest<
227+
InferBody<P>,
228+
InferResponse<P>,
229+
InferQuery<P>
230+
>
168231
}
169232

170233
/**
171234
* Create an instance of the request for DELETE method
172235
*/
173-
delete(endpoint: string) {
174-
return this.request(endpoint, 'DELETE')
236+
delete<P extends string>(endpoint: P): ApiRequest<InferBody<P>, InferResponse<P>, InferQuery<P>> {
237+
return this.request(endpoint, 'DELETE') as ApiRequest<
238+
InferBody<P>,
239+
InferResponse<P>,
240+
InferQuery<P>
241+
>
175242
}
176243

177244
/**
178245
* Create an instance of the request for HEAD method
179246
*/
180-
head(endpoint: string) {
181-
return this.request(endpoint, 'HEAD')
247+
head<P extends string>(endpoint: P): ApiRequest<never, InferResponse<P>, InferQuery<P>> {
248+
return this.request(endpoint, 'HEAD') as ApiRequest<never, InferResponse<P>, InferQuery<P>>
182249
}
183250

184251
/**
185252
* Create an instance of the request for OPTIONS method
186253
*/
187-
options(endpoint: string) {
188-
return this.request(endpoint, 'OPTIONS')
254+
options<P extends string>(endpoint: P): ApiRequest<never, InferResponse<P>, InferQuery<P>> {
255+
return this.request(endpoint, 'OPTIONS') as ApiRequest<never, InferResponse<P>, InferQuery<P>>
256+
}
257+
258+
/**
259+
* Create a type-safe request using a named route from the registry.
260+
* The route name must be registered in both the runtime registry
261+
* (via ApiClient.setRoutes()) and the type registry (UserRoutesRegistry).
262+
*/
263+
visit<Name extends keyof UserRoutesRegistry>(
264+
...args: IsEmptyObject<InferRouteParams<Name>> extends true
265+
? [name: Name]
266+
: [name: Name, params: InferRouteParams<Name>]
267+
): ApiRequest<InferRouteBody<Name>, InferRouteResponse<Name>, InferRouteQuery<Name>> {
268+
const name = args[0]
269+
const params = (args[1] ?? {}) as Record<string, any>
270+
271+
const registry = (this.constructor as typeof ApiClient).#routesRegistry
272+
if (!registry) {
273+
throw new Error(
274+
`Routes registry not configured. Use ApiClient.routes() to register your routes.`
275+
)
276+
}
277+
278+
const routeDef = registry[name as string]
279+
if (!routeDef) {
280+
throw new Error(`Route "${String(name)}" not found in routes registry.`)
281+
}
282+
283+
const serializer = (this.constructor as typeof ApiClient).#patternSerializer
284+
const endpoint = serializer(routeDef.pattern, params)
285+
const method = routeDef.methods[0]
286+
287+
return this.request(endpoint, method) as ApiRequest<
288+
InferRouteBody<Name>,
289+
InferRouteResponse<Name>,
290+
InferRouteQuery<Name>
291+
>
189292
}
190293
}

src/request.ts

Lines changed: 59 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ const DUMP_CALLS = {
3333
headers: dumpRequestHeaders,
3434
}
3535

36-
export class ApiRequest extends Macroable {
36+
export class ApiRequest<TBody = any, TResponse = any, TQuery = any> extends Macroable {
3737
/**
3838
* The serializer to use for serializing request query params
3939
*/
@@ -169,7 +169,7 @@ export class ApiRequest extends Macroable {
169169
* Send HTTP request to the server. Errors except the client errors
170170
* are tured into a response object.
171171
*/
172-
async #sendRequest() {
172+
async #sendRequest(): Promise<ApiResponse<TResponse>> {
173173
let response: Response
174174

175175
try {
@@ -195,7 +195,7 @@ export class ApiRequest extends Macroable {
195195
}
196196

197197
await this.#setupRunner.cleanup(null, this)
198-
return new ApiResponse(this, response, this.config, this.#assert)
198+
return new ApiResponse<TResponse>(this, response, this.config, this.#assert)
199199
}
200200

201201
/**
@@ -359,7 +359,17 @@ export class ApiRequest extends Macroable {
359359
* password: 'secret'
360360
* })
361361
*/
362-
form(values: string | object) {
362+
form(values: TBody) {
363+
this.type('form')
364+
this.request.send(values as string | object)
365+
return this
366+
}
367+
368+
/**
369+
* Set form values without type checking.
370+
* Useful for testing invalid form data.
371+
*/
372+
unsafeForm(values: string | object) {
363373
this.type('form')
364374
this.request.send(values)
365375
return this
@@ -375,7 +385,17 @@ export class ApiRequest extends Macroable {
375385
* password: 'secret'
376386
* })
377387
*/
378-
json(values: string | object) {
388+
json(values: TBody) {
389+
this.type('json')
390+
this.request.send(values as string | object)
391+
return this
392+
}
393+
394+
/**
395+
* Set JSON body for the request without type checking.
396+
* Useful for testing invalid JSON payloads.
397+
*/
398+
unsafeJson(values: string | object) {
379399
this.type('json')
380400
this.request.send(values)
381401
return this
@@ -389,8 +409,23 @@ export class ApiRequest extends Macroable {
389409
* request.qs({ order_by: 'id' })
390410
*/
391411
qs(key: string, value: any): this
392-
qs(values: string | object): this
393-
qs(key: string | object, value?: any): this {
412+
qs(values: TQuery): this
413+
qs(key: string | TQuery, value?: any): this {
414+
if (!value) {
415+
this.request.query(typeof key === 'string' ? key : ApiRequest.qsSerializer(key as object))
416+
} else {
417+
this.request.query(ApiRequest.qsSerializer({ [key as string]: value }))
418+
}
419+
return this
420+
}
421+
422+
/**
423+
* Set querystring for the request without type checking.
424+
* Useful for testing invalid query parameters.
425+
*/
426+
unsafeQs(key: string, value: any): this
427+
unsafeQs(values: string | object): this
428+
unsafeQs(key: string | object, value?: any): this {
394429
if (!value) {
395430
this.request.query(typeof key === 'string' ? key : ApiRequest.qsSerializer(key))
396431
} else {
@@ -580,10 +615,16 @@ export class ApiRequest extends Macroable {
580615
* - 'ENETUNREACH'
581616
* - 'EAI_AGAIN'
582617
*/
583-
retry(count: number, retryUntilCallback?: (error: any, response: ApiResponse) => boolean): this {
618+
retry(
619+
count: number,
620+
retryUntilCallback?: (error: any, response: ApiResponse<TResponse>) => boolean
621+
): this {
584622
if (retryUntilCallback) {
585623
this.request.retry(count, (error, response) => {
586-
return retryUntilCallback(error, new ApiResponse(this, response, this.config, this.#assert))
624+
return retryUntilCallback(
625+
error,
626+
new ApiResponse<TResponse>(this, response, this.config, this.#assert)
627+
)
587628
})
588629

589630
return this
@@ -596,7 +637,7 @@ export class ApiRequest extends Macroable {
596637
/**
597638
* Make the API request
598639
*/
599-
async send() {
640+
async send(): Promise<ApiResponse<TResponse>> {
600641
/**
601642
* Step 1: Instantiate hooks runners
602643
*/
@@ -623,8 +664,11 @@ export class ApiRequest extends Macroable {
623664
/**
624665
* Implementation of `then` for the promise API
625666
*/
626-
then<TResult1 = ApiResponse, TResult2 = never>(
627-
resolve?: ((value: ApiResponse) => TResult1 | PromiseLike<TResult1>) | undefined | null,
667+
then<TResult1 = ApiResponse<TResponse>, TResult2 = never>(
668+
resolve?:
669+
| ((value: ApiResponse<TResponse>) => TResult1 | PromiseLike<TResult1>)
670+
| undefined
671+
| null,
628672
reject?: ((reason: any) => TResult2 | PromiseLike<TResult2>) | undefined | null
629673
): Promise<TResult1 | TResult2> {
630674
return this.send().then(resolve, reject)
@@ -634,15 +678,15 @@ export class ApiRequest extends Macroable {
634678
* Implementation of `catch` for the promise API
635679
*/
636680
catch<TResult = never>(
637-
reject?: ((reason: ApiResponse) => TResult | PromiseLike<TResult>) | undefined | null
638-
): Promise<ApiResponse | TResult> {
681+
reject?: ((reason: ApiResponse<TResponse>) => TResult | PromiseLike<TResult>) | undefined | null
682+
): Promise<ApiResponse<TResponse> | TResult> {
639683
return this.send().catch(reject)
640684
}
641685

642686
/**
643687
* Implementation of `finally` for the promise API
644688
*/
645-
finally(fullfilled?: (() => void) | undefined | null): Promise<ApiResponse> {
689+
finally(fullfilled?: (() => void) | undefined | null): Promise<ApiResponse<TResponse>> {
646690
return this.send().finally(fullfilled)
647691
}
648692

0 commit comments

Comments
 (0)