diff --git a/docs/src/content/docs/guides/validation/body.mdx b/docs/src/content/docs/guides/validation/body.mdx
index 1622232..428740a 100644
--- a/docs/src/content/docs/guides/validation/body.mdx
+++ b/docs/src/content/docs/guides/validation/body.mdx
@@ -10,6 +10,11 @@ The request body can be validated with the `schemas.body` option.
`ctx.body` defaults to `unknown`, since its shape can't be inferred.
+
+ Aura Router has limited type inference for TypeBox schemas, due to the expensive computation operations performed by TypeBox. If
+ you want to use TypeBox, use the `Static` type to infer the schema type inside the endpoint handler.
+
+
@@ -99,7 +104,7 @@ export const createUser = createEndpoint(
```ts lineNumbers
-import { Type } from "typebox"
+import { Type, type Static } from "typebox"
import { createEndpointConfig, createEndpoint } from "@aura-stack/router"
export const config = createEndpointConfig({
@@ -115,7 +120,7 @@ export const createUser = createEndpoint(
"POST",
"/users",
async (ctx) => {
- const { name, email } = ctx.body
+ const { name, email } = ctx.body as unknown as Static
return ctx.json({ id: "new-id", name, email }, { status: 201 })
},
config
@@ -235,7 +240,7 @@ export const createUser = createEndpoint(
```ts lineNumbers
-import { Type } from "typebox"
+import { Type, type Static } from "typebox"
import { createEndpointConfig, createEndpoint } from "@aura-stack/router"
export const config = createEndpointConfig({
@@ -257,7 +262,7 @@ export const createUser = createEndpoint(
"POST",
"/users",
async (ctx) => {
- const { name, email } = ctx.body
+ const { name, email } = ctx.body as unknown as Static
return ctx.json({ id: "new-id", name, email }, { status: 201 })
},
config
diff --git a/docs/src/content/docs/guides/validation/headers.mdx b/docs/src/content/docs/guides/validation/headers.mdx
index ee900ff..f31ad8b 100644
--- a/docs/src/content/docs/guides/validation/headers.mdx
+++ b/docs/src/content/docs/guides/validation/headers.mdx
@@ -15,6 +15,11 @@ Request headers can be validated with the `schemas.headers` option, which checks
correctly.
+
+ Aura Router has limited type inference for TypeBox schemas, due to the expensive computation operations performed by TypeBox. If
+ you want to use TypeBox, use the `Static` type to infer the schema type inside the endpoint handler.
+
+
@@ -104,7 +109,7 @@ export const signOut = createEndpoint(
```ts lineNumbers
-import { Type } from "typebox"
+import { Type, type Static } from "typebox"
import { createEndpointConfig, createEndpoint } from "@aura-stack/router"
export const config = createEndpointConfig({
@@ -120,7 +125,7 @@ export const signOut = createEndpoint(
"POST",
"/signOut",
async (ctx) => {
- const { authorization } = ctx.headers
+ const { authorization } = ctx.headers as unknown as Static
return ctx.json({ message: "Signed out successfully" })
},
config
@@ -237,7 +242,7 @@ export const signOut = createEndpoint(
```ts lineNumbers
-import { Type } from "typebox"
+import { Type, type Static } from "typebox"
import { createEndpointConfig, createEndpoint } from "@aura-stack/router"
export const config = createEndpointConfig({
@@ -258,7 +263,7 @@ export const signOut = createEndpoint(
"POST",
"/signOut",
async (ctx) => {
- const { authorization } = ctx.headers
+ const { authorization } = ctx.headers as unknown as Static
return ctx.json({ message: "Signed out successfully" })
},
config
diff --git a/docs/src/content/docs/guides/validation/index.mdx b/docs/src/content/docs/guides/validation/index.mdx
index cfe93b6..cf3c879 100644
--- a/docs/src/content/docs/guides/validation/index.mdx
+++ b/docs/src/content/docs/guides/validation/index.mdx
@@ -3,7 +3,21 @@ title: Schema Validation
description: Learn how to add schema validation to your Aura Router endpoints using popular validation libraries.
---
-Schema validation is an optional configuration in `createEndpoint` and `createEndpointConfig`. It enables runtime validation for `headers`, `params`, `searchParams`, `body` and `response` verifying the incoming data and giving the endpoint's request context full type safety.
+Schema validation is an optional configuration in `createEndpoint` and `createEndpointConfig`. It enables runtime validation for `headers`, `params`, `searchParams`, `body`, and `response`, verifying the incoming data and giving the endpoint's request context full type safety.
+
+
+ Aura Router doesn't ship built-in schema validation. Install a supported validation library to use this feature — see
+ [Installation](#installation) below.
+
+
+
+ Aura Router has limited type inference for TypeBox schemas, due to the expensive computation operations performed by TypeBox. If
+ you want to use TypeBox, use the `Static` type to infer the schema type inside the endpoint handler.
+
+
+
+
+
```ts lineNumbers
import { z } from "zod"
@@ -14,7 +28,7 @@ export const endpoint = createEndpoint(
"/auth/signIn",
async (ctx) => {
const { username, password } = ctx.body
- return Response.json({ message: "Successful Login" })
+ return ctx.json({ message: "Successful Login" })
},
{
schemas: {
@@ -27,6 +41,89 @@ export const endpoint = createEndpoint(
)
```
+
+
+
+
+```ts lineNumbers
+import * as valibot from "valibot"
+import { createEndpoint } from "@aura-stack/router"
+
+export const endpoint = createEndpoint(
+ "POST",
+ "/auth/signIn",
+ async (ctx) => {
+ const { username, password } = ctx.body
+ return ctx.json({ message: "Successful Login" })
+ },
+ {
+ schemas: {
+ body: valibot.object({
+ username: valibot.string(),
+ password: valibot.string(),
+ }),
+ },
+ }
+)
+```
+
+
+
+
+
+```ts lineNumbers
+import { type } from "arktype"
+import { createEndpoint } from "@aura-stack/router"
+
+export const endpoint = createEndpoint(
+ "POST",
+ "/auth/signIn",
+ async (ctx) => {
+ const { username, password } = ctx.body
+ return ctx.json({ message: "Successful Login" })
+ },
+ {
+ schemas: {
+ body: type({
+ username: "string",
+ password: "string",
+ }),
+ },
+ }
+)
+```
+
+
+
+
+
+```ts lineNumbers
+import { Type, type Static } from "typebox"
+import { createEndpoint } from "@aura-stack/router"
+
+export const endpoint = createEndpoint(
+ "POST",
+ "/auth/signIn",
+ async (ctx) => {
+ // TypeBox has limited type inference, cast manually using Static<>
+ const { username, password } = ctx.body as unknown as Static
+ return ctx.json({ message: "Successful Login" })
+ },
+ {
+ schemas: {
+ body: Type.Object({
+ username: Type.String(),
+ password: Type.String(),
+ }),
+ },
+ }
+)
+```
+
+
+
+
+
## Installation
To add schema validation support, install the corresponding validation library alongside `@aura-stack/router`. Supported validators are Zod, TypeBox, Valibot, and ArkType.
@@ -72,15 +169,19 @@ npm install arktype
Validate request headers using the `schemas.headers` option.
+
Validate request params using the `schemas.params` option.
+
Validate request search params using the `schemas.searchParams` option.
+
Validate request body using the `schemas.body` option.
+
Validate outgoing responses using the `schemas.response` option.
diff --git a/docs/src/content/docs/guides/validation/params.mdx b/docs/src/content/docs/guides/validation/params.mdx
index 6989cdf..ad5f4c2 100644
--- a/docs/src/content/docs/guides/validation/params.mdx
+++ b/docs/src/content/docs/guides/validation/params.mdx
@@ -10,6 +10,11 @@ Dynamic URL segments can be validated with the `schemas.params` option, which ch
each param is instead inferred as a plain string, extracted directly from the route pattern.
+
+ Aura Router has limited type inference for TypeBox schemas, due to the expensive computation operations performed by TypeBox. If
+ you want to use TypeBox, use the `Static` type to infer the schema type inside the endpoint handler.
+
+
@@ -99,7 +104,7 @@ export const getBookById = createEndpoint(
```ts lineNumbers
-import { Type } from "typebox"
+import { Type, type Static } from "typebox"
import { createEndpointConfig, createEndpoint } from "@aura-stack/router"
export const config = createEndpointConfig("/users/:userId/books/:bookId", {
@@ -115,7 +120,7 @@ export const getBookById = createEndpoint(
"GET",
"/users/:userId/books/:bookId",
async (ctx) => {
- const { userId, bookId } = ctx.params
+ const { userId, bookId } = ctx.params as unknown as Static
return ctx.json({ bookId })
},
config
@@ -235,7 +240,7 @@ export const getBookById = createEndpoint(
```ts lineNumbers
-import { Type } from "typebox"
+import { Type, type Static } from "typebox"
import { createEndpointConfig, createEndpoint } from "@aura-stack/router"
export const config = createEndpointConfig("/users/:userId/books/:bookId", {
@@ -257,7 +262,7 @@ export const getBookById = createEndpoint(
"GET",
"/users/:userId/books/:bookId",
async (ctx) => {
- const { userId, bookId } = ctx.params
+ const { userId, bookId } = ctx.params as unknown as Static
return ctx.json({ bookId })
},
config
diff --git a/docs/src/content/docs/guides/validation/search-params.mdx b/docs/src/content/docs/guides/validation/search-params.mdx
index 2e4c724..dc8fbb0 100644
--- a/docs/src/content/docs/guides/validation/search-params.mdx
+++ b/docs/src/content/docs/guides/validation/search-params.mdx
@@ -10,6 +10,11 @@ Search params (query params) can be validated with the `schemas.searchParams` op
schema is defined, `ctx.searchParams` is the native `URLSearchParams` object instead.
+
+ Aura Router has limited type inference for TypeBox schemas, due to the expensive computation operations performed by TypeBox. If
+ you want to use TypeBox, use the `Static` type to infer the schema type inside the endpoint handler.
+
+
@@ -107,7 +112,7 @@ export const searchUsers = createEndpoint(
```ts lineNumbers
-import { Type } from "typebox"
+import { Type, type Static } from "typebox"
import { createEndpointConfig, createEndpoint } from "@aura-stack/router"
export const config = createEndpointConfig({
@@ -130,7 +135,7 @@ export const searchUsers = createEndpoint(
"GET",
"/users/search",
async (ctx) => {
- const { q, page } = ctx.searchParams
+ const { q, page } = ctx.searchParams as unknown as Static
return ctx.json({ query: q, page, results: [] })
},
config
@@ -261,7 +266,7 @@ export const searchUsers = createEndpoint(
```ts lineNumbers
-import { Type } from "typebox"
+import { Type, type Static } from "typebox"
import { createEndpointConfig, createEndpoint } from "@aura-stack/router"
export const config = createEndpointConfig({
@@ -291,7 +296,7 @@ export const searchUsers = createEndpoint(
"GET",
"/users/search",
async (ctx) => {
- const { q, page } = ctx.searchParams
+ const { q, page } = ctx.searchParams as unknown as Static
return ctx.json({ query: q, page, results: [] })
},
config
diff --git a/src/@types/client.ts b/src/@types/client.ts
index 9445783..6c44289 100644
--- a/src/@types/client.ts
+++ b/src/@types/client.ts
@@ -1,9 +1,9 @@
import type { Type } from "arktype"
import type { ObjectSchema } from "valibot"
-import type { RequestHeaders } from "@/@types/http.ts"
+import type { HTTPMethod, RequestHeaders } from "@/@types/http.ts"
import type { infer as Infer } from "zod/v4/core"
import type { InferValibotSchema, SchemaKind, SupportedSchemas } from "@/@types/schemas.ts"
-import type { RoutePattern, EndpointConfig, Prettify, RouteEndpoint, Awaitable } from "@/@types/types.ts"
+import type { RoutePattern, EndpointConfig, Prettify, RouteEndpoint, Awaitable, GetRouteParams } from "@/@types/types.ts"
export type InferSchema> = Kind extends "zod"
? Infer
@@ -27,19 +27,27 @@ export type SchemaValues = T[keyof T]
type HasSchemas =
C extends EndpointConfig
- ? [SchemaValues] extends [never]
+ ? [SchemaValues>] extends [never]
? false
- : [SchemaValues] extends [SupportedSchemas]
+ : [SchemaValues>] extends [SupportedSchemas]
? true
: false
: false
-type InferContent> =
+type ComplementaryParams = T extends { params: any }
+ ? T
+ : keyof GetRouteParams extends never
+ ? T
+ : T & { params: GetRouteParams }
+
+type ComplementaryHeaders = T extends { headers: any } ? T : T & { headers?: HeadersInit }
+
+export type InferContent, Route extends RoutePattern> =
Config extends EndpointConfig
? [SchemaValues] extends [never]
? unknown
: [SchemaValues] extends [SupportedSchemas]
- ? RemoveUndefined>
+ ? Prettify>, Route>>>
: unknown
: unknown
@@ -52,16 +60,20 @@ type InferContent> =
* RouteEndpoint<"POST", "/users", EndpointConfig, Handler>
* ]>
*/
-export type Client[]> = Endpoints extends unknown[]
- ? Endpoints extends [infer First, ...infer Rest]
+export type Client[]> = Endpoints extends readonly unknown[]
+ ? Endpoints extends readonly [infer First, ...infer Rest]
? First extends RouteEndpoint
- ? Prettify<
- {
- [K in Lowercase]: HasSchemas extends false
- ? (path: Route, ctx?: RequestInit) => Awaitable>
- : (path: Route, ctx: Omit & InferContent) => Awaitable>
+ ? Method extends HTTPMethod | HTTPMethod[]
+ ? {
+ [K in Lowercase]: HasSchemas extends false
+ ? (path: Route, ctx?: ComplementaryParams) => Awaitable>
+ : (
+ path: Route,
+ ctx: Omit &
+ Prettify, "response">>
+ ) => Awaitable>
} & Client[] ? Rest : []>
- >
+ : {}
: {}
: {}
: {}
diff --git a/src/@types/types.ts b/src/@types/types.ts
index bed2aad..15b4f90 100644
--- a/src/@types/types.ts
+++ b/src/@types/types.ts
@@ -330,7 +330,7 @@ export interface EndpointMeta<
Schemas extends EndpointSchemas,
> {
route: Route
- method: Method
+ method: Method extends HTTPMethod[] ? Method[number] : Method
headers: ContextHeaders>
body: ContextBody>
searchParams: ContextSearchParams>
@@ -500,7 +500,7 @@ export interface RouterConfig extends GlobalCtx {
export declare const endpointsSymbol: unique symbol
-export type Router[]> = {
+export type Router[]> = {
readonly __endpoints: Endpoints
handle: (request: Request) => Awaitable
} & GetHttpHandlers
diff --git a/test/client.test.ts b/test/client.test.ts
deleted file mode 100644
index d9c08a0..0000000
--- a/test/client.test.ts
+++ /dev/null
@@ -1,771 +0,0 @@
-import { describe, test, expect, vi, beforeEach, expectTypeOf } from "vitest"
-import { type } from "arktype"
-import { z } from "zod"
-import * as valibot from "valibot"
-import * as typebox from "typebox"
-import { createRouter } from "@/router.ts"
-import { createEndpoint, createEndpointConfig } from "@/endpoint.ts"
-import { createClient } from "@/client.ts"
-import type { JsonResponse } from "@/@types/index.ts"
-
-beforeEach(() => {
- vi.stubGlobal(
- "fetch",
- vi.fn().mockResolvedValueOnce({
- status: 200,
- json: () => Promise.resolve({ success: true }),
- })
- )
-})
-
-describe("Client", () => {
- const router = createRouter([
- // @ts-ignore
- createEndpoint("GET", "/users", (ctx) => {
- return ctx.json({ users: [] })
- }),
- createEndpoint("GET", "/users/:userId", () => new Response(""), {
- schemas: { params: z.object({ userId: z.string() }) },
- }),
- createEndpoint("POST", "/users", () => new Response(""), {
- schemas: { body: z.object({ name: z.string() }) },
- }),
- createEndpoint("DELETE", "/users/:userId", () => new Response(""), {
- schemas: {
- params: z.object({ userId: z.string() }),
- searchParams: z.object({ force: z.string().optional() }),
- },
- }),
- ])
- const client = createClient({
- baseURL: "http://api.example.com",
- })
-
- test("GET request", async () => {
- await client.get("/users")
- await client.get("/users")
- expect(fetch).toHaveBeenCalledWith(
- "http://api.example.com/users",
- expect.objectContaining({
- method: "GET",
- })
- )
- })
-
- test("GET request with dynamic params", async () => {
- await client.get("/users/:userId", {
- params: { userId: "123" },
- })
- await client.get("/users/:userId", {
- params: { userId: "123" },
- })
- expect(fetch).toHaveBeenCalledWith(
- "http://api.example.com/users/123",
- expect.objectContaining({
- method: "GET",
- })
- )
- })
-
- test("POST request with search parameters", async () => {
- await client.post("/users", {
- headers: {
- "Content-Type": "application/x-www-form-urlencoded",
- },
- // @ts-ignore @todo: add support for different body types
- body: new URLSearchParams({ name: "Jane Doe" }),
- })
- })
-
- test("POST request with body", async () => {
- const body = { name: "John Doe" }
- await client.post("/users", { body, headers: { "Content-Type": "application/json" } })
-
- expect(fetch).toHaveBeenCalledWith(
- "http://api.example.com/users",
- expect.objectContaining({
- method: "POST",
- body: JSON.stringify(body),
- headers: {
- "Content-Type": "application/json",
- },
- })
- )
- })
-
- test("DELETE request with search parameters", async () => {
- await client.delete("/users/:userId", {
- params: { userId: "456" },
- searchParams: { force: "true" },
- })
-
- expect(fetch).toHaveBeenCalledWith(
- "http://api.example.com/users/456?force=true",
- expect.objectContaining({
- method: "DELETE",
- })
- )
- })
-
- test("createClient headers merging", async () => {
- const client = createClient({
- baseURL: "http://api.example.com",
- headers: { "X-API-KEY": "secret" },
- })
-
- await client.get("/users", {
- headers: { "X-Custom": "val" },
- })
-
- expect(fetch).toHaveBeenCalledWith(
- "http://api.example.com/users",
- expect.objectContaining({
- headers: expect.objectContaining({
- "X-API-KEY": "secret",
- "X-Custom": "val",
- }),
- })
- )
- })
-
- test("createClient with basePath", async () => {
- const client = createClient({
- baseURL: "http://api.example.com",
- basePath: "/v1",
- })
-
- await client.get("/users")
- expect(fetch).toHaveBeenCalledWith("http://api.example.com/v1/users", expect.objectContaining({}))
- })
-
- test("createClient with headers as a function", async () => {
- const client = createClient({
- baseURL: "http://api.example.com",
- headers: () => ({ "content-type": "application/json", "x-custom-header": "custom-value" }),
- })
- await client.get("/users")
- expect(fetch).toHaveBeenCalledWith(
- "http://api.example.com/users",
- expect.objectContaining({
- headers: expect.objectContaining({
- "content-type": "application/json",
- "x-custom-header": "custom-value",
- }),
- })
- )
- })
-
- test("createClient with dynamic headers as a function", async () => {
- const client = createClient({
- baseURL: "http://api.example.com",
- headers: async () => {
- const token = await Promise.resolve("dynamic-token")
- return {
- "content-type": "application/json",
- "x-dynamic-token": token,
- }
- },
- })
- await client.get("/users")
- expect(fetch).toHaveBeenCalledWith(
- "http://api.example.com/users",
- expect.objectContaining({
- headers: expect.objectContaining({
- "content-type": "application/json",
- "x-dynamic-token": "dynamic-token",
- }),
- })
- )
- })
-
- test("createClient with cache option", async () => {
- const client = createClient({
- baseURL: "http://api.example.com",
- cache: "no-cache",
- })
- await client.get("/users")
- expect(fetch).toHaveBeenCalledWith(
- "http://api.example.com/users",
- expect.objectContaining({
- cache: "no-cache",
- })
- )
- })
-
- test("createClient with overridden cache option", async () => {
- const client = createClient({
- baseURL: "http://api.example.com",
- cache: "no-cache",
- })
- await client.get("/users", {
- cache: "reload",
- })
- expect(fetch).toHaveBeenCalledWith(
- "http://api.example.com/users",
- expect.objectContaining({
- cache: "reload",
- })
- )
- })
-
- test("createClient with credentials option", async () => {
- const client = createClient({
- baseURL: "http://api.example.com",
- credentials: "include",
- })
- await client.get("/users")
- expect(fetch).toHaveBeenCalledWith(
- "http://api.example.com/users",
- expect.objectContaining({
- credentials: "include",
- })
- )
- })
-
- test("createClient with overridden credentials option", async () => {
- const client = createClient({
- baseURL: "http://api.example.com",
- credentials: "include",
- })
- await client.get("/users", {
- credentials: "same-origin",
- })
- expect(fetch).toHaveBeenCalledWith(
- "http://api.example.com/users",
- expect.objectContaining({
- credentials: "same-origin",
- })
- )
- })
-
- test("createClient with mode option", async () => {
- const client = createClient({
- baseURL: "http://api.example.com",
- mode: "cors",
- })
- await client.get("/users")
- expect(fetch).toHaveBeenCalledWith(
- "http://api.example.com/users",
- expect.objectContaining({
- mode: "cors",
- })
- )
- })
-
- test("createClient with overridden mode option", async () => {
- const client = createClient({
- baseURL: "http://api.example.com",
- mode: "cors",
- })
- await client.get("/users", {
- mode: "no-cors",
- })
- expect(fetch).toHaveBeenCalledWith(
- "http://api.example.com/users",
- expect.objectContaining({
- mode: "no-cors",
- })
- )
- })
-
- test("createClient with custom fetch implementation", async () => {
- const customFetch = vi.fn(() => {
- return Promise.resolve(new Response(JSON.stringify({ success: true, custom: true }), { status: 200 }))
- })
-
- const client = createClient({
- baseURL: "http://api.example.com",
- fetch: customFetch,
- })
-
- const response = await client.get("/users")
- const json = await response.json()
- expect(json).toEqual({ success: true, custom: true })
- expect(customFetch).toHaveBeenCalledWith("http://api.example.com/users", expect.objectContaining({ method: "GET" }))
- expect(fetch).not.toHaveBeenCalled()
- })
-})
-
-test("Client type inference with Zod schemas", async () => {
- const notFoundConfig = createEndpointConfig({
- schemas: {
- response: {
- 200: z.object({ message: z.string() }),
- 404: z.object({ error: z.string() }),
- },
- },
- })
-
- const notFound = createEndpoint(
- "GET",
- "/not-found",
- (ctx) => {
- return ctx.json({ error: "Not Found" }, { status: 404 })
- },
- notFoundConfig
- )
-
- const getItemConfig = createEndpointConfig({
- schemas: {
- params: z.object({ itemId: z.string() }),
- },
- })
-
- const getItem = createEndpoint(
- "GET",
- "/items/:itemId",
- (ctx) => {
- return ctx.json({ method: ctx.method })
- },
- getItemConfig
- )
-
- const createItem = createEndpoint("POST", "/items", (ctx) => {
- return ctx.json({ method: ctx.method })
- })
-
- const deleteItemConfig = createEndpointConfig({
- schemas: {
- params: z.object({ itemId: z.string() }),
- searchParams: z.object({ force: z.string().optional() }),
- },
- })
-
- const deleteItem = createEndpoint(
- "DELETE",
- "/items/:itemId",
- (ctx) => {
- return ctx.json({ method: ctx.method })
- },
- deleteItemConfig
- )
-
- const signOutConfig = createEndpointConfig({
- schemas: {
- headers: z.object({
- authorization: z.string(),
- "x-csrf-token": z.string(),
- }),
- },
- })
-
- const signOut = createEndpoint(
- "POST",
- "/signOut",
- (ctx) => {
- return ctx.json({ method: ctx.method })
- },
- signOutConfig
- )
-
- const router = createRouter([getItem, createItem, deleteItem, signOut, notFound])
-
- const client = createClient({
- baseURL: "http://api.example.com",
- })
-
- client.get("/items/:itemId", {
- params: { itemId: "123" },
- })
-
- const item = await client.get("/items/:itemId", {
- params: { itemId: "123" },
- })
- expectTypeOf().toEqualTypeOf>()
-
- const newItem = await client.post("/items", {
- body: JSON.stringify({ name: "New Item" }),
- headers: { "Content-Type": "application/json" },
- })
- expectTypeOf().toEqualTypeOf>()
-
- const deletedItem = await client.delete("/items/:itemId", {
- params: { itemId: "123" },
- searchParams: { force: "true" },
- })
- expectTypeOf().toEqualTypeOf>()
-
- const out = await client.post("/signOut", {
- headers: {
- authorization: "Bearer",
- "x-csrf-token": "token",
- },
- })
- expectTypeOf().toEqualTypeOf>()
-
- const notFoundResponse = await client.get("/not-found")
- expectTypeOf().toEqualTypeOf>()
-})
-
-test("Client type inference with Valibot schemas", async () => {
- const notFoundConfig = createEndpointConfig({
- schemas: {
- response: {
- 200: valibot.object({ message: valibot.string() }),
- 404: valibot.object({ error: valibot.string() }),
- },
- },
- })
-
- const notFound = createEndpoint(
- "GET",
- "/not-found",
- (ctx) => {
- return ctx.json({ error: "Not Found" }, { status: 404 })
- },
- notFoundConfig
- )
-
- const getItemConfig = createEndpointConfig({
- schemas: {
- params: valibot.object({ itemId: valibot.string() }),
- },
- })
-
- const getItem = createEndpoint(
- "GET",
- "/items/:itemId",
- (ctx) => {
- return ctx.json({ method: ctx.method })
- },
- getItemConfig
- )
-
- const createItem = createEndpoint("POST", "/items", (ctx) => {
- return ctx.json({ method: ctx.method })
- })
-
- const deleteItemConfig = createEndpointConfig({
- schemas: {
- params: valibot.object({ itemId: valibot.string() }),
- searchParams: valibot.object({ force: valibot.string() }),
- },
- })
-
- const deleteItem = createEndpoint(
- "DELETE",
- "/items/:itemId",
- (ctx) => {
- return ctx.json({ method: ctx.method })
- },
- deleteItemConfig
- )
-
- const signOutConfig = createEndpointConfig({
- schemas: {
- headers: valibot.object({
- authorization: valibot.string(),
- "x-csrf-token": valibot.string(),
- }),
- },
- })
-
- const signOut = createEndpoint(
- "POST",
- "/signOut",
- (ctx) => {
- return ctx.json({ method: ctx.method })
- },
- signOutConfig
- )
-
- const router = createRouter([getItem, createItem, deleteItem, signOut, notFound])
-
- const client = createClient({
- baseURL: "http://api.example.com",
- })
-
- client.get("/items/:itemId", {
- params: {
- itemId: "123",
- },
- })
-
- client.get("/items/:itemId", {
- params: { itemId: "123" },
- })
-
- const item = await client.get("/items/:itemId", {
- params: { itemId: "123" },
- })
- expectTypeOf().toEqualTypeOf>()
-
- const newItem = await client.post("/items", {
- body: JSON.stringify({ name: "New Item" }),
- headers: { "Content-Type": "application/json" },
- })
- expectTypeOf().toEqualTypeOf>()
-
- const deletedItem = await client.delete("/items/:itemId", {
- params: { itemId: "123" },
- searchParams: { force: "true" },
- })
- expectTypeOf().toEqualTypeOf>()
-
- const signedOut = await client.post("/signOut", {
- headers: {
- authorization: "Bearer",
- "x-csrf-token": "token",
- },
- })
- expectTypeOf().toEqualTypeOf>()
-
- const notFoundResponse = await client.get("/not-found")
- expectTypeOf().toEqualTypeOf>()
-})
-
-test("Client type inference with Arktype schemas", async () => {
- const notFoundConfig = createEndpointConfig({
- schemas: {
- response: {
- 200: type({ message: "string" }),
- 404: type({ error: "string" }),
- },
- },
- })
-
- const notFound = createEndpoint(
- "GET",
- "/not-found",
- (ctx) => {
- return ctx.json({ error: "Not Found" }, { status: 404 })
- },
- notFoundConfig
- )
-
- const getItemConfig = createEndpointConfig({
- schemas: {
- params: type({
- itemId: "string",
- }),
- },
- })
-
- const getItem = createEndpoint(
- "GET",
- "/items/:itemId",
- (ctx) => {
- return ctx.json({ method: ctx.method })
- },
- getItemConfig
- )
-
- const createItem = createEndpoint("POST", "/items", (ctx) => {
- return ctx.json({ method: ctx.method })
- })
-
- const deleteItemConfig = createEndpointConfig({
- schemas: {
- params: type({
- itemId: "string",
- }),
- searchParams: type({
- force: "string",
- }),
- },
- })
-
- const deleteItem = createEndpoint(
- "DELETE",
- "/items/:itemId",
- (ctx) => {
- return ctx.json({ method: ctx.method })
- },
- deleteItemConfig
- )
-
- const signOutConfig = createEndpointConfig({
- schemas: {
- headers: type({
- authorization: "string",
- "x-csrf-token": "string",
- }),
- },
- })
-
- const signOut = createEndpoint(
- "POST",
- "/signOut",
- (ctx) => {
- return ctx.json({ method: ctx.method })
- },
- signOutConfig
- )
-
- const router = createRouter([getItem, createItem, deleteItem, signOut, notFound])
-
- const client = createClient({
- baseURL: "http://api.example.com",
- })
-
- client.get("/items/:itemId", {
- params: {
- itemId: "123",
- },
- })
-
- client.get("/items/:itemId", {
- params: {
- itemId: "123",
- },
- })
-
- client.get("/items/:itemId", {
- params: { itemId: "123" },
- })
-
- const item = await client.get("/items/:itemId", {
- params: { itemId: "123" },
- })
- expectTypeOf().toEqualTypeOf>()
-
- const newItem = await client.post("/items", {
- body: JSON.stringify({ name: "New Item" }),
- headers: { "Content-Type": "application/json" },
- })
- expectTypeOf().toEqualTypeOf>()
-
- const deletedItem = await client.delete("/items/:itemId", {
- params: { itemId: "123" },
- searchParams: { force: "true" },
- })
- expectTypeOf().toEqualTypeOf>()
-
- const signedOut = await client.post("/signOut", {
- headers: {
- authorization: "Bearer",
- "x-csrf-token": "token",
- },
- })
- expectTypeOf().toEqualTypeOf>()
-
- const notFoundResponse = await client.get("/not-found")
- expectTypeOf().toEqualTypeOf>()
-})
-
-test("Client type inference with TypeBox schemas", async () => {
- const notFoundConfig = createEndpointConfig({
- schemas: {
- response: {
- 200: typebox.Object({ message: typebox.String() }),
- 404: typebox.Object({ error: typebox.String() }),
- },
- },
- })
-
- const notFound = createEndpoint(
- "GET",
- "/not-found",
- (ctx) => {
- return ctx.json({ error: "Not Found" }, { status: 404 })
- },
- notFoundConfig
- )
-
- const getItemConfig = createEndpointConfig({
- schemas: {
- params: typebox.Object({
- itemId: typebox.String(),
- }),
- },
- })
-
- const getItem = createEndpoint(
- "GET",
- "/items/:itemId",
- (ctx) => {
- return ctx.json({ method: ctx.method })
- },
- getItemConfig
- )
-
- const createItem = createEndpoint("POST", "/items", (ctx) => {
- return ctx.json({ method: ctx.method })
- })
-
- const deleteItemConfig = createEndpointConfig({
- schemas: {
- params: typebox.Object({
- itemId: typebox.String(),
- }),
- searchParams: typebox.Object({
- force: typebox.String(),
- }),
- },
- })
-
- const deleteItem = createEndpoint(
- "DELETE",
- "/items/:itemId",
- (ctx) => {
- return ctx.json({ method: ctx.method })
- },
- deleteItemConfig
- )
-
- const getItems = createEndpoint("GET", "/items", (ctx) => {
- return ctx.json({ method: ctx.method })
- })
-
- const signOutConfig = createEndpointConfig({
- schemas: {
- headers: typebox.Object({
- authorization: typebox.String(),
- "x-csrf-token": typebox.String(),
- }),
- },
- })
-
- const signOut = createEndpoint(
- "POST",
- "/signOut",
- (ctx) => {
- return ctx.json({ method: ctx.method })
- },
- signOutConfig
- )
-
- const router = createRouter([getItem, createItem, deleteItem, getItems, signOut, notFound])
-
- const client = createClient({
- baseURL: "http://api.example.com",
- })
-
- client.get("/items/:itemId", {
- params: {
- itemId: "123",
- },
- })
-
- client.get("/items/:itemId", {
- params: { itemId: "123" },
- })
-
- const item = await client.get("/items/:itemId", {
- params: { itemId: "123" },
- })
- expectTypeOf().toEqualTypeOf>()
-
- const newItem = await client.post("/items", {
- body: JSON.stringify({ name: "New Item" }),
- headers: { "Content-Type": "application/json" },
- })
- expectTypeOf().toEqualTypeOf>()
-
- const deletedItem = await client.delete("/items/:itemId", {
- params: { itemId: "123" },
- searchParams: { force: "true" },
- })
- expectTypeOf().toEqualTypeOf>()
-
- const signedOut = await client.post("/signOut", {
- headers: {
- authorization: "Bearer token",
- "x-csrf-token": "csrf-token",
- },
- })
- expectTypeOf().toEqualTypeOf>()
-
- const notFoundResponse = await client.get("/not-found")
- expectTypeOf().toEqualTypeOf>()
-})
diff --git a/test/client/arktype.test-d.ts b/test/client/arktype.test-d.ts
new file mode 100644
index 0000000..526a416
--- /dev/null
+++ b/test/client/arktype.test-d.ts
@@ -0,0 +1,747 @@
+import { describe, test, expectTypeOf } from "vitest"
+import { type } from "arktype"
+import { createRouter } from "@/router.ts"
+import { createEndpoint, createEndpointConfig } from "@/endpoint.ts"
+import { createClient } from "@/client.ts"
+import type { JsonResponse } from "@/@types/index.ts"
+
+describe("Client type inference without schemas", async () => {
+ test("With params", async () => {
+ const getChannel = createEndpoint("GET", "/channel/:param1/:param2", (ctx) => {
+ return ctx.json({ param1: ctx.params.param1, param2: ctx.params.param2 })
+ })
+
+ const getItems = createEndpoint("DELETE", "/items/:param1/:param2", (ctx) => {
+ return ctx.json({ params1: ctx.params.param1, param2: ctx.params.param2 })
+ })
+
+ const router = createRouter([getChannel, getItems])
+ const client = createClient({
+ baseURL: "http://api.example.com",
+ })
+
+ await client.get("/channel/:param1/:param2", {
+ params: {
+ param1: "value1",
+ param2: "value2",
+ },
+ })
+
+ await client.delete("/items/:param1/:param2", {
+ params: {
+ param1: "value1",
+ param2: "value2",
+ },
+ })
+ })
+})
+
+describe("Client type inference with Zod schemas", () => {
+ test("With params", async () => {
+ const getItemConfig = createEndpointConfig({
+ schemas: {
+ params: type({ itemId: "string" }),
+ },
+ })
+
+ const getItem = createEndpoint(
+ "GET",
+ "/items/:itemId",
+ (ctx) => {
+ return ctx.json({ itemId: ctx.params.itemId })
+ },
+ getItemConfig
+ )
+
+ const getBooksConfig = createEndpointConfig({
+ schemas: {
+ params: type({ bookId: "string", chapterId: "string" }),
+ },
+ })
+
+ const getBooks = createEndpoint(
+ "GET",
+ "/books/:bookId/chapters/:chapterId",
+ (ctx) => {
+ return ctx.json({ bookId: ctx.params.bookId, chapterId: ctx.params.chapterId })
+ },
+ getBooksConfig
+ )
+
+ const deleteItemConfig = createEndpointConfig({
+ schemas: {
+ params: type({ itemId: "string" }),
+ searchParams: type({ force: "string?" }),
+ },
+ })
+
+ const deleteItem = createEndpoint(
+ "DELETE",
+ "/items/:itemId",
+ (ctx) => {
+ return ctx.json({ method: ctx.method })
+ },
+ deleteItemConfig
+ )
+
+ const getMethods = createEndpoint(["GET", "POST", "DELETE"], "/methods/:methodId", (ctx) => {
+ return ctx.json({ methodId: ctx.params.methodId, method: ctx.method })
+ })
+
+ const router = createRouter([getItem, getBooks, deleteItem, getMethods])
+
+ const client = createClient({
+ baseURL: "http://api.example.com",
+ })
+
+ await client.get("/items/:itemId", {
+ params: { itemId: "123" },
+ })
+
+ const item = await client.get("/items/:itemId", {
+ params: { itemId: "123" },
+ })
+ expectTypeOf().toEqualTypeOf>()
+
+ const chapter = await client.get("/books/:bookId/chapters/:chapterId", {
+ params: { bookId: "456", chapterId: "789" },
+ })
+ expectTypeOf().toEqualTypeOf>()
+
+ const deletedItem = await client.delete("/items/:itemId", {
+ params: { itemId: "123" },
+ searchParams: { force: "true" },
+ })
+ expectTypeOf().toEqualTypeOf>()
+
+ const getMethodResponse = await client.get("/methods/:methodId", {
+ params: { methodId: "get" },
+ })
+ expectTypeOf().toEqualTypeOf<
+ JsonResponse<{ methodId: string; method: "GET" | "POST" | "DELETE" }>
+ >()
+
+ const postMethodResponse = await client.post("/methods/:methodId", {
+ params: { methodId: "post" },
+ })
+ expectTypeOf().toEqualTypeOf<
+ JsonResponse<{ methodId: string; method: "GET" | "POST" | "DELETE" }>
+ >()
+
+ const deleteMethodResponse = await client.delete("/methods/:methodId", {
+ params: { methodId: "delete" },
+ })
+ expectTypeOf().toEqualTypeOf<
+ JsonResponse<{ methodId: string; method: "GET" | "POST" | "DELETE" }>
+ >()
+ })
+
+ test("With searchParams", async () => {
+ const getItemsConfig = createEndpointConfig({
+ schemas: {
+ searchParams: type({
+ page: "number",
+ q: "string",
+ is_eval: "boolean",
+ }),
+ },
+ })
+
+ const getItems = createEndpoint(
+ "GET",
+ "/items",
+ (ctx) => {
+ return ctx.json({ page: ctx.searchParams.page, q: ctx.searchParams.q, is_eval: ctx.searchParams.is_eval })
+ },
+ getItemsConfig
+ )
+
+ const callbackConfig = createEndpointConfig({
+ schemas: {
+ searchParams: type({
+ code: "string",
+ state: "string",
+ code_challenge: "string?",
+ }),
+ },
+ })
+
+ const callback = createEndpoint(
+ "POST",
+ "/callback",
+ (ctx) => {
+ return ctx.json({
+ code: ctx.searchParams.code,
+ state: ctx.searchParams.state,
+ code_challenge: ctx.searchParams.code_challenge,
+ })
+ },
+ callbackConfig
+ )
+
+ const callbackParamConfig = createEndpointConfig({
+ schemas: {
+ searchParams: type({
+ code: "string",
+ state: "string",
+ code_challenge: "string?",
+ }),
+ },
+ })
+
+ const callbackParam = createEndpoint(
+ "GET",
+ "/callback/:callbackId",
+ (ctx) => {
+ return ctx.json({ callbackId: ctx.params.callbackId })
+ },
+ callbackParamConfig
+ )
+
+ const withParamsConfig = createEndpointConfig({
+ schemas: {
+ searchParams: type({
+ page: "number",
+ q: "string",
+ is_eval: "boolean",
+ }),
+ },
+ })
+
+ const withParams = createEndpoint(
+ "GET",
+ "/with-params/:param1/:param2",
+ (ctx) => {
+ return ctx.json({
+ page: ctx.searchParams.page,
+ q: ctx.searchParams.q,
+ is_eval: ctx.searchParams.is_eval,
+ param1: ctx.params.param1,
+ param2: ctx.params.param2,
+ })
+ },
+ withParamsConfig
+ )
+
+ const getMethodsConfig = createEndpointConfig({
+ schemas: {
+ searchParams: type({
+ name: "string",
+ age: "number?",
+ }),
+ },
+ })
+
+ const getMethods = createEndpoint(
+ ["GET", "POST", "DELETE"],
+ "/methods/:methodId",
+ (ctx) => {
+ return ctx.json({ methodId: ctx.params.methodId, method: ctx.method })
+ },
+ getMethodsConfig
+ )
+
+ const router = createRouter([getItems, callback, withParams, callbackParam, getMethods])
+
+ const client = createClient({
+ baseURL: "http://api.example.com",
+ })
+
+ const items = await client.get("/items", {
+ searchParams: { page: 1, q: "test", is_eval: true },
+ })
+ expectTypeOf().toEqualTypeOf>()
+
+ const callbackResponse = await client.post("/callback", {
+ searchParams: { code: "abc123", state: "xyz789" },
+ })
+ expectTypeOf().toEqualTypeOf<
+ JsonResponse<{ code: string; state: string; code_challenge: string | undefined }>
+ >()
+
+ await client.get("/with-params/:param1/:param2", {
+ params: { param1: "value1", param2: "value2" },
+ searchParams: { is_eval: false, page: 2, q: "search" },
+ })
+
+ const withParamsResponse = await client.get("/with-params/:param1/:param2", {
+ params: { param1: "value1", param2: "value2" },
+ searchParams: { page: 2, q: "search", is_eval: false },
+ })
+ expectTypeOf().toEqualTypeOf<
+ JsonResponse<{ page: number; q: string; is_eval: boolean; param1: string; param2: string }>
+ >()
+
+ const getMethodResponse = await client.get("/methods/:methodId", {
+ params: { methodId: "get" },
+ searchParams: { name: "John", age: 30 },
+ })
+ expectTypeOf().toEqualTypeOf<
+ JsonResponse<{ methodId: string; method: "GET" | "POST" | "DELETE" }>
+ >()
+
+ const postMethodResponse = await client.post("/methods/:methodId", {
+ params: { methodId: "post" },
+ searchParams: { name: "John", age: 30 },
+ })
+ expectTypeOf().toEqualTypeOf<
+ JsonResponse<{ methodId: string; method: "GET" | "POST" | "DELETE" }>
+ >()
+
+ const deleteMethodResponse = await client.delete("/methods/:methodId", {
+ params: { methodId: "delete" },
+ searchParams: { name: "John", age: 30 },
+ })
+ expectTypeOf().toEqualTypeOf<
+ JsonResponse<{ methodId: string; method: "GET" | "POST" | "DELETE" }>
+ >()
+ })
+
+ test("With body", async () => {
+ const createUserConfig = createEndpointConfig({
+ schemas: {
+ body: type({
+ name: "string",
+ nickname: "string",
+ is_eval: "boolean",
+ cash: "number",
+ items: "string[]",
+ }),
+ },
+ })
+
+ const createUser = createEndpoint(
+ "POST",
+ "/users",
+ (ctx) => {
+ return ctx.json(ctx.body)
+ },
+ createUserConfig
+ )
+
+ const withParamsConfig = createEndpointConfig({
+ schemas: {
+ body: type({
+ page: "number",
+ q: "string",
+ is_eval: "boolean",
+ }),
+ },
+ })
+
+ const withParams = createEndpoint(
+ "GET",
+ "/with-params/:param1/:param2",
+ (ctx) => {
+ return ctx.json({
+ page: ctx.body.page,
+ q: ctx.body.q,
+ is_eval: ctx.body.is_eval,
+ param1: ctx.params.param1,
+ param2: ctx.params.param2,
+ })
+ },
+ withParamsConfig
+ )
+
+ const getMethodsConfig = createEndpointConfig({
+ schemas: {
+ body: type({
+ name: "string",
+ age: "number?",
+ }),
+ },
+ })
+
+ const getMethods = createEndpoint(
+ ["GET", "POST", "DELETE"],
+ "/methods/:methodId",
+ (ctx) => {
+ return ctx.json({ methodId: ctx.params.methodId, method: ctx.method })
+ },
+ getMethodsConfig
+ )
+
+ const router = createRouter([createUser, withParams, getMethods])
+
+ const client = createClient({
+ baseURL: "http://api.example.com",
+ })
+
+ const newUser = await client.post("/users", {
+ body: {
+ name: "John Doe",
+ nickname: "johndoe",
+ is_eval: true,
+ cash: 100,
+ items: ["item1", "item2"],
+ },
+ })
+ expectTypeOf().toEqualTypeOf<
+ JsonResponse<{
+ name: string
+ nickname: string
+ is_eval: boolean
+ cash: number
+ items: string[]
+ }>
+ >()
+
+ const withParamsResponse = await client.get("/with-params/:param1/:param2", {
+ params: { param1: "value1", param2: "value2" },
+ body: { page: 2, q: "search", is_eval: false },
+ })
+ expectTypeOf().toEqualTypeOf<
+ JsonResponse<{ page: number; q: string; is_eval: boolean; param1: string; param2: string }>
+ >()
+
+ const getMethodResponse = await client.get("/methods/:methodId", {
+ params: { methodId: "get" },
+ body: { name: "John", age: 30 },
+ })
+ expectTypeOf().toEqualTypeOf<
+ JsonResponse<{ methodId: string; method: "GET" | "POST" | "DELETE" }>
+ >()
+
+ const postMethodResponse = await client.post("/methods/:methodId", {
+ params: { methodId: "post" },
+ body: { name: "John", age: 30 },
+ })
+ expectTypeOf().toEqualTypeOf<
+ JsonResponse<{ methodId: string; method: "GET" | "POST" | "DELETE" }>
+ >()
+
+ const deleteMethodResponse = await client.delete("/methods/:methodId", {
+ params: { methodId: "delete" },
+ body: { name: "John", age: 30 },
+ })
+ expectTypeOf().toEqualTypeOf<
+ JsonResponse<{ methodId: string; method: "GET" | "POST" | "DELETE" }>
+ >()
+ })
+
+ test("With headers", async () => {
+ const getUserConfig = createEndpointConfig({
+ schemas: {
+ headers: type({
+ authorization: "string",
+ "x-csrf-token": "string",
+ }),
+ },
+ })
+
+ const getUser = createEndpoint(
+ "GET",
+ "/users/:userId",
+ (ctx) => {
+ return ctx.json({
+ authorization: ctx.headers.authorization,
+ csrfToken: ctx.headers["x-csrf-token"],
+ })
+ },
+ getUserConfig
+ )
+
+ const createUserConfig = createEndpointConfig({
+ schemas: {
+ body: type({
+ name: "string",
+ nickname: "string",
+ is_eval: "boolean",
+ cash: "number",
+ items: "string[]",
+ }),
+ headers: type({
+ authorization: "string",
+ "x-csrf-token": "string",
+ }),
+ },
+ })
+
+ const createUser = createEndpoint(
+ "POST",
+ "/users",
+ (ctx) => {
+ return ctx.json(ctx.body)
+ },
+ createUserConfig
+ )
+
+ const withParamsConfig = createEndpointConfig({
+ schemas: {
+ headers: type({
+ page: "number",
+ q: "string",
+ is_eval: "boolean",
+ }),
+ },
+ })
+
+ const withParams = createEndpoint(
+ "GET",
+ "/with-params/:param1/:param2",
+ (ctx) => {
+ return ctx.json({
+ page: ctx.headers.page,
+ q: ctx.headers.q,
+ is_eval: ctx.headers.is_eval,
+ param1: ctx.params.param1,
+ param2: ctx.params.param2,
+ })
+ },
+ withParamsConfig
+ )
+
+ const getMethodsConfig = createEndpointConfig({
+ schemas: {
+ headers: type({
+ authorization: "string",
+ token: "string",
+ }),
+ },
+ })
+
+ const getMethods = createEndpoint(
+ ["GET", "POST", "DELETE"],
+ "/methods/:methodId",
+ (ctx) => {
+ return ctx.json({ methodId: ctx.params.methodId, method: ctx.method })
+ },
+ getMethodsConfig
+ )
+
+ const router = createRouter([getUser, createUser, withParams, getMethods])
+
+ const client = createClient({
+ baseURL: "http://api.example.com",
+ })
+
+ await client.get("/users/:userId", {
+ params: { userId: "123" },
+ headers: {
+ "x-csrf-token": "",
+ authorization: "",
+ },
+ })
+ const userResponse = await client.get("/users/:userId", {
+ params: { userId: "123" },
+ headers: {
+ authorization: "Bearer token",
+ "x-csrf-token": "csrf-token",
+ },
+ })
+ expectTypeOf().toEqualTypeOf>()
+
+ const newUser = await client.post("/users", {
+ body: {
+ name: "John Doe",
+ nickname: "johndoe",
+ is_eval: true,
+ cash: 100,
+ items: ["item1", "item2"],
+ },
+ headers: {
+ authorization: "Bearer token",
+ "x-csrf-token": "csrf-token",
+ },
+ })
+ expectTypeOf().toEqualTypeOf<
+ JsonResponse<{
+ name: string
+ nickname: string
+ is_eval: boolean
+ cash: number
+ items: string[]
+ }>
+ >()
+
+ const withParamsResponse = await client.get("/with-params/:param1/:param2", {
+ params: {
+ param1: "value1",
+ param2: "value2",
+ },
+ headers: {
+ is_eval: false,
+ page: 2,
+ q: "search",
+ },
+ })
+ expectTypeOf().toEqualTypeOf<
+ JsonResponse<{ page: number; q: string; is_eval: boolean; param1: string; param2: string }>
+ >()
+
+ const getMethodResponse = await client.get("/methods/:methodId", {
+ params: { methodId: "get" },
+ headers: { authorization: "bearer", token: "token" },
+ })
+ expectTypeOf().toEqualTypeOf<
+ JsonResponse<{ methodId: string; method: "GET" | "POST" | "DELETE" }>
+ >()
+
+ const postMethodResponse = await client.post("/methods/:methodId", {
+ params: { methodId: "post" },
+ headers: { authorization: "bearer", token: "token" },
+ })
+ expectTypeOf().toEqualTypeOf<
+ JsonResponse<{ methodId: string; method: "GET" | "POST" | "DELETE" }>
+ >()
+
+ const deleteMethodResponse = await client.delete("/methods/:methodId", {
+ params: { methodId: "delete" },
+ headers: { authorization: "bearer", token: "token" },
+ })
+ expectTypeOf().toEqualTypeOf<
+ JsonResponse<{ methodId: string; method: "GET" | "POST" | "DELETE" }>
+ >()
+ })
+
+ test("With response", async () => {
+ const simpleStatusConfig = createEndpointConfig({
+ schemas: {
+ response: type({
+ code: "string",
+ message: "string",
+ id: "number",
+ is_eval: "boolean",
+ }),
+ },
+ })
+
+ const multiStatusCodeConfig = createEndpointConfig({
+ schemas: {
+ response: {
+ 200: type({ code: "string", message: "string", id: "number", is_eval: "boolean" }),
+ 404: type({ code: "string", id: "number", error: "string" }),
+ },
+ },
+ })
+
+ const multiStatusCodeConfigWithDefault = createEndpointConfig({
+ schemas: {
+ response: {
+ 200: type({ code: "string", message: "string", id: "number", is_eval: "boolean" }),
+ 204: type({ code: "string", message: "string", id: "number", is_eval: "boolean" }),
+ 302: type({ location: "string" }),
+ 404: type({ code: "string", id: "number", error: "string" }),
+ 500: type({ code: "string", id: "number", error: "string" }),
+ },
+ },
+ })
+
+ const simpleStatusCode = createEndpoint(
+ "GET",
+ "/simple-status",
+ (ctx) => {
+ return ctx.json({ code: "200", message: "OK", id: 1, is_eval: true })
+ },
+ simpleStatusConfig
+ )
+
+ const multiStatusCode = createEndpoint(
+ "GET",
+ "/not-found",
+ (ctx) => {
+ return ctx.json({ error: "Not Found" })
+ },
+ multiStatusCodeConfig
+ )
+
+ const multiStatusCodeWithDefault = createEndpoint(
+ "GET",
+ "/multi-status",
+ (ctx) => {
+ return ctx.json({ code: "200", message: "OK", id: 1, is_eval: true })
+ },
+ multiStatusCodeConfigWithDefault
+ )
+
+ const withParamsConfig = createEndpointConfig({
+ schemas: {
+ response: {
+ 200: type({
+ param1: "string",
+ param2: "string",
+ }),
+ 4040: type({
+ error: "string",
+ }),
+ },
+ },
+ })
+
+ const withParams = createEndpoint(
+ "GET",
+ "/with-params/:param1/:param2",
+ (ctx) => {
+ return ctx.json({
+ param1: ctx.params.param1,
+ param2: ctx.params.param2,
+ })
+ },
+ withParamsConfig
+ )
+
+ const getMethodsConfig = createEndpointConfig({
+ schemas: {
+ response: type({
+ message: "string",
+ code: "string",
+ }),
+ },
+ })
+
+ const getMethods = createEndpoint(
+ ["GET", "POST", "DELETE"],
+ "/methods/:methodId",
+ (ctx) => {
+ return ctx.json({ methodId: ctx.params.methodId, method: ctx.method })
+ },
+ getMethodsConfig
+ )
+
+ const router = createRouter([simpleStatusCode, multiStatusCode, multiStatusCodeWithDefault, withParams, getMethods])
+
+ const client = createClient({
+ baseURL: "http://api.example.com",
+ })
+
+ const simpleStatusResponse = await client.get("/simple-status")
+ expectTypeOf().toEqualTypeOf<
+ JsonResponse<{ code: string; message: string; id: number; is_eval: boolean }>
+ >()
+
+ const multiStatusResponse = await client.get("/not-found")
+ expectTypeOf().toEqualTypeOf>()
+
+ const multiStatusCodeWithDefaultResponse = await client.get("/multi-status")
+ expectTypeOf().toEqualTypeOf<
+ JsonResponse<{ code: string; message: string; id: number; is_eval: boolean }>
+ >()
+
+ const withParamsResponse = await client.get("/with-params/:param1/:param2", {
+ params: { param1: "value1", param2: "value2" },
+ })
+ expectTypeOf().toEqualTypeOf>()
+
+ const getMethodResponse = await client.get("/methods/:methodId", {
+ params: { methodId: "get" },
+ })
+ expectTypeOf().toEqualTypeOf<
+ JsonResponse<{ methodId: string; method: "GET" | "POST" | "DELETE" }>
+ >()
+
+ const postMethodResponse = await client.post("/methods/:methodId", {
+ params: { methodId: "post" },
+ })
+ expectTypeOf().toEqualTypeOf<
+ JsonResponse<{ methodId: string; method: "GET" | "POST" | "DELETE" }>
+ >()
+
+ const deleteMethodResponse = await client.delete("/methods/:methodId", {
+ params: { methodId: "delete" },
+ })
+ expectTypeOf().toEqualTypeOf<
+ JsonResponse<{ methodId: string; method: "GET" | "POST" | "DELETE" }>
+ >()
+ })
+})
diff --git a/test/client/index.test.ts b/test/client/index.test.ts
new file mode 100644
index 0000000..338221e
--- /dev/null
+++ b/test/client/index.test.ts
@@ -0,0 +1,286 @@
+import { describe, test, expect, vi, beforeEach } from "vitest"
+import { z } from "zod"
+import { createRouter } from "@/router.ts"
+import { createEndpoint } from "@/endpoint.ts"
+import { createClient } from "@/client.ts"
+
+beforeEach(() => {
+ vi.stubGlobal(
+ "fetch",
+ vi.fn().mockResolvedValueOnce({
+ status: 200,
+ json: () => Promise.resolve({ success: true }),
+ })
+ )
+})
+
+describe("Client", () => {
+ const router = createRouter([
+ // @ts-ignore
+ createEndpoint("GET", "/users", (ctx) => {
+ return ctx.json({ users: [] })
+ }),
+ createEndpoint("GET", "/users/:userId", () => new Response(""), {
+ schemas: { params: z.object({ userId: z.string() }) },
+ }),
+ createEndpoint("POST", "/users", () => new Response(""), {
+ schemas: { body: z.object({ name: z.string() }) },
+ }),
+ createEndpoint("DELETE", "/users/:userId", () => new Response(""), {
+ schemas: {
+ params: z.object({ userId: z.string() }),
+ searchParams: z.object({ force: z.string().optional() }),
+ },
+ }),
+ ])
+ const client = createClient({
+ baseURL: "http://api.example.com",
+ })
+
+ test("GET request", async () => {
+ await client.get("/users")
+ await client.get("/users")
+ expect(fetch).toHaveBeenCalledWith(
+ "http://api.example.com/users",
+ expect.objectContaining({
+ method: "GET",
+ })
+ )
+ })
+
+ test("GET request with dynamic params", async () => {
+ await client.get("/users/:userId", {
+ params: { userId: "123" },
+ })
+ await client.get("/users/:userId", {
+ params: { userId: "123" },
+ })
+ expect(fetch).toHaveBeenCalledWith(
+ "http://api.example.com/users/123",
+ expect.objectContaining({
+ method: "GET",
+ })
+ )
+ })
+
+ test("POST request with search parameters", async () => {
+ await client.post("/users", {
+ headers: {
+ "Content-Type": "application/x-www-form-urlencoded",
+ },
+ // @ts-ignore @todo: add support for different body types
+ body: new URLSearchParams({ name: "Jane Doe" }),
+ })
+ })
+
+ test("POST request with body", async () => {
+ const body = { name: "John Doe" }
+ await client.post("/users", {
+ body,
+ })
+ await client.post("/users", { body, headers: { "Content-Type": "application/json" } })
+
+ expect(fetch).toHaveBeenCalledWith(
+ "http://api.example.com/users",
+ expect.objectContaining({
+ method: "POST",
+ body: JSON.stringify(body),
+ headers: {
+ "Content-Type": "application/json",
+ },
+ })
+ )
+ })
+
+ test("DELETE request with search parameters", async () => {
+ await client.delete("/users/:userId", {
+ params: { userId: "456" },
+ searchParams: { force: "true" },
+ })
+
+ expect(fetch).toHaveBeenCalledWith(
+ "http://api.example.com/users/456?force=true",
+ expect.objectContaining({
+ method: "DELETE",
+ })
+ )
+ })
+
+ test("createClient headers merging", async () => {
+ const client = createClient({
+ baseURL: "http://api.example.com",
+ headers: { "X-API-KEY": "secret" },
+ })
+
+ await client.get("/users", {
+ headers: { "X-Custom": "val" },
+ })
+
+ expect(fetch).toHaveBeenCalledWith(
+ "http://api.example.com/users",
+ expect.objectContaining({
+ headers: expect.objectContaining({
+ "X-API-KEY": "secret",
+ "X-Custom": "val",
+ }),
+ })
+ )
+ })
+
+ test("createClient with basePath", async () => {
+ const client = createClient({
+ baseURL: "http://api.example.com",
+ basePath: "/v1",
+ })
+
+ await client.get("/users")
+ expect(fetch).toHaveBeenCalledWith("http://api.example.com/v1/users", expect.objectContaining({}))
+ })
+
+ test("createClient with headers as a function", async () => {
+ const client = createClient({
+ baseURL: "http://api.example.com",
+ headers: () => ({ "content-type": "application/json", "x-custom-header": "custom-value" }),
+ })
+ await client.get("/users")
+ expect(fetch).toHaveBeenCalledWith(
+ "http://api.example.com/users",
+ expect.objectContaining({
+ headers: expect.objectContaining({
+ "content-type": "application/json",
+ "x-custom-header": "custom-value",
+ }),
+ })
+ )
+ })
+
+ test("createClient with dynamic headers as a function", async () => {
+ const client = createClient({
+ baseURL: "http://api.example.com",
+ headers: async () => {
+ const token = await Promise.resolve("dynamic-token")
+ return {
+ "content-type": "application/json",
+ "x-dynamic-token": token,
+ }
+ },
+ })
+ await client.get("/users")
+ expect(fetch).toHaveBeenCalledWith(
+ "http://api.example.com/users",
+ expect.objectContaining({
+ headers: expect.objectContaining({
+ "content-type": "application/json",
+ "x-dynamic-token": "dynamic-token",
+ }),
+ })
+ )
+ })
+
+ test("createClient with cache option", async () => {
+ const client = createClient({
+ baseURL: "http://api.example.com",
+ cache: "no-cache",
+ })
+ await client.get("/users")
+ expect(fetch).toHaveBeenCalledWith(
+ "http://api.example.com/users",
+ expect.objectContaining({
+ cache: "no-cache",
+ })
+ )
+ })
+
+ test("createClient with overridden cache option", async () => {
+ const client = createClient({
+ baseURL: "http://api.example.com",
+ cache: "no-cache",
+ })
+ await client.get("/users", {
+ cache: "reload",
+ })
+ expect(fetch).toHaveBeenCalledWith(
+ "http://api.example.com/users",
+ expect.objectContaining({
+ cache: "reload",
+ })
+ )
+ })
+
+ test("createClient with credentials option", async () => {
+ const client = createClient({
+ baseURL: "http://api.example.com",
+ credentials: "include",
+ })
+ await client.get("/users")
+ expect(fetch).toHaveBeenCalledWith(
+ "http://api.example.com/users",
+ expect.objectContaining({
+ credentials: "include",
+ })
+ )
+ })
+
+ test("createClient with overridden credentials option", async () => {
+ const client = createClient({
+ baseURL: "http://api.example.com",
+ credentials: "include",
+ })
+ await client.get("/users", {
+ credentials: "same-origin",
+ })
+ expect(fetch).toHaveBeenCalledWith(
+ "http://api.example.com/users",
+ expect.objectContaining({
+ credentials: "same-origin",
+ })
+ )
+ })
+
+ test("createClient with mode option", async () => {
+ const client = createClient({
+ baseURL: "http://api.example.com",
+ mode: "cors",
+ })
+ await client.get("/users")
+ expect(fetch).toHaveBeenCalledWith(
+ "http://api.example.com/users",
+ expect.objectContaining({
+ mode: "cors",
+ })
+ )
+ })
+
+ test("createClient with overridden mode option", async () => {
+ const client = createClient({
+ baseURL: "http://api.example.com",
+ mode: "cors",
+ })
+ await client.get("/users", {
+ mode: "no-cors",
+ })
+ expect(fetch).toHaveBeenCalledWith(
+ "http://api.example.com/users",
+ expect.objectContaining({
+ mode: "no-cors",
+ })
+ )
+ })
+
+ test("createClient with custom fetch implementation", async () => {
+ const customFetch = vi.fn(() => {
+ return Promise.resolve(new Response(JSON.stringify({ success: true, custom: true }), { status: 200 }))
+ })
+
+ const client = createClient({
+ baseURL: "http://api.example.com",
+ fetch: customFetch,
+ })
+
+ const response = await client.get("/users")
+ const json = await response.json()
+ expect(json).toEqual({ success: true, custom: true })
+ expect(customFetch).toHaveBeenCalledWith("http://api.example.com/users", expect.objectContaining({ method: "GET" }))
+ expect(fetch).not.toHaveBeenCalled()
+ })
+})
diff --git a/test/client/typebox.test-d.ts b/test/client/typebox.test-d.ts
new file mode 100644
index 0000000..2dc9f2c
--- /dev/null
+++ b/test/client/typebox.test-d.ts
@@ -0,0 +1,860 @@
+import { describe, test, expectTypeOf } from "vitest"
+import * as typebox from "typebox"
+import { createRouter } from "@/router.ts"
+import { createEndpoint, createEndpointConfig } from "@/endpoint.ts"
+import { createClient } from "@/client.ts"
+import type { JsonResponse } from "@/@types/index.ts"
+
+describe("Client type inference without schemas", async () => {
+ test("With params", async () => {
+ const getChannel = createEndpoint("GET", "/channel/:param1/:param2", (ctx) => {
+ return ctx.json({ param1: ctx.params.param1, param2: ctx.params.param2 })
+ })
+
+ const getItems = createEndpoint("DELETE", "/items/:param1/:param2", (ctx) => {
+ return ctx.json({ params1: ctx.params.param1, param2: ctx.params.param2 })
+ })
+
+ const router = createRouter([getChannel, getItems])
+ const client = createClient({
+ baseURL: "http://api.example.com",
+ })
+
+ await client.get("/channel/:param1/:param2", {
+ params: {
+ param1: "value1",
+ param2: "value2",
+ },
+ })
+
+ await client.delete("/items/:param1/:param2", {
+ params: {
+ param1: "value1",
+ param2: "value2",
+ },
+ })
+ })
+})
+
+/**
+ * Aura Router doesn't offer a complete type inference for TypeBox schemas, due to the
+ * expensive computation operations performed by TypeBox and specifically the `Static` type.
+ * This is a known limitation of TypeBox and is not specific to Aura Router. When the `Static` type
+ * is used the `Instantiations` increase exponentially, from 301_598 (current) to 1_500_000 (with `Static`),
+ * which can lead to performance issues and slow down the TypeScript compiler.
+ *
+ * We recommend use the `Static` inside the handler function.
+ */
+describe("Client type inference with Zod schemas", () => {
+ test("With params", async () => {
+ const getItemConfig = createEndpointConfig({
+ schemas: {
+ params: typebox.Object({ itemId: typebox.String() }),
+ },
+ })
+
+ const getItem = createEndpoint(
+ "GET",
+ "/items/:itemId",
+ (ctx) => {
+ const params = ctx.params as any as { itemId: string }
+ return ctx.json({ itemId: params.itemId })
+ },
+ getItemConfig
+ )
+
+ const getBooksConfig = createEndpointConfig({
+ schemas: {
+ params: typebox.Object({ bookId: typebox.String(), chapterId: typebox.String() }),
+ },
+ })
+
+ const getBooks = createEndpoint(
+ "GET",
+ "/books/:bookId/chapters/:chapterId",
+ (ctx) => {
+ const params = ctx.params as any as { bookId: string; chapterId: string }
+ return ctx.json({ bookId: params.bookId, chapterId: params.chapterId })
+ },
+ getBooksConfig
+ )
+
+ const deleteItemConfig = createEndpointConfig({
+ schemas: {
+ params: typebox.Object({ itemId: typebox.String() }),
+ searchParams: typebox.Object({ force: typebox.Optional(typebox.String()) }),
+ },
+ })
+
+ const deleteItem = createEndpoint(
+ "DELETE",
+ "/items/:itemId",
+ (ctx) => {
+ return ctx.json({ method: ctx.method })
+ },
+ deleteItemConfig
+ )
+
+ const getMethods = createEndpoint(["GET", "POST", "DELETE"], "/methods/:methodId", (ctx) => {
+ return ctx.json({ methodId: ctx.params.methodId, method: ctx.method })
+ })
+
+ const router = createRouter([getItem, getBooks, deleteItem, getMethods])
+
+ const client = createClient({
+ baseURL: "http://api.example.com",
+ })
+
+ await client.get("/items/:itemId", {
+ params: { itemId: "123" },
+ })
+
+ const item = await client.get("/items/:itemId", {
+ params: { itemId: "123" },
+ })
+ expectTypeOf().toEqualTypeOf>()
+
+ const chapter = await client.get("/books/:bookId/chapters/:chapterId", {
+ params: { bookId: "456", chapterId: "789" },
+ })
+ expectTypeOf().toEqualTypeOf>()
+
+ const deletedItem = await client.delete("/items/:itemId", {
+ params: { itemId: "123" },
+ searchParams: { force: "true" },
+ })
+ expectTypeOf().toEqualTypeOf>()
+
+ const getMethodResponse = await client.get("/methods/:methodId", {
+ params: { methodId: "get" },
+ })
+ expectTypeOf().toEqualTypeOf<
+ JsonResponse<{ methodId: string; method: "GET" | "POST" | "DELETE" }>
+ >()
+
+ const postMethodResponse = await client.post("/methods/:methodId", {
+ params: { methodId: "post" },
+ })
+ expectTypeOf().toEqualTypeOf<
+ JsonResponse<{ methodId: string; method: "GET" | "POST" | "DELETE" }>
+ >()
+
+ const deleteMethodResponse = await client.delete("/methods/:methodId", {
+ params: { methodId: "delete" },
+ })
+ expectTypeOf().toEqualTypeOf<
+ JsonResponse<{ methodId: string; method: "GET" | "POST" | "DELETE" }>
+ >()
+ })
+
+ test("With searchParams", async () => {
+ const getItemsConfig = createEndpointConfig({
+ schemas: {
+ searchParams: typebox.Object({
+ page: typebox.Number(),
+ q: typebox.String(),
+ is_eval: typebox.Boolean(),
+ }),
+ },
+ })
+
+ const getItems = createEndpoint(
+ "GET",
+ "/items",
+ (ctx) => {
+ const params = ctx.searchParams as any as { page: number; q: string; is_eval: boolean }
+ return ctx.json({ page: params.page, q: params.q, is_eval: params.is_eval })
+ },
+ getItemsConfig
+ )
+
+ const callbackConfig = createEndpointConfig({
+ schemas: {
+ searchParams: typebox.Object({
+ code: typebox.String(),
+ state: typebox.String(),
+ code_challenge: typebox.Optional(typebox.String()),
+ }),
+ },
+ })
+
+ const callback = createEndpoint(
+ "POST",
+ "/callback",
+ (ctx) => {
+ const searchParams = ctx.searchParams as any as { code: string; state: string; code_challenge?: string }
+ return ctx.json({
+ code: searchParams.code,
+ state: searchParams.state,
+ code_challenge: searchParams.code_challenge,
+ })
+ },
+ callbackConfig
+ )
+
+ const callbackParamConfig = createEndpointConfig({
+ schemas: {
+ searchParams: typebox.Object({
+ code: typebox.String(),
+ state: typebox.String(),
+ code_challenge: typebox.Optional(typebox.String()),
+ }),
+ },
+ })
+
+ const callbackParam = createEndpoint(
+ "GET",
+ "/callback/:callbackId",
+ (ctx) => {
+ return ctx.json({ callbackId: ctx.params.callbackId })
+ },
+ callbackParamConfig
+ )
+
+ const withParamsConfig = createEndpointConfig({
+ schemas: {
+ searchParams: typebox.Object({
+ page: typebox.Number(),
+ q: typebox.String(),
+ is_eval: typebox.Boolean(),
+ }),
+ },
+ })
+
+ const withParams = createEndpoint(
+ "GET",
+ "/with-params/:param1/:param2",
+ (ctx) => {
+ const searchParams = ctx.searchParams as any as { page: number; q: string; is_eval: boolean }
+ const params = ctx.params as any as { param1: string; param2: string }
+ return ctx.json({
+ page: searchParams.page,
+ q: searchParams.q,
+ is_eval: searchParams.is_eval,
+ param1: params.param1,
+ param2: params.param2,
+ })
+ },
+ withParamsConfig
+ )
+
+ const getMethodsConfig = createEndpointConfig({
+ schemas: {
+ searchParams: typebox.Object({
+ name: typebox.String(),
+ age: typebox.Optional(typebox.Number()),
+ }),
+ },
+ })
+
+ const getMethods = createEndpoint(
+ ["GET", "POST", "DELETE"],
+ "/methods/:methodId",
+ (ctx) => {
+ return ctx.json({ methodId: ctx.params.methodId, method: ctx.method })
+ },
+ getMethodsConfig
+ )
+
+ const router = createRouter([getItems, callback, withParams, callbackParam, getMethods])
+
+ const client = createClient({
+ baseURL: "http://api.example.com",
+ })
+
+ const items = await client.get("/items", {
+ searchParams: { page: 1, q: "test", is_eval: true },
+ })
+ expectTypeOf().toEqualTypeOf>()
+
+ const callbackResponse = await client.post("/callback", {
+ searchParams: { code: "abc123", state: "xyz789" },
+ })
+ expectTypeOf().toEqualTypeOf<
+ JsonResponse<{ code: string; state: string; code_challenge: string | undefined }>
+ >()
+
+ await client.get("/with-params/:param1/:param2", {
+ params: { param1: "value1", param2: "value2" },
+ searchParams: { is_eval: false, page: 2, q: "search" },
+ })
+
+ const withParamsResponse = await client.get("/with-params/:param1/:param2", {
+ params: { param1: "value1", param2: "value2" },
+ searchParams: { page: 2, q: "search", is_eval: false },
+ })
+ expectTypeOf().toEqualTypeOf<
+ JsonResponse<{ page: number; q: string; is_eval: boolean; param1: string; param2: string }>
+ >()
+
+ const getMethodResponse = await client.get("/methods/:methodId", {
+ params: { methodId: "get" },
+ searchParams: { name: "John", age: 30 },
+ })
+ expectTypeOf().toEqualTypeOf<
+ JsonResponse<{ methodId: string; method: "GET" | "POST" | "DELETE" }>
+ >()
+
+ const postMethodResponse = await client.post("/methods/:methodId", {
+ params: { methodId: "post" },
+ searchParams: { name: "John", age: 30 },
+ })
+ expectTypeOf().toEqualTypeOf<
+ JsonResponse<{ methodId: string; method: "GET" | "POST" | "DELETE" }>
+ >()
+
+ const deleteMethodResponse = await client.delete("/methods/:methodId", {
+ params: { methodId: "delete" },
+ searchParams: { name: "John", age: 30 },
+ })
+ expectTypeOf().toEqualTypeOf<
+ JsonResponse<{ methodId: string; method: "GET" | "POST" | "DELETE" }>
+ >()
+ })
+
+ test("With body", async () => {
+ const createUserConfig = createEndpointConfig({
+ schemas: {
+ body: typebox.Object({
+ name: typebox.String(),
+ nickname: typebox.String(),
+ is_eval: typebox.Boolean(),
+ cash: typebox.Number(),
+ items: typebox.Array(typebox.String()),
+ }),
+ },
+ })
+
+ const createUser = createEndpoint(
+ "POST",
+ "/users",
+ (ctx) => {
+ const body = ctx.body as any as {
+ name: string
+ nickname: string
+ is_eval: boolean
+ cash: number
+ items: string[]
+ }
+ return ctx.json(body)
+ },
+ createUserConfig
+ )
+
+ const withParamsConfig = createEndpointConfig({
+ schemas: {
+ body: typebox.Object({
+ page: typebox.Number(),
+ q: typebox.String(),
+ is_eval: typebox.Boolean(),
+ }),
+ },
+ })
+
+ const withParams = createEndpoint(
+ "GET",
+ "/with-params/:param1/:param2",
+ (ctx) => {
+ const body = ctx.body as any as { page: number; q: string; is_eval: boolean }
+ const params = ctx.params as any as { param1: string; param2: string }
+ return ctx.json({
+ page: body.page,
+ q: body.q,
+ is_eval: body.is_eval,
+ param1: params.param1,
+ param2: params.param2,
+ })
+ },
+ withParamsConfig
+ )
+
+ const getMethodsConfig = createEndpointConfig({
+ schemas: {
+ body: typebox.Object({
+ name: typebox.String(),
+ age: typebox.Optional(typebox.Number()),
+ }),
+ },
+ })
+
+ const getMethods = createEndpoint(
+ ["GET", "POST", "DELETE"],
+ "/methods/:methodId",
+ (ctx) => {
+ const params = ctx.params as any as { methodId: string }
+ return ctx.json({ methodId: params.methodId, method: ctx.method })
+ },
+ getMethodsConfig
+ )
+
+ const router = createRouter([createUser, withParams, getMethods])
+
+ const client = createClient({
+ baseURL: "http://api.example.com",
+ })
+
+ const newUser = await client.post("/users", {
+ body: {
+ name: "John Doe",
+ nickname: "johndoe",
+ is_eval: true,
+ cash: 100,
+ items: ["item1", "item2"],
+ },
+ })
+ expectTypeOf().toEqualTypeOf<
+ JsonResponse<{
+ name: string
+ nickname: string
+ is_eval: boolean
+ cash: number
+ items: string[]
+ }>
+ >()
+
+ const withParamsResponse = await client.get("/with-params/:param1/:param2", {
+ params: { param1: "value1", param2: "value2" },
+ body: { page: 2, q: "search", is_eval: false },
+ })
+ expectTypeOf().toEqualTypeOf<
+ JsonResponse<{ page: number; q: string; is_eval: boolean; param1: string; param2: string }>
+ >()
+
+ const getMethodResponse = await client.get("/methods/:methodId", {
+ params: { methodId: "get" },
+ body: { name: "John", age: 30 },
+ })
+ expectTypeOf().toEqualTypeOf<
+ JsonResponse<{ methodId: string; method: "GET" | "POST" | "DELETE" }>
+ >()
+
+ const postMethodResponse = await client.post("/methods/:methodId", {
+ params: { methodId: "post" },
+ body: { name: "John", age: 30 },
+ })
+ expectTypeOf().toEqualTypeOf<
+ JsonResponse<{ methodId: string; method: "GET" | "POST" | "DELETE" }>
+ >()
+
+ const deleteMethodResponse = await client.delete("/methods/:methodId", {
+ params: { methodId: "delete" },
+ body: { name: "John", age: 30 },
+ })
+ expectTypeOf().toEqualTypeOf<
+ JsonResponse<{ methodId: string; method: "GET" | "POST" | "DELETE" }>
+ >()
+ })
+
+ test("With headers", async () => {
+ const getUserConfig = createEndpointConfig({
+ schemas: {
+ headers: typebox.Object({
+ authorization: typebox.String(),
+ "x-csrf-token": typebox.String(),
+ }),
+ },
+ })
+
+ const getUser = createEndpoint(
+ "GET",
+ "/users/:userId",
+ (ctx) => {
+ const headers = ctx.headers as any as { authorization: string; "x-csrf-token": string }
+ return ctx.json({
+ authorization: headers.authorization,
+ csrfToken: headers["x-csrf-token"],
+ })
+ },
+ getUserConfig
+ )
+
+ const createUserConfig = createEndpointConfig({
+ schemas: {
+ body: typebox.Object({
+ name: typebox.String(),
+ nickname: typebox.String(),
+ is_eval: typebox.Boolean(),
+ cash: typebox.Number(),
+ items: typebox.Array(typebox.String()),
+ }),
+ headers: typebox.Object({
+ authorization: typebox.String(),
+ "x-csrf-token": typebox.String(),
+ }),
+ },
+ })
+
+ const createUser = createEndpoint(
+ "POST",
+ "/users",
+ (ctx) => {
+ const body = ctx.body as any as {
+ name: string
+ nickname: string
+ is_eval: boolean
+ cash: number
+ items: string[]
+ }
+ return ctx.json(body)
+ },
+ createUserConfig
+ )
+
+ const withParamsConfig = createEndpointConfig({
+ schemas: {
+ headers: typebox.Object({
+ page: typebox.Number(),
+ q: typebox.String(),
+ is_eval: typebox.Boolean(),
+ }),
+ },
+ })
+
+ const withParams = createEndpoint(
+ "GET",
+ "/with-params/:param1/:param2",
+ (ctx) => {
+ const headers = ctx.headers as any as { page: number; q: string; is_eval: boolean }
+ const params = ctx.params as any as { param1: string; param2: string }
+ return ctx.json({
+ page: headers.page,
+ q: headers.q,
+ is_eval: headers.is_eval,
+ param1: params.param1,
+ param2: params.param2,
+ })
+ },
+ withParamsConfig
+ )
+
+ const getMethodsConfig = createEndpointConfig({
+ schemas: {
+ headers: typebox.Object({
+ authorization: typebox.String(),
+ token: typebox.String(),
+ }),
+ },
+ })
+
+ const getMethods = createEndpoint(
+ ["GET", "POST", "DELETE"],
+ "/methods/:methodId",
+ (ctx) => {
+ const params = ctx.params as any as { methodId: string }
+ const method = ctx.method as "GET" | "POST" | "DELETE"
+ return ctx.json({ methodId: params.methodId, method: method })
+ },
+ getMethodsConfig
+ )
+
+ const router = createRouter([getUser, createUser, withParams, getMethods])
+
+ const client = createClient({
+ baseURL: "http://api.example.com",
+ })
+
+ await client.get("/users/:userId", {
+ params: { userId: "123" },
+ headers: {
+ "x-csrf-token": "",
+ authorization: "",
+ },
+ })
+ const userResponse = await client.get("/users/:userId", {
+ params: { userId: "123" },
+ headers: {
+ authorization: "Bearer token",
+ "x-csrf-token": "csrf-token",
+ },
+ })
+ expectTypeOf().toEqualTypeOf>()
+
+ const newUser = await client.post("/users", {
+ body: {
+ name: "John Doe",
+ nickname: "johndoe",
+ is_eval: true,
+ cash: 100,
+ items: ["item1", "item2"],
+ },
+ headers: {
+ authorization: "Bearer token",
+ "x-csrf-token": "csrf-token",
+ },
+ })
+ expectTypeOf().toEqualTypeOf<
+ JsonResponse<{
+ name: string
+ nickname: string
+ is_eval: boolean
+ cash: number
+ items: string[]
+ }>
+ >()
+
+ const withParamsResponse = await client.get("/with-params/:param1/:param2", {
+ params: {
+ param1: "value1",
+ param2: "value2",
+ },
+ headers: {
+ is_eval: false,
+ page: 2,
+ q: "search",
+ },
+ })
+ expectTypeOf().toEqualTypeOf<
+ JsonResponse<{ page: number; q: string; is_eval: boolean; param1: string; param2: string }>
+ >()
+
+ const getMethodResponse = await client.get("/methods/:methodId", {
+ params: { methodId: "get" },
+ headers: { authorization: "bearer", token: "token" },
+ })
+ expectTypeOf().toEqualTypeOf<
+ JsonResponse<{ methodId: string; method: "GET" | "POST" | "DELETE" }>
+ >()
+
+ const postMethodResponse = await client.post("/methods/:methodId", {
+ params: { methodId: "post" },
+ headers: { authorization: "bearer", token: "token" },
+ })
+ expectTypeOf().toEqualTypeOf<
+ JsonResponse<{ methodId: string; method: "GET" | "POST" | "DELETE" }>
+ >()
+
+ const deleteMethodResponse = await client.delete("/methods/:methodId", {
+ params: { methodId: "delete" },
+ headers: { authorization: "bearer", token: "token" },
+ })
+ expectTypeOf().toEqualTypeOf<
+ JsonResponse<{ methodId: string; method: "GET" | "POST" | "DELETE" }>
+ >()
+ })
+
+ test("With response", async () => {
+ const simpleStatusConfig = createEndpointConfig({
+ schemas: {
+ response: typebox.Object({
+ code: typebox.String(),
+ message: typebox.String(),
+ id: typebox.Number(),
+ is_eval: typebox.Boolean(),
+ }),
+ },
+ })
+
+ const multiStatusCodeConfig = createEndpointConfig({
+ schemas: {
+ response: {
+ 200: typebox.Object({
+ message: typebox.String(),
+ code: typebox.String(),
+ id: typebox.Number(),
+ is_eval: typebox.Boolean(),
+ }),
+ 404: typebox.Object({ error: typebox.String(), code: typebox.String(), id: typebox.Number() }),
+ },
+ },
+ })
+
+ const multiStatusCodeConfigWithDefault = createEndpointConfig({
+ schemas: {
+ response: {
+ 200: typebox.Object({
+ code: typebox.String(),
+ message: typebox.String(),
+ id: typebox.Number(),
+ is_eval: typebox.Boolean(),
+ }),
+ 204: typebox.Object({
+ code: typebox.String(),
+ message: typebox.String(),
+ id: typebox.Number(),
+ is_eval: typebox.Boolean(),
+ }),
+ 302: typebox.Object({ location: typebox.String() }),
+ 404: typebox.Object({ code: typebox.String(), id: typebox.Number(), error: typebox.String() }),
+ 500: typebox.Object({ code: typebox.String(), id: typebox.Number(), error: typebox.String() }),
+ },
+ },
+ })
+
+ const simpleStatusCode = createEndpoint(
+ "GET",
+ "/simple-status",
+ (ctx) => {
+ return ctx.json({ code: "200", message: "OK", id: 1, is_eval: true })
+ },
+ simpleStatusConfig
+ )
+
+ const multiStatusCode = createEndpoint(
+ "GET",
+ "/not-found",
+ (ctx) => {
+ return ctx.json({ error: "Not Found" })
+ },
+ multiStatusCodeConfig
+ )
+
+ const multiStatusCodeWithDefault = createEndpoint(
+ "GET",
+ "/multi-status",
+ (ctx) => {
+ return ctx.json({ code: "200", message: "OK", id: 1, is_eval: true })
+ },
+ multiStatusCodeConfigWithDefault
+ )
+
+ const withParamsConfig = createEndpointConfig({
+ schemas: {
+ response: {
+ 200: typebox.Object({
+ param1: typebox.String(),
+ param2: typebox.String(),
+ }),
+ 4040: typebox.Object({
+ error: typebox.String(),
+ }),
+ },
+ },
+ })
+
+ const withParams = createEndpoint(
+ "GET",
+ "/with-params/:param1/:param2",
+ (ctx) => {
+ return ctx.json({
+ param1: ctx.params.param1,
+ param2: ctx.params.param2,
+ })
+ },
+ withParamsConfig
+ )
+
+ const getMethodsConfig = createEndpointConfig({
+ schemas: {
+ response: typebox.Object({
+ message: typebox.String(),
+ code: typebox.String(),
+ }),
+ },
+ })
+
+ const getMethods = createEndpoint(
+ ["GET", "POST", "DELETE"],
+ "/methods/:methodId",
+ (ctx) => {
+ return ctx.json({ methodId: ctx.params.methodId, method: ctx.method })
+ },
+ getMethodsConfig
+ )
+
+ const router = createRouter([simpleStatusCode, multiStatusCode, multiStatusCodeWithDefault, withParams, getMethods])
+
+ const client = createClient({
+ baseURL: "http://api.example.com",
+ })
+
+ const simpleStatusResponse = await client.get("/simple-status")
+ expectTypeOf().toEqualTypeOf<
+ JsonResponse<{ code: string; message: string; id: number; is_eval: boolean }>
+ >()
+
+ const multiStatusResponse = await client.get("/not-found")
+ expectTypeOf().toEqualTypeOf>()
+
+ const multiStatusCodeWithDefaultResponse = await client.get("/multi-status")
+ expectTypeOf().toEqualTypeOf<
+ JsonResponse<{ code: string; message: string; id: number; is_eval: boolean }>
+ >()
+
+ const withParamsResponse = await client.get("/with-params/:param1/:param2", {
+ params: { param1: "value1", param2: "value2" },
+ })
+ expectTypeOf().toEqualTypeOf>()
+
+ const getMethodResponse = await client.get("/methods/:methodId", {
+ params: { methodId: "get" },
+ })
+ expectTypeOf().toEqualTypeOf<
+ JsonResponse<{ methodId: string; method: "GET" | "POST" | "DELETE" }>
+ >()
+
+ const postMethodResponse = await client.post("/methods/:methodId", {
+ params: { methodId: "post" },
+ })
+ expectTypeOf().toEqualTypeOf<
+ JsonResponse<{ methodId: string; method: "GET" | "POST" | "DELETE" }>
+ >()
+
+ const deleteMethodResponse = await client.delete("/methods/:methodId", {
+ params: { methodId: "delete" },
+ })
+ expectTypeOf().toEqualTypeOf<
+ JsonResponse<{ methodId: string; method: "GET" | "POST" | "DELETE" }>
+ >()
+ })
+
+ test("With all schemas", async () => {
+ const allSchemasConfig = createEndpointConfig({
+ schemas: {
+ body: typebox.Object({
+ name: typebox.String(),
+ nickname: typebox.String(),
+ }),
+ headers: typebox.Object({
+ authorization: typebox.String(),
+ }),
+ params: typebox.Object({
+ userId: typebox.String(),
+ itemId: typebox.String(),
+ }),
+ searchParams: typebox.Object({
+ page: typebox.Number(),
+ q: typebox.String(),
+ }),
+ },
+ })
+
+ const allSchemasEndpoint = createEndpoint(
+ ["GET", "POST"],
+ "/users/:userId/items/:itemId",
+ (ctx) => {
+ const body = ctx.body as any as { name: string; nickname: string }
+ const headers = ctx.headers as any as { authorization: string }
+ const params = ctx.params as any as { userId: string; itemId: string }
+ const searchParams = ctx.searchParams as any as { page: number; q: string }
+ return ctx.json({
+ body: body,
+ headers: headers,
+ params: params,
+ searchParams: searchParams,
+ })
+ },
+ allSchemasConfig
+ )
+
+ const router = createRouter([allSchemasEndpoint])
+ const client = createClient({
+ baseURL: "http://api.example.com",
+ })
+
+ const allSchemasResponse = await client.post("/users/:userId/items/:itemId", {
+ params: { userId: "123", itemId: "456" },
+ searchParams: { page: 1, q: "test" },
+ headers: { authorization: "Bearer token" },
+ body: { name: "John Doe", nickname: "johndoe" },
+ })
+
+ expectTypeOf().toEqualTypeOf<
+ JsonResponse<{
+ body: { name: string; nickname: string }
+ headers: { authorization: string }
+ params: { userId: string; itemId: string }
+ searchParams: { page: number; q: string }
+ }>
+ >()
+ })
+})
diff --git a/test/client/valibot.test-d.ts b/test/client/valibot.test-d.ts
new file mode 100644
index 0000000..02bc2a2
--- /dev/null
+++ b/test/client/valibot.test-d.ts
@@ -0,0 +1,819 @@
+import { describe, test, expectTypeOf } from "vitest"
+import * as valibot from "valibot"
+import { createRouter } from "@/router.ts"
+import { createEndpoint, createEndpointConfig } from "@/endpoint.ts"
+import { createClient } from "@/client.ts"
+import type { JsonResponse } from "@/@types/index.ts"
+
+describe("Client type inference without schemas", async () => {
+ test("With params", async () => {
+ const getChannel = createEndpoint("GET", "/channel/:param1/:param2", (ctx) => {
+ return ctx.json({ param1: ctx.params.param1, param2: ctx.params.param2 })
+ })
+
+ const getItems = createEndpoint("DELETE", "/items/:param1/:param2", (ctx) => {
+ return ctx.json({ params1: ctx.params.param1, param2: ctx.params.param2 })
+ })
+
+ const router = createRouter([getChannel, getItems])
+ const client = createClient({
+ baseURL: "http://api.example.com",
+ })
+
+ await client.get("/channel/:param1/:param2", {
+ params: {
+ param1: "value1",
+ param2: "value2",
+ },
+ })
+
+ await client.delete("/items/:param1/:param2", {
+ params: {
+ param1: "value1",
+ param2: "value2",
+ },
+ })
+ })
+})
+
+describe("Client type inference with Zod schemas", () => {
+ test("With params", async () => {
+ const getItemConfig = createEndpointConfig({
+ schemas: {
+ params: valibot.object({ itemId: valibot.string() }),
+ },
+ })
+
+ const getItem = createEndpoint(
+ "GET",
+ "/items/:itemId",
+ (ctx) => {
+ return ctx.json({ itemId: ctx.params.itemId })
+ },
+ getItemConfig
+ )
+
+ const getBooksConfig = createEndpointConfig({
+ schemas: {
+ params: valibot.object({ bookId: valibot.string(), chapterId: valibot.string() }),
+ },
+ })
+
+ const getBooks = createEndpoint(
+ "GET",
+ "/books/:bookId/chapters/:chapterId",
+ (ctx) => {
+ return ctx.json({ bookId: ctx.params.bookId, chapterId: ctx.params.chapterId })
+ },
+ getBooksConfig
+ )
+
+ const deleteItemConfig = createEndpointConfig({
+ schemas: {
+ params: valibot.object({ itemId: valibot.string() }),
+ searchParams: valibot.object({ force: valibot.optional(valibot.string()) }),
+ },
+ })
+
+ const deleteItem = createEndpoint(
+ "DELETE",
+ "/items/:itemId",
+ (ctx) => {
+ return ctx.json({ method: ctx.method })
+ },
+ deleteItemConfig
+ )
+
+ const getMethods = createEndpoint(["GET", "POST", "DELETE"], "/methods/:methodId", (ctx) => {
+ return ctx.json({ methodId: ctx.params.methodId, method: ctx.method })
+ })
+
+ const router = createRouter([getItem, getBooks, deleteItem, getMethods])
+
+ const client = createClient({
+ baseURL: "http://api.example.com",
+ })
+
+ await client.get("/items/:itemId", {
+ params: { itemId: "123" },
+ })
+
+ const item = await client.get("/items/:itemId", {
+ params: { itemId: "123" },
+ })
+ expectTypeOf().toEqualTypeOf>()
+
+ const chapter = await client.get("/books/:bookId/chapters/:chapterId", {
+ params: { bookId: "456", chapterId: "789" },
+ })
+ expectTypeOf().toEqualTypeOf>()
+
+ const deletedItem = await client.delete("/items/:itemId", {
+ params: { itemId: "123" },
+ searchParams: { force: "true" },
+ })
+ expectTypeOf().toEqualTypeOf>()
+
+ const getMethodResponse = await client.get("/methods/:methodId", {
+ params: { methodId: "get" },
+ })
+ expectTypeOf().toEqualTypeOf<
+ JsonResponse<{ methodId: string; method: "GET" | "POST" | "DELETE" }>
+ >()
+
+ const postMethodResponse = await client.post("/methods/:methodId", {
+ params: { methodId: "post" },
+ })
+ expectTypeOf().toEqualTypeOf<
+ JsonResponse<{ methodId: string; method: "GET" | "POST" | "DELETE" }>
+ >()
+
+ const deleteMethodResponse = await client.delete("/methods/:methodId", {
+ params: { methodId: "delete" },
+ })
+ expectTypeOf().toEqualTypeOf<
+ JsonResponse<{ methodId: string; method: "GET" | "POST" | "DELETE" }>
+ >()
+ })
+
+ test("With searchParams", async () => {
+ const getItemsConfig = createEndpointConfig({
+ schemas: {
+ searchParams: valibot.object({
+ page: valibot.number(),
+ q: valibot.string(),
+ is_eval: valibot.boolean(),
+ }),
+ },
+ })
+
+ const getItems = createEndpoint(
+ "GET",
+ "/items",
+ (ctx) => {
+ return ctx.json({ page: ctx.searchParams.page, q: ctx.searchParams.q, is_eval: ctx.searchParams.is_eval })
+ },
+ getItemsConfig
+ )
+
+ const callbackConfig = createEndpointConfig({
+ schemas: {
+ searchParams: valibot.object({
+ code: valibot.string(),
+ state: valibot.string(),
+ code_challenge: valibot.optional(valibot.string()),
+ }),
+ },
+ })
+
+ const callback = createEndpoint(
+ "POST",
+ "/callback",
+ (ctx) => {
+ return ctx.json({
+ code: ctx.searchParams.code,
+ state: ctx.searchParams.state,
+ code_challenge: ctx.searchParams.code_challenge,
+ })
+ },
+ callbackConfig
+ )
+
+ const callbackParamConfig = createEndpointConfig({
+ schemas: {
+ searchParams: valibot.object({
+ code: valibot.string(),
+ state: valibot.string(),
+ code_challenge: valibot.optional(valibot.string()),
+ }),
+ },
+ })
+
+ const callbackParam = createEndpoint(
+ "GET",
+ "/callback/:callbackId",
+ (ctx) => {
+ return ctx.json({ callbackId: ctx.params.callbackId })
+ },
+ callbackParamConfig
+ )
+
+ const withParamsConfig = createEndpointConfig({
+ schemas: {
+ searchParams: valibot.object({
+ page: valibot.number(),
+ q: valibot.string(),
+ is_eval: valibot.boolean(),
+ }),
+ },
+ })
+
+ const withParams = createEndpoint(
+ "GET",
+ "/with-params/:param1/:param2",
+ (ctx) => {
+ return ctx.json({
+ page: ctx.searchParams.page,
+ q: ctx.searchParams.q,
+ is_eval: ctx.searchParams.is_eval,
+ param1: ctx.params.param1,
+ param2: ctx.params.param2,
+ })
+ },
+ withParamsConfig
+ )
+
+ const getMethodsConfig = createEndpointConfig({
+ schemas: {
+ searchParams: valibot.object({
+ name: valibot.string(),
+ age: valibot.optional(valibot.number()),
+ }),
+ },
+ })
+
+ const getMethods = createEndpoint(
+ ["GET", "POST", "DELETE"],
+ "/methods/:methodId",
+ (ctx) => {
+ return ctx.json({ methodId: ctx.params.methodId, method: ctx.method })
+ },
+ getMethodsConfig
+ )
+
+ const router = createRouter([getItems, callback, withParams, callbackParam, getMethods])
+
+ const client = createClient({
+ baseURL: "http://api.example.com",
+ })
+
+ const items = await client.get("/items", {
+ searchParams: { page: 1, q: "test", is_eval: true },
+ })
+ expectTypeOf().toEqualTypeOf>()
+
+ const callbackResponse = await client.post("/callback", {
+ searchParams: { code: "abc123", state: "xyz789" },
+ })
+ expectTypeOf().toEqualTypeOf<
+ JsonResponse<{ code: string; state: string; code_challenge: string | undefined }>
+ >()
+
+ await client.get("/with-params/:param1/:param2", {
+ params: { param1: "value1", param2: "value2" },
+ searchParams: { is_eval: false, page: 2, q: "search" },
+ })
+
+ const withParamsResponse = await client.get("/with-params/:param1/:param2", {
+ params: { param1: "value1", param2: "value2" },
+ searchParams: { page: 2, q: "search", is_eval: false },
+ })
+ expectTypeOf().toEqualTypeOf<
+ JsonResponse<{ page: number; q: string; is_eval: boolean; param1: string; param2: string }>
+ >()
+
+ const getMethodResponse = await client.get("/methods/:methodId", {
+ params: { methodId: "get" },
+ searchParams: { name: "John", age: 30 },
+ })
+ expectTypeOf().toEqualTypeOf<
+ JsonResponse<{ methodId: string; method: "GET" | "POST" | "DELETE" }>
+ >()
+
+ const postMethodResponse = await client.post("/methods/:methodId", {
+ params: { methodId: "post" },
+ searchParams: { name: "John", age: 30 },
+ })
+ expectTypeOf().toEqualTypeOf<
+ JsonResponse<{ methodId: string; method: "GET" | "POST" | "DELETE" }>
+ >()
+
+ const deleteMethodResponse = await client.delete("/methods/:methodId", {
+ params: { methodId: "delete" },
+ searchParams: { name: "John", age: 30 },
+ })
+ expectTypeOf().toEqualTypeOf<
+ JsonResponse<{ methodId: string; method: "GET" | "POST" | "DELETE" }>
+ >()
+ })
+
+ test("With body", async () => {
+ const createUserConfig = createEndpointConfig({
+ schemas: {
+ body: valibot.object({
+ name: valibot.string(),
+ nickname: valibot.string(),
+ is_eval: valibot.boolean(),
+ cash: valibot.number(),
+ items: valibot.array(valibot.string()),
+ }),
+ },
+ })
+
+ const createUser = createEndpoint(
+ "POST",
+ "/users",
+ (ctx) => {
+ return ctx.json(ctx.body)
+ },
+ createUserConfig
+ )
+
+ const withParamsConfig = createEndpointConfig({
+ schemas: {
+ body: valibot.object({
+ page: valibot.number(),
+ q: valibot.string(),
+ is_eval: valibot.boolean(),
+ }),
+ },
+ })
+
+ const withParams = createEndpoint(
+ "GET",
+ "/with-params/:param1/:param2",
+ (ctx) => {
+ return ctx.json({
+ page: ctx.body.page,
+ q: ctx.body.q,
+ is_eval: ctx.body.is_eval,
+ param1: ctx.params.param1,
+ param2: ctx.params.param2,
+ })
+ },
+ withParamsConfig
+ )
+
+ const getMethodsConfig = createEndpointConfig({
+ schemas: {
+ body: valibot.object({
+ name: valibot.string(),
+ age: valibot.optional(valibot.number()),
+ }),
+ },
+ })
+
+ const getMethods = createEndpoint(
+ ["GET", "POST", "DELETE"],
+ "/methods/:methodId",
+ (ctx) => {
+ return ctx.json({ methodId: ctx.params.methodId, method: ctx.method })
+ },
+ getMethodsConfig
+ )
+
+ const router = createRouter([createUser, withParams, getMethods])
+
+ const client = createClient({
+ baseURL: "http://api.example.com",
+ })
+
+ const newUser = await client.post("/users", {
+ body: {
+ name: "John Doe",
+ nickname: "johndoe",
+ is_eval: true,
+ cash: 100,
+ items: ["item1", "item2"],
+ },
+ })
+ expectTypeOf().toEqualTypeOf<
+ JsonResponse<{
+ name: string
+ nickname: string
+ is_eval: boolean
+ cash: number
+ items: string[]
+ }>
+ >()
+
+ const withParamsResponse = await client.get("/with-params/:param1/:param2", {
+ params: { param1: "value1", param2: "value2" },
+ body: { page: 2, q: "search", is_eval: false },
+ })
+ expectTypeOf().toEqualTypeOf<
+ JsonResponse<{ page: number; q: string; is_eval: boolean; param1: string; param2: string }>
+ >()
+
+ const getMethodResponse = await client.get("/methods/:methodId", {
+ params: { methodId: "get" },
+ body: { name: "John", age: 30 },
+ })
+ expectTypeOf().toEqualTypeOf<
+ JsonResponse<{ methodId: string; method: "GET" | "POST" | "DELETE" }>
+ >()
+
+ const postMethodResponse = await client.post("/methods/:methodId", {
+ params: { methodId: "post" },
+ body: { name: "John", age: 30 },
+ })
+ expectTypeOf().toEqualTypeOf<
+ JsonResponse<{ methodId: string; method: "GET" | "POST" | "DELETE" }>
+ >()
+
+ const deleteMethodResponse = await client.delete("/methods/:methodId", {
+ params: { methodId: "delete" },
+ body: { name: "John", age: 30 },
+ })
+ expectTypeOf().toEqualTypeOf<
+ JsonResponse<{ methodId: string; method: "GET" | "POST" | "DELETE" }>
+ >()
+ })
+
+ test("With headers", async () => {
+ const getUserConfig = createEndpointConfig({
+ schemas: {
+ headers: valibot.object({
+ authorization: valibot.string(),
+ "x-csrf-token": valibot.string(),
+ }),
+ },
+ })
+
+ const getUser = createEndpoint(
+ "GET",
+ "/users/:userId",
+ (ctx) => {
+ return ctx.json({
+ authorization: ctx.headers.authorization,
+ csrfToken: ctx.headers["x-csrf-token"],
+ })
+ },
+ getUserConfig
+ )
+
+ const createUserConfig = createEndpointConfig({
+ schemas: {
+ body: valibot.object({
+ name: valibot.string(),
+ nickname: valibot.string(),
+ is_eval: valibot.boolean(),
+ cash: valibot.number(),
+ items: valibot.array(valibot.string()),
+ }),
+ headers: valibot.object({
+ authorization: valibot.string(),
+ "x-csrf-token": valibot.string(),
+ }),
+ },
+ })
+
+ const createUser = createEndpoint(
+ "POST",
+ "/users",
+ (ctx) => {
+ return ctx.json(ctx.body)
+ },
+ createUserConfig
+ )
+
+ const withParamsConfig = createEndpointConfig({
+ schemas: {
+ headers: valibot.object({
+ page: valibot.number(),
+ q: valibot.string(),
+ is_eval: valibot.boolean(),
+ }),
+ },
+ })
+
+ const withParams = createEndpoint(
+ "GET",
+ "/with-params/:param1/:param2",
+ (ctx) => {
+ return ctx.json({
+ page: ctx.headers.page,
+ q: ctx.headers.q,
+ is_eval: ctx.headers.is_eval,
+ param1: ctx.params.param1,
+ param2: ctx.params.param2,
+ })
+ },
+ withParamsConfig
+ )
+
+ const getMethodsConfig = createEndpointConfig({
+ schemas: {
+ headers: valibot.object({
+ authorization: valibot.string(),
+ token: valibot.string(),
+ }),
+ },
+ })
+
+ const getMethods = createEndpoint(
+ ["GET", "POST", "DELETE"],
+ "/methods/:methodId",
+ (ctx) => {
+ return ctx.json({ methodId: ctx.params.methodId, method: ctx.method })
+ },
+ getMethodsConfig
+ )
+
+ const router = createRouter([getUser, createUser, withParams, getMethods])
+
+ const client = createClient({
+ baseURL: "http://api.example.com",
+ })
+
+ await client.get("/users/:userId", {
+ params: { userId: "123" },
+ headers: {
+ "x-csrf-token": "",
+ authorization: "",
+ },
+ })
+ const userResponse = await client.get("/users/:userId", {
+ params: { userId: "123" },
+ headers: {
+ authorization: "Bearer token",
+ "x-csrf-token": "csrf-token",
+ },
+ })
+ expectTypeOf().toEqualTypeOf>()
+
+ const newUser = await client.post("/users", {
+ body: {
+ name: "John Doe",
+ nickname: "johndoe",
+ is_eval: true,
+ cash: 100,
+ items: ["item1", "item2"],
+ },
+ headers: {
+ authorization: "Bearer token",
+ "x-csrf-token": "csrf-token",
+ },
+ })
+ expectTypeOf().toEqualTypeOf<
+ JsonResponse<{
+ name: string
+ nickname: string
+ is_eval: boolean
+ cash: number
+ items: string[]
+ }>
+ >()
+
+ const withParamsResponse = await client.get("/with-params/:param1/:param2", {
+ params: {
+ param1: "value1",
+ param2: "value2",
+ },
+ headers: {
+ is_eval: false,
+ page: 2,
+ q: "search",
+ },
+ })
+ expectTypeOf