diff --git a/index.ts b/index.ts index 250ef92..fcb1ff7 100644 --- a/index.ts +++ b/index.ts @@ -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' @@ -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 ) diff --git a/src/client.ts b/src/client.ts index 1a1eb3f..0456e07 100644 --- a/src/client.ts +++ b/src/client.ts @@ -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 @@ -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 @@ -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 */ @@ -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
(endpoint: P): ApiRequest > {
+ return this.request(endpoint, 'GET') as ApiRequest >
}
/**
* Create an instance of the request for POST method
*/
- post(endpoint: string) {
- return this.request(endpoint, 'POST')
+ post (endpoint: P): ApiRequest , InferQuery > {
+ return this.request(endpoint, 'POST') as ApiRequest<
+ InferBody ,
+ InferResponse ,
+ InferQuery
+ >
}
/**
* Create an instance of the request for PUT method
*/
- put(endpoint: string) {
- return this.request(endpoint, 'PUT')
+ put (endpoint: P): ApiRequest , InferQuery > {
+ return this.request(endpoint, 'PUT') as ApiRequest<
+ InferBody ,
+ InferResponse ,
+ InferQuery
+ >
}
/**
* Create an instance of the request for PATCH method
*/
- patch(endpoint: string) {
- return this.request(endpoint, 'PATCH')
+ patch (endpoint: P): ApiRequest , InferQuery > {
+ return this.request(endpoint, 'PATCH') as ApiRequest<
+ InferBody ,
+ InferResponse ,
+ InferQuery
+ >
}
/**
* Create an instance of the request for DELETE method
*/
- delete(endpoint: string) {
- return this.request(endpoint, 'DELETE')
+ delete (endpoint: P): ApiRequest , InferQuery > {
+ return this.request(endpoint, 'DELETE') as ApiRequest<
+ InferBody ,
+ InferResponse ,
+ InferQuery
+ >
}
/**
* Create an instance of the request for HEAD method
*/
- head(endpoint: string) {
- return this.request(endpoint, 'HEAD')
+ head (endpoint: P): ApiRequest > {
+ return this.request(endpoint, 'HEAD') as ApiRequest >
}
/**
* Create an instance of the request for OPTIONS method
*/
- options(endpoint: string) {
- return this.request(endpoint, 'OPTIONS')
+ options (endpoint: P): ApiRequest > {
+ return this.request(endpoint, 'OPTIONS') as ApiRequest >
+ }
+
+ /**
+ * 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 = {
+ [K in keyof UserRoutesRegistry]: UserRoutesRegistry[K] extends { pattern: P }
+ ? UserRoutesRegistry[K]
+ : never
+}[keyof UserRoutesRegistry]
+
+/**
+ * Extract all patterns from the registry
+ */
+type AllPatterns = UserRoutesRegistry[keyof UserRoutesRegistry] extends { pattern: infer P }
+ ? P extends string
+ ? P
+ : never
+ : never
+
+/**
+ * Helper to extract a type from a named route
+ */
+type InferFromRoute<
+ Name extends keyof UserRoutesRegistry,
+ Key extends 'params' | 'query' | 'body' | 'response',
+> = UserRoutesRegistry[Name] extends { types: infer Types }
+ ? Key extends keyof Types
+ ? Types[Key]
+ : never
+ : never
+
+/**
+ * Helper to extract a type from a route pattern
+ */
+type InferFromPattern<
+ P extends string,
+ Key extends 'body' | 'query' | 'response',
+> = HasUserRegistry extends true
+ ? [FindRouteByPattern ] extends [never]
+ ? any
+ : FindRouteByPattern extends { types: infer Types }
+ ? Key extends keyof Types
+ ? Types[Key]
+ : any
+ : any
+ : any
+
+export type InferRouteParams = InferFromPattern
+export type InferResponse = InferFromPattern
+export type InferQuery = InferFromPattern
+
+/**
+ * Valid patterns (restricted to known patterns if registry is configured)
+ */
+export type ValidPattern = HasUserRegistry extends true ? AllPatterns : string
+
+/**
+ * Options for the apiClient plugin
+ */
+export interface ApiClientPluginOptions {
+ baseURL?: string
+ registry?: RoutesRegistry
+ patternSerializer?: PatternSerializer
+}
diff --git a/tests/api_client/visit.spec.ts b/tests/api_client/visit.spec.ts
new file mode 100644
index 0000000..52e4ea0
--- /dev/null
+++ b/tests/api_client/visit.spec.ts
@@ -0,0 +1,384 @@
+/*
+ * @japa/api-client
+ *
+ * (c) Japa.dev
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+import { test } from '@japa/runner'
+
+import { ApiClient } from '../../src/client.js'
+import { httpServer } from '../../tests_helpers/index.js'
+import type { RoutesRegistry } from '../../src/types.js'
+
+/**
+ * Test routes registry (runtime)
+ */
+const testRoutes = {
+ 'users.index': { methods: ['GET'], pattern: '/users' },
+ 'users.show': { methods: ['GET'], pattern: '/users/:id' },
+ 'users.create': { methods: ['POST'], pattern: '/users' },
+ 'users.update': { methods: ['PUT'], pattern: '/users/:id' },
+ 'posts.index': { methods: ['GET', 'HEAD'], pattern: '/posts' },
+} as const satisfies RoutesRegistry
+
+/**
+ * Type augmentation for tests
+ */
+declare module '../../src/types.js' {
+ interface UserRoutesRegistry {
+ 'users.index': {
+ methods: ['GET']
+ pattern: '/users'
+ types: {
+ params: {}
+ query: { page?: number; limit?: number }
+ body: {}
+ response: { users: Array<{ id: number; name: string }> }
+ }
+ }
+ 'users.show': {
+ methods: ['GET']
+ pattern: '/users/:id'
+ types: {
+ params: { id: string }
+ query: {}
+ body: {}
+ response: { user: { id: number; name: string } }
+ }
+ }
+ 'users.create': {
+ methods: ['POST']
+ pattern: '/users'
+ types: {
+ params: {}
+ query: {}
+ body: { name: string; email: string }
+ response: { user: { id: number; name: string } }
+ }
+ }
+ 'users.update': {
+ methods: ['PUT']
+ pattern: '/users/:id'
+ types: {
+ params: { id: string }
+ query: {}
+ body: { name?: string; email?: string }
+ response: { user: { id: number; name: string } }
+ }
+ }
+ 'posts.index': {
+ methods: ['GET', 'HEAD']
+ pattern: '/posts'
+ types: {
+ params: {}
+ query: {}
+ body: {}
+ response: { posts: Array<{ id: number; title: string }> }
+ }
+ }
+ }
+}
+
+test.group('API client | visit', (group) => {
+ group.each.setup(async () => {
+ await httpServer.create()
+ return () => httpServer.close()
+ })
+
+ group.each.setup(() => {
+ ApiClient.setRoutes(testRoutes)
+ return () => {
+ ApiClient.clearRequestHandlers()
+ ApiClient.clearSetupHooks()
+ ApiClient.clearTeardownHooks()
+ }
+ })
+
+ test('make request using named route without params', async ({ assert }) => {
+ let requestMethod: string
+ let requestUrl: string
+
+ httpServer.onRequest((req, res) => {
+ requestMethod = req.method!
+ requestUrl = req.url!
+ res.setHeader('Content-Type', 'application/json')
+ res.end(JSON.stringify({ users: [{ id: 1, name: 'John' }] }))
+ })
+
+ const client = new ApiClient(httpServer.baseUrl)
+ const response = await client.visit('users.index')
+
+ assert.equal(requestMethod!, 'GET')
+ assert.equal(requestUrl!, '/users')
+ assert.deepEqual(response.body(), { users: [{ id: 1, name: 'John' }] })
+ })
+
+ test('make request using named route with params', async ({ assert }) => {
+ let requestMethod: string
+ let requestUrl: string
+
+ httpServer.onRequest((req, res) => {
+ requestMethod = req.method!
+ requestUrl = req.url!
+ res.setHeader('Content-Type', 'application/json')
+ res.end(JSON.stringify({ user: { id: 123, name: 'John' } }))
+ })
+
+ const client = new ApiClient(httpServer.baseUrl)
+ const response = await client.visit('users.show', { id: '123' })
+
+ assert.equal(requestMethod!, 'GET')
+ assert.equal(requestUrl!, '/users/123')
+ assert.deepEqual(response.body(), { user: { id: 123, name: 'John' } })
+ })
+
+ test('use first method from methods array', async ({ assert }) => {
+ let requestMethod: string
+
+ httpServer.onRequest((req, res) => {
+ requestMethod = req.method!
+ res.setHeader('Content-Type', 'application/json')
+ res.end(JSON.stringify({ posts: [] }))
+ })
+
+ const client = new ApiClient(httpServer.baseUrl)
+ await client.visit('posts.index')
+
+ assert.equal(requestMethod!, 'GET')
+ })
+
+ test('send typed JSON body', async ({ assert }) => {
+ let requestBody: any
+
+ httpServer.onRequest((req, res) => {
+ let body = ''
+ req.on('data', (chunk) => (body += chunk))
+ req.on('end', () => {
+ requestBody = JSON.parse(body)
+ res.setHeader('Content-Type', 'application/json')
+ res.end(JSON.stringify({ user: { id: 1, name: 'John' } }))
+ })
+ })
+
+ const client = new ApiClient(httpServer.baseUrl)
+ await client.visit('users.create').json({ name: 'John', email: 'john@example.com' })
+
+ assert.deepEqual(requestBody, { name: 'John', email: 'john@example.com' })
+ })
+
+ test('send typed query string', async ({ assert }) => {
+ let requestUrl: string
+
+ httpServer.onRequest((req, res) => {
+ requestUrl = req.url!
+ res.setHeader('Content-Type', 'application/json')
+ res.end(JSON.stringify({ users: [] }))
+ })
+
+ const client = new ApiClient(httpServer.baseUrl)
+ await client.visit('users.index').qs({ page: 2, limit: 10 })
+
+ assert.equal(requestUrl!, '/users?page=2&limit=10')
+ })
+
+ test('throw error when routes registry is not configured', async ({ assert }) => {
+ ApiClient.clearRoutes()
+
+ const client = new ApiClient(httpServer.baseUrl)
+
+ await assert.rejects(() => client.visit('users.index'), /Routes registry not configured/)
+
+ // Re-configure for subsequent tests
+ ApiClient.setRoutes(testRoutes)
+ })
+
+ test('throw error when route is not found in registry', async ({ assert }) => {
+ httpServer.onRequest((_, res) => res.end())
+
+ const client = new ApiClient(httpServer.baseUrl)
+
+ await assert.rejects(
+ // @ts-expect-error - intentionally using non-existent route
+ () => client.visit('non.existent'),
+ /Route "non.existent" not found/
+ )
+ })
+
+ test('use custom pattern serializer', async ({ assert }) => {
+ let requestUrl: string
+
+ httpServer.onRequest((req, res) => {
+ requestUrl = req.url!
+ res.setHeader('Content-Type', 'application/json')
+ res.end(JSON.stringify({ user: { id: 1, name: 'John' } }))
+ })
+
+ // Custom serializer that uses {param} instead of :param
+ ApiClient.setPatternSerializer((pattern, params) => {
+ return pattern.replace(/:(\w+)/g, (_, key) => `[${params[key]}]`)
+ })
+
+ const client = new ApiClient(httpServer.baseUrl)
+ await client.visit('users.show', { id: '456' })
+
+ assert.equal(requestUrl!, '/users/[456]')
+
+ // Reset to default
+ ApiClient.setPatternSerializer((pattern, params) => {
+ return pattern.replace(/:(\w+)/g, (_, key) => String(params[key] ?? ''))
+ })
+ })
+})
+
+test.group('API client | unsafe methods', (group) => {
+ group.each.setup(async () => {
+ await httpServer.create()
+ return () => httpServer.close()
+ })
+
+ group.each.setup(() => {
+ ApiClient.setRoutes(testRoutes)
+ return () => {
+ ApiClient.clearRequestHandlers()
+ ApiClient.clearSetupHooks()
+ ApiClient.clearTeardownHooks()
+ }
+ })
+
+ test('unsafeJson allows sending invalid body', async ({ assert }) => {
+ let requestBody: any
+
+ httpServer.onRequest((req, res) => {
+ let body = ''
+ req.on('data', (chunk) => (body += chunk))
+ req.on('end', () => {
+ requestBody = JSON.parse(body)
+ res.setHeader('Content-Type', 'application/json')
+ res.end(JSON.stringify({ user: { id: 1, name: 'John' } }))
+ })
+ })
+
+ const client = new ApiClient(httpServer.baseUrl)
+ // Using unsafeJson to send data that doesn't match the expected body type
+ await client.visit('users.create').unsafeJson({ invalid: 'data', extra: 123 })
+
+ assert.deepEqual(requestBody, { invalid: 'data', extra: 123 })
+ })
+
+ test('unsafeForm allows sending invalid form data', async ({ assert }) => {
+ let requestBody: string
+
+ httpServer.onRequest((req, res) => {
+ let body = ''
+ req.on('data', (chunk) => (body += chunk))
+ req.on('end', () => {
+ requestBody = body
+ res.setHeader('Content-Type', 'application/json')
+ res.end(JSON.stringify({ user: { id: 1, name: 'John' } }))
+ })
+ })
+
+ const client = new ApiClient(httpServer.baseUrl)
+ await client.visit('users.create').unsafeForm({ wrong: 'field' })
+
+ assert.equal(requestBody!, 'wrong=field')
+ })
+
+ test('unsafeQs allows sending invalid query params', async ({ assert }) => {
+ let requestUrl: string
+
+ httpServer.onRequest((req, res) => {
+ requestUrl = req.url!
+ res.setHeader('Content-Type', 'application/json')
+ res.end(JSON.stringify({ users: [] }))
+ })
+
+ const client = new ApiClient(httpServer.baseUrl)
+ await client.visit('users.index').unsafeQs({ invalid: 'param' })
+
+ assert.equal(requestUrl!, '/users?invalid=param')
+ })
+})
+
+test.group('API client | type safety', (group) => {
+ group.each.setup(() => {
+ ApiClient.setRoutes(testRoutes)
+ return () => {
+ ApiClient.clearRequestHandlers()
+ }
+ })
+
+ test('positive', async () => {
+ const client = new ApiClient()
+
+ // visit without params when route has no params
+ client.visit('users.index')
+
+ // visit with params when route requires params
+ client.visit('users.show', { id: '123' })
+
+ // json with correct body type
+ client.visit('users.create').json({ name: 'John', email: 'john@example.com' })
+
+ // qs with correct query type
+ client.visit('users.index').qs({ page: 1 })
+
+ // response body has correct type
+ const response = await client.visit('users.index')
+ const body = response.body()
+ const users: Array<{ id: number; name: string }> = body.users
+ console.log(users)
+
+ // response body for single resource
+ const showResponse = await client.visit('users.show', { id: '1' })
+ const showBody = showResponse.body()
+ const user: { id: number; name: string } = showBody.user
+ console.log(user)
+ }).skip(true, 'Type-only test')
+
+ test('negative', async () => {
+ const client = new ApiClient()
+
+ // visit non-existent route
+ // @ts-expect-error - 'invalid.route' doesn't exist in UserRoutesRegistry
+ client.visit('invalid.route')
+
+ // visit without required params
+ // @ts-expect-error - users.show requires { id: string } param
+ client.visit('users.show')
+
+ // visit with wrong param type
+ // @ts-expect-error - id should be string, not number
+ client.visit('users.show', { id: 123 })
+
+ // visit with missing param
+ // @ts-expect-error - missing required 'id' param
+ client.visit('users.show', {})
+
+ // json with wrong body type
+ // @ts-expect-error - body should have name and email, not just 'wrong'
+ client.visit('users.create').json({ wrong: 'field' })
+
+ // json with missing required field
+ // @ts-expect-error - missing required 'email' field
+ client.visit('users.create').json({ name: 'John' })
+
+ // qs with wrong query type
+ // @ts-expect-error - 'invalid' is not a valid query param for users.index
+ client.visit('users.index').qs({ invalid: 'param' })
+
+ // response body with wrong type
+ const response = await client.visit('users.index')
+ const body = response.body()
+ // @ts-expect-error - body.users is Array<{ id: number; name: string }>, not string
+ const wrongType: string = body.users
+ console.log(wrongType)
+
+ // accessing non-existent property on response
+ // @ts-expect-error - 'nonExistent' doesn't exist on response body
+ console.log(body.nonExistent)
+ }).skip(true, 'Type-only test')
+})