Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 13 additions & 2 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import type { PluginFn } from '@japa/runner/types'
import { ApiClient } from './src/client.js'
import { TestContext } from '@japa/runner/core'
import type { ApiClientPluginOptions } from './src/types.js'

export { ApiClient }
export { ApiRequest } from './src/request.js'
Expand All @@ -19,12 +20,22 @@ export { ApiResponse } from './src/response.js'
* API client plugin registers an HTTP request client that
* can be used for testing API endpoints.
*/
export function apiClient(options?: string | { baseURL?: string }): PluginFn {
export function apiClient(options?: string | ApiClientPluginOptions): PluginFn {
return function () {
const normalizedOptions = typeof options === 'string' ? { baseURL: options } : options

if (normalizedOptions?.registry) {
ApiClient.setRoutes(normalizedOptions.registry)
}

if (normalizedOptions?.patternSerializer) {
ApiClient.setPatternSerializer(normalizedOptions.patternSerializer)
}

TestContext.getter(
'client',
function (this: TestContext) {
return new ApiClient(typeof options === 'string' ? options : options?.baseURL, this.assert)
return new ApiClient(normalizedOptions?.baseURL, this.assert)
},
true
)
Expand Down
133 changes: 118 additions & 15 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,22 @@ import Macroable from '@poppinss/macroable'
import type { Assert } from '@japa/assert'

import { ApiRequest } from './request.js'
import { type SetupHandler, type TeardownHandler, type CookiesSerializer } from './types.js'
import {
type SetupHandler,
type TeardownHandler,
type CookiesSerializer,
type InferBody,
type InferResponse,
type InferQuery,
type RoutesRegistry,
type PatternSerializer,
type UserRoutesRegistry,
type InferRouteBody,
type InferRouteQuery,
type InferRouteResponse,
type InferRouteParams,
type IsEmptyObject,
} from './types.js'

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

static #customCookiesSerializer?: CookiesSerializer

/**
* Routes registry for type-safe named routes
*/
static #routesRegistry?: RoutesRegistry

/**
* Pattern serializer for converting patterns to URLs
*/
static #patternSerializer: PatternSerializer = (pattern, params) => {
return pattern.replace(/:(\w+)/g, (_, key) => String(params[key] ?? ''))
}

#baseUrl?: string
#assert?: Assert

Expand Down Expand Up @@ -104,6 +131,30 @@ export class ApiClient extends Macroable {
return this
}

/**
* Register a routes registry for type-safe named routes
*/
static setRoutes(registry: RoutesRegistry) {
this.#routesRegistry = registry
return this
}

/**
* Register a custom pattern serializer
*/
static setPatternSerializer(serializer: PatternSerializer) {
this.#patternSerializer = serializer
return this
}

/**
* Clear the routes registry
*/
static clearRoutes() {
this.#routesRegistry = undefined
return this
}

/**
* Create an instance of the request
*/
Expand Down Expand Up @@ -142,49 +193,101 @@ export class ApiClient extends Macroable {
/**
* Create an instance of the request for GET method
*/
get(endpoint: string) {
return this.request(endpoint, 'GET')
get<P extends string>(endpoint: P): ApiRequest<never, InferResponse<P>, InferQuery<P>> {
return this.request(endpoint, 'GET') as ApiRequest<never, InferResponse<P>, InferQuery<P>>
}

/**
* Create an instance of the request for POST method
*/
post(endpoint: string) {
return this.request(endpoint, 'POST')
post<P extends string>(endpoint: P): ApiRequest<InferBody<P>, InferResponse<P>, InferQuery<P>> {
return this.request(endpoint, 'POST') as ApiRequest<
InferBody<P>,
InferResponse<P>,
InferQuery<P>
>
}

/**
* Create an instance of the request for PUT method
*/
put(endpoint: string) {
return this.request(endpoint, 'PUT')
put<P extends string>(endpoint: P): ApiRequest<InferBody<P>, InferResponse<P>, InferQuery<P>> {
return this.request(endpoint, 'PUT') as ApiRequest<
InferBody<P>,
InferResponse<P>,
InferQuery<P>
>
}

/**
* Create an instance of the request for PATCH method
*/
patch(endpoint: string) {
return this.request(endpoint, 'PATCH')
patch<P extends string>(endpoint: P): ApiRequest<InferBody<P>, InferResponse<P>, InferQuery<P>> {
return this.request(endpoint, 'PATCH') as ApiRequest<
InferBody<P>,
InferResponse<P>,
InferQuery<P>
>
}

/**
* Create an instance of the request for DELETE method
*/
delete(endpoint: string) {
return this.request(endpoint, 'DELETE')
delete<P extends string>(endpoint: P): ApiRequest<InferBody<P>, InferResponse<P>, InferQuery<P>> {
return this.request(endpoint, 'DELETE') as ApiRequest<
InferBody<P>,
InferResponse<P>,
InferQuery<P>
>
}

/**
* Create an instance of the request for HEAD method
*/
head(endpoint: string) {
return this.request(endpoint, 'HEAD')
head<P extends string>(endpoint: P): ApiRequest<never, InferResponse<P>, InferQuery<P>> {
return this.request(endpoint, 'HEAD') as ApiRequest<never, InferResponse<P>, InferQuery<P>>
}

/**
* Create an instance of the request for OPTIONS method
*/
options(endpoint: string) {
return this.request(endpoint, 'OPTIONS')
options<P extends string>(endpoint: P): ApiRequest<never, InferResponse<P>, InferQuery<P>> {
return this.request(endpoint, 'OPTIONS') as ApiRequest<never, InferResponse<P>, InferQuery<P>>
}

/**
* Create a type-safe request using a named route from the registry.
* The route name must be registered in both the runtime registry
* (via ApiClient.setRoutes()) and the type registry (UserRoutesRegistry).
*/
visit<Name extends keyof UserRoutesRegistry>(
...args: IsEmptyObject<InferRouteParams<Name>> extends true
? [name: Name]
: [name: Name, params: InferRouteParams<Name>]
): ApiRequest<InferRouteBody<Name>, InferRouteResponse<Name>, InferRouteQuery<Name>> {
const name = args[0]
const params = (args[1] ?? {}) as Record<string, any>

const registry = (this.constructor as typeof ApiClient).#routesRegistry
if (!registry) {
throw new Error(
`Routes registry not configured. Use ApiClient.routes() to register your routes.`
)
}

const routeDef = registry[name as string]
if (!routeDef) {
throw new Error(`Route "${String(name)}" not found in routes registry.`)
}

const serializer = (this.constructor as typeof ApiClient).#patternSerializer
const endpoint = serializer(routeDef.pattern, params)
const method = routeDef.methods[0]

return this.request(endpoint, method) as ApiRequest<
InferRouteBody<Name>,
InferRouteResponse<Name>,
InferRouteQuery<Name>
>
}
}
74 changes: 59 additions & 15 deletions src/request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ const DUMP_CALLS = {
headers: dumpRequestHeaders,
}

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

try {
Expand All @@ -195,7 +195,7 @@ export class ApiRequest extends Macroable {
}

await this.#setupRunner.cleanup(null, this)
return new ApiResponse(this, response, this.config, this.#assert)
return new ApiResponse<TResponse>(this, response, this.config, this.#assert)
}

/**
Expand Down Expand Up @@ -359,7 +359,17 @@ export class ApiRequest extends Macroable {
* password: 'secret'
* })
*/
form(values: string | object) {
form(values: TBody) {
this.type('form')
this.request.send(values as string | object)
return this
}

/**
* Set form values without type checking.
* Useful for testing invalid form data.
*/
unsafeForm(values: string | object) {
this.type('form')
this.request.send(values)
return this
Expand All @@ -375,7 +385,17 @@ export class ApiRequest extends Macroable {
* password: 'secret'
* })
*/
json(values: string | object) {
json(values: TBody) {
this.type('json')
this.request.send(values as string | object)
return this
}

/**
* Set JSON body for the request without type checking.
* Useful for testing invalid JSON payloads.
*/
unsafeJson(values: string | object) {
this.type('json')
this.request.send(values)
return this
Expand All @@ -389,8 +409,23 @@ export class ApiRequest extends Macroable {
* request.qs({ order_by: 'id' })
*/
qs(key: string, value: any): this
qs(values: string | object): this
qs(key: string | object, value?: any): this {
qs(values: TQuery): this
qs(key: string | TQuery, value?: any): this {
if (!value) {
this.request.query(typeof key === 'string' ? key : ApiRequest.qsSerializer(key as object))
} else {
this.request.query(ApiRequest.qsSerializer({ [key as string]: value }))
}
return this
}

/**
* Set querystring for the request without type checking.
* Useful for testing invalid query parameters.
*/
unsafeQs(key: string, value: any): this
unsafeQs(values: string | object): this
unsafeQs(key: string | object, value?: any): this {
if (!value) {
this.request.query(typeof key === 'string' ? key : ApiRequest.qsSerializer(key))
} else {
Expand Down Expand Up @@ -580,10 +615,16 @@ export class ApiRequest extends Macroable {
* - 'ENETUNREACH'
* - 'EAI_AGAIN'
*/
retry(count: number, retryUntilCallback?: (error: any, response: ApiResponse) => boolean): this {
retry(
count: number,
retryUntilCallback?: (error: any, response: ApiResponse<TResponse>) => boolean
): this {
if (retryUntilCallback) {
this.request.retry(count, (error, response) => {
return retryUntilCallback(error, new ApiResponse(this, response, this.config, this.#assert))
return retryUntilCallback(
error,
new ApiResponse<TResponse>(this, response, this.config, this.#assert)
)
})

return this
Expand All @@ -596,7 +637,7 @@ export class ApiRequest extends Macroable {
/**
* Make the API request
*/
async send() {
async send(): Promise<ApiResponse<TResponse>> {
/**
* Step 1: Instantiate hooks runners
*/
Expand All @@ -623,8 +664,11 @@ export class ApiRequest extends Macroable {
/**
* Implementation of `then` for the promise API
*/
then<TResult1 = ApiResponse, TResult2 = never>(
resolve?: ((value: ApiResponse) => TResult1 | PromiseLike<TResult1>) | undefined | null,
then<TResult1 = ApiResponse<TResponse>, TResult2 = never>(
resolve?:
| ((value: ApiResponse<TResponse>) => TResult1 | PromiseLike<TResult1>)
| undefined
| null,
reject?: ((reason: any) => TResult2 | PromiseLike<TResult2>) | undefined | null
): Promise<TResult1 | TResult2> {
return this.send().then(resolve, reject)
Expand All @@ -634,15 +678,15 @@ export class ApiRequest extends Macroable {
* Implementation of `catch` for the promise API
*/
catch<TResult = never>(
reject?: ((reason: ApiResponse) => TResult | PromiseLike<TResult>) | undefined | null
): Promise<ApiResponse | TResult> {
reject?: ((reason: ApiResponse<TResponse>) => TResult | PromiseLike<TResult>) | undefined | null
): Promise<ApiResponse<TResponse> | TResult> {
return this.send().catch(reject)
}

/**
* Implementation of `finally` for the promise API
*/
finally(fullfilled?: (() => void) | undefined | null): Promise<ApiResponse> {
finally(fullfilled?: (() => void) | undefined | null): Promise<ApiResponse<TResponse>> {
return this.send().finally(fullfilled)
}

Expand Down
Loading