From d7ee3a69a6c61253f96ba9d31dc8d3f9d9548a00 Mon Sep 17 00:00:00 2001 From: Hernan Alvarado Date: Thu, 10 Sep 2026 22:38:12 -0500 Subject: [PATCH 1/3] fix(client): resolve type inference for endpoints with schemas --- src/@types/client.ts | 40 +- src/@types/types.ts | 4 +- src/client.ts | 2 +- test/client.test.ts | 771 ------------------------------ test/client/arktype.test-d.ts | 749 +++++++++++++++++++++++++++++ test/client/index.test.ts | 286 +++++++++++ test/client/typebox.test-d.ts | 862 ++++++++++++++++++++++++++++++++++ test/client/valibot.test-d.ts | 821 ++++++++++++++++++++++++++++++++ test/client/zod.test-d.ts | 806 +++++++++++++++++++++++++++++++ 9 files changed, 3558 insertions(+), 783 deletions(-) delete mode 100644 test/client.test.ts create mode 100644 test/client/arktype.test-d.ts create mode 100644 test/client/index.test.ts create mode 100644 test/client/typebox.test-d.ts create mode 100644 test/client/valibot.test-d.ts create mode 100644 test/client/zod.test-d.ts diff --git a/src/@types/client.ts b/src/@types/client.ts index 9445783..268b931 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 @@ -34,12 +34,22 @@ type HasSchemas = : 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< + ComplementaryHeaders>, Route> & { route?: Route }> + > : unknown : unknown @@ -55,13 +65,25 @@ type InferContent> = export type Client[]> = Endpoints extends unknown[] ? Endpoints extends [infer First, ...infer Rest] ? First extends RouteEndpoint - ? Prettify< - { + ? Method extends HTTPMethod[] + ? { + [K in Lowercase]: HasSchemas extends false + ? (path: Route, ctx?: ComplementaryParams) => Awaitable> + : ( + path: Route, + ctx: Omit & + Prettify, "response">> + ) => Awaitable> + } & Client[] ? Rest : []> + : { [K in Lowercase]: HasSchemas extends false - ? (path: Route, ctx?: RequestInit) => Awaitable> - : (path: Route, ctx: Omit & InferContent) => Awaitable> + ? (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/src/client.ts b/src/client.ts index 6d880c2..69087a9 100644 --- a/src/client.ts +++ b/src/client.ts @@ -16,7 +16,7 @@ import type { Router, InferEndpoints, Client, HTTPMethod, ClientOptions } from " * * client.get("/users") */ -export function createClient>(options: ClientOptions): Client> { +export function createClient>(options: ClientOptions): Client> { const { baseURL, basePath, headers: defaultHeaders, fetch: customFetch, ...clientOptions } = options const fetchFn = customFetch ?? ((input: RequestInfo | URL, init?: RequestInit) => globalThis.fetch(input, init)) 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..1a75f84 --- /dev/null +++ b/test/client/arktype.test-d.ts @@ -0,0 +1,749 @@ +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", + }, + route: "/with-params/:param1/:param2", + }) + 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", + }) + + await client.get("/multi-status", {}) + const simpleStatusResponse = await client.get("/multi-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..03d7ebc --- /dev/null +++ b/test/client/typebox.test-d.ts @@ -0,0 +1,862 @@ +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", + }, + route: "/with-params/:param1/:param2", + }) + 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", + }) + + await client.get("/multi-status", {}) + const simpleStatusResponse = await client.get("/multi-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..86985b1 --- /dev/null +++ b/test/client/valibot.test-d.ts @@ -0,0 +1,821 @@ +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", + }, + route: "/with-params/:param1/:param2", + }) + 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: valibot.object({ + code: valibot.string(), + message: valibot.string(), + id: valibot.number(), + is_eval: valibot.boolean(), + }), + }, + }) + + const multiStatusCodeConfig = createEndpointConfig({ + schemas: { + response: { + 200: valibot.object({ + message: valibot.string(), + code: valibot.string(), + id: valibot.number(), + is_eval: valibot.boolean(), + }), + 404: valibot.object({ error: valibot.string(), code: valibot.string(), id: valibot.number() }), + }, + }, + }) + + const multiStatusCodeConfigWithDefault = createEndpointConfig({ + schemas: { + response: { + 200: valibot.object({ + code: valibot.string(), + message: valibot.string(), + id: valibot.number(), + is_eval: valibot.boolean(), + }), + 204: valibot.object({ + code: valibot.string(), + message: valibot.string(), + id: valibot.number(), + is_eval: valibot.boolean(), + }), + 302: valibot.object({ location: valibot.string() }), + 404: valibot.object({ code: valibot.string(), id: valibot.number(), error: valibot.string() }), + 500: valibot.object({ code: valibot.string(), id: valibot.number(), error: valibot.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: valibot.object({ + param1: valibot.string(), + param2: valibot.string(), + }), + 4040: valibot.object({ + error: valibot.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: valibot.object({ + message: valibot.string(), + code: 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([simpleStatusCode, multiStatusCode, multiStatusCodeWithDefault, withParams, getMethods]) + + const client = createClient({ + baseURL: "http://api.example.com", + }) + + await client.get("/multi-status", {}) + const simpleStatusResponse = await client.get("/multi-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: valibot.object({ + name: valibot.string(), + nickname: valibot.string(), + }), + headers: valibot.object({ + authorization: valibot.string(), + }), + params: valibot.object({ + userId: valibot.string(), + itemId: valibot.string(), + }), + searchParams: valibot.object({ + page: valibot.number(), + q: valibot.string(), + }), + }, + }) + + const allSchemasEndpoint = createEndpoint( + ["GET", "POST"], + "/users/:userId/items/:itemId", + (ctx) => { + return ctx.json({ + body: ctx.body, + headers: ctx.headers, + params: ctx.params, + searchParams: ctx.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/zod.test-d.ts b/test/client/zod.test-d.ts new file mode 100644 index 0000000..3a79fb9 --- /dev/null +++ b/test/client/zod.test-d.ts @@ -0,0 +1,806 @@ +import { describe, test, expectTypeOf } from "vitest" +import { z } from "zod" +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: z.object({ itemId: z.string() }), + }, + }) + + const getItem = createEndpoint( + "GET", + "/items/:itemId", + (ctx) => { + return ctx.json({ itemId: ctx.params.itemId }) + }, + getItemConfig + ) + + const getBooksConfig = createEndpointConfig({ + schemas: { + params: z.object({ bookId: z.string(), chapterId: z.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: 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 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: z.object({ + page: z.number(), + q: z.string(), + is_eval: z.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: z.object({ + code: z.string(), + state: z.string(), + code_challenge: z.string().optional(), + }), + }, + }) + + 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: z.object({ + code: z.string(), + state: z.string(), + code_challenge: z.string().optional(), + }), + }, + }) + + const callbackParam = createEndpoint( + "GET", + "/callback/:callbackId", + (ctx) => { + return ctx.json({ callbackId: ctx.params.callbackId }) + }, + callbackParamConfig + ) + + const withParamsConfig = createEndpointConfig({ + schemas: { + searchParams: z.object({ + page: z.number(), + q: z.string(), + is_eval: z.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: z.object({ + name: z.string(), + age: z.number().optional(), + }), + }, + }) + + 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: z.object({ + name: z.string(), + nickname: z.string(), + is_eval: z.boolean(), + cash: z.number(), + items: z.array(z.string()), + }), + }, + }) + + const createUser = createEndpoint( + "POST", + "/users", + (ctx) => { + return ctx.json(ctx.body) + }, + createUserConfig + ) + + const withParamsConfig = createEndpointConfig({ + schemas: { + body: z.object({ + page: z.number(), + q: z.string(), + is_eval: z.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: z.object({ + name: z.string(), + age: z.number().optional(), + }), + }, + }) + + 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: z.object({ + authorization: z.string(), + "x-csrf-token": z.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: z.object({ + name: z.string(), + nickname: z.string(), + is_eval: z.boolean(), + cash: z.number(), + items: z.array(z.string()), + }), + headers: z.object({ + authorization: z.string(), + "x-csrf-token": z.string(), + }), + }, + }) + + const createUser = createEndpoint( + "POST", + "/users", + (ctx) => { + return ctx.json(ctx.body) + }, + createUserConfig + ) + + const withParamsConfig = createEndpointConfig({ + schemas: { + headers: z.object({ + page: z.number(), + q: z.string(), + is_eval: z.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: z.object({ + authorization: z.string(), + token: z.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", + }, + route: "/with-params/:param1/:param2", + }) + 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: z.object({ + code: z.string(), + message: z.string(), + id: z.number(), + is_eval: z.boolean(), + }), + }, + }) + + const multiStatusCodeConfig = createEndpointConfig({ + schemas: { + response: { + 200: z.object({ message: z.string(), code: z.string(), id: z.number(), is_eval: z.boolean() }), + 404: z.object({ error: z.string(), code: z.string(), id: z.number() }), + }, + }, + }) + + const multiStatusCodeConfigWithDefault = createEndpointConfig({ + schemas: { + response: { + 200: z.object({ code: z.string(), message: z.string(), id: z.number(), is_eval: z.boolean() }), + 204: z.object({ code: z.string(), message: z.string(), id: z.number(), is_eval: z.boolean() }), + 302: z.object({ location: z.string() }), + 404: z.object({ code: z.string(), id: z.number(), error: z.string() }), + 500: z.object({ code: z.string(), id: z.number(), error: z.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: z.object({ + param1: z.string(), + param2: z.string(), + }), + 4040: z.object({ + error: z.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: z.object({ + message: z.string(), + code: z.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", + }) + + await client.get("/multi-status", {}) + const simpleStatusResponse = await client.get("/multi-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: z.object({ + name: z.string(), + nickname: z.string(), + }), + headers: z.object({ + authorization: z.string(), + }), + params: z.object({ + userId: z.string(), + itemId: z.string(), + }), + searchParams: z.object({ + page: z.number(), + q: z.string(), + }), + }, + }) + + const allSchemasEndpoint = createEndpoint( + ["GET", "POST"], + "/users/:userId/items/:itemId", + (ctx) => { + return ctx.json({ + body: ctx.body, + headers: ctx.headers, + params: ctx.params, + searchParams: ctx.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 } + }> + >() + }) +}) From 54de1ec2fde01b6af2b7d49db094507d51e661d1 Mon Sep 17 00:00:00 2001 From: Hernan Alvarado Date: Fri, 11 Sep 2026 11:10:06 -0500 Subject: [PATCH 2/3] chore(client): clean up types --- src/@types/client.ts | 26 ++++++++------------------ src/client.ts | 2 +- test/client/arktype.test-d.ts | 4 +--- test/client/typebox.test-d.ts | 4 +--- test/client/valibot.test-d.ts | 4 +--- test/client/zod.test-d.ts | 4 +--- 6 files changed, 13 insertions(+), 31 deletions(-) diff --git a/src/@types/client.ts b/src/@types/client.ts index 268b931..6c44289 100644 --- a/src/@types/client.ts +++ b/src/@types/client.ts @@ -27,9 +27,9 @@ 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 @@ -47,9 +47,7 @@ export type InferContent, Route ext ? [SchemaValues] extends [never] ? unknown : [SchemaValues] extends [SupportedSchemas] - ? Prettify< - ComplementaryHeaders>, Route> & { route?: Route }> - > + ? Prettify>, Route>>> : unknown : unknown @@ -62,21 +60,12 @@ export type InferContent, Route ext * 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 - ? Method extends HTTPMethod[] + ? Method extends HTTPMethod | HTTPMethod[] ? { - [K in Lowercase]: HasSchemas extends false - ? (path: Route, ctx?: ComplementaryParams) => Awaitable> - : ( - path: Route, - ctx: Omit & - Prettify, "response">> - ) => Awaitable> - } & Client[] ? Rest : []> - : { - [K in Lowercase]: HasSchemas extends false + [K in Lowercase]: HasSchemas extends false ? (path: Route, ctx?: ComplementaryParams) => Awaitable> : ( path: Route, @@ -84,6 +73,7 @@ export type Client[ Prettify, "response">> ) => Awaitable> } & Client[] ? Rest : []> + : {} : {} : {} : {} diff --git a/src/client.ts b/src/client.ts index 69087a9..6d880c2 100644 --- a/src/client.ts +++ b/src/client.ts @@ -16,7 +16,7 @@ import type { Router, InferEndpoints, Client, HTTPMethod, ClientOptions } from " * * client.get("/users") */ -export function createClient>(options: ClientOptions): Client> { +export function createClient>(options: ClientOptions): Client> { const { baseURL, basePath, headers: defaultHeaders, fetch: customFetch, ...clientOptions } = options const fetchFn = customFetch ?? ((input: RequestInfo | URL, init?: RequestInit) => globalThis.fetch(input, init)) diff --git a/test/client/arktype.test-d.ts b/test/client/arktype.test-d.ts index 1a75f84..526a416 100644 --- a/test/client/arktype.test-d.ts +++ b/test/client/arktype.test-d.ts @@ -565,7 +565,6 @@ describe("Client type inference with Zod schemas", () => { page: 2, q: "search", }, - route: "/with-params/:param1/:param2", }) expectTypeOf().toEqualTypeOf< JsonResponse<{ page: number; q: string; is_eval: boolean; param1: string; param2: string }> @@ -706,8 +705,7 @@ describe("Client type inference with Zod schemas", () => { baseURL: "http://api.example.com", }) - await client.get("/multi-status", {}) - const simpleStatusResponse = await client.get("/multi-status") + const simpleStatusResponse = await client.get("/simple-status") expectTypeOf().toEqualTypeOf< JsonResponse<{ code: string; message: string; id: number; is_eval: boolean }> >() diff --git a/test/client/typebox.test-d.ts b/test/client/typebox.test-d.ts index 03d7ebc..2dc9f2c 100644 --- a/test/client/typebox.test-d.ts +++ b/test/client/typebox.test-d.ts @@ -602,7 +602,6 @@ describe("Client type inference with Zod schemas", () => { page: 2, q: "search", }, - route: "/with-params/:param1/:param2", }) expectTypeOf().toEqualTypeOf< JsonResponse<{ page: number; q: string; is_eval: boolean; param1: string; param2: string }> @@ -758,8 +757,7 @@ describe("Client type inference with Zod schemas", () => { baseURL: "http://api.example.com", }) - await client.get("/multi-status", {}) - const simpleStatusResponse = await client.get("/multi-status") + const simpleStatusResponse = await client.get("/simple-status") expectTypeOf().toEqualTypeOf< JsonResponse<{ code: string; message: string; id: number; is_eval: boolean }> >() diff --git a/test/client/valibot.test-d.ts b/test/client/valibot.test-d.ts index 86985b1..02bc2a2 100644 --- a/test/client/valibot.test-d.ts +++ b/test/client/valibot.test-d.ts @@ -565,7 +565,6 @@ describe("Client type inference with Zod schemas", () => { page: 2, q: "search", }, - route: "/with-params/:param1/:param2", }) expectTypeOf().toEqualTypeOf< JsonResponse<{ page: number; q: string; is_eval: boolean; param1: string; param2: string }> @@ -721,8 +720,7 @@ describe("Client type inference with Zod schemas", () => { baseURL: "http://api.example.com", }) - await client.get("/multi-status", {}) - const simpleStatusResponse = await client.get("/multi-status") + const simpleStatusResponse = await client.get("/simple-status") expectTypeOf().toEqualTypeOf< JsonResponse<{ code: string; message: string; id: number; is_eval: boolean }> >() diff --git a/test/client/zod.test-d.ts b/test/client/zod.test-d.ts index 3a79fb9..6583d11 100644 --- a/test/client/zod.test-d.ts +++ b/test/client/zod.test-d.ts @@ -565,7 +565,6 @@ describe("Client type inference with Zod schemas", () => { page: 2, q: "search", }, - route: "/with-params/:param1/:param2", }) expectTypeOf().toEqualTypeOf< JsonResponse<{ page: number; q: string; is_eval: boolean; param1: string; param2: string }> @@ -706,8 +705,7 @@ describe("Client type inference with Zod schemas", () => { baseURL: "http://api.example.com", }) - await client.get("/multi-status", {}) - const simpleStatusResponse = await client.get("/multi-status") + const simpleStatusResponse = await client.get("/simple-status") expectTypeOf().toEqualTypeOf< JsonResponse<{ code: string; message: string; id: number; is_eval: boolean }> >() From b131aaeab253bc7e619ecb66ad2911ab2f22b47d Mon Sep 17 00:00:00 2001 From: Hernan Alvarado Date: Fri, 11 Sep 2026 11:11:18 -0500 Subject: [PATCH 3/3] docs: update docs --- .../content/docs/guides/validation/body.mdx | 13 ++- .../docs/guides/validation/headers.mdx | 13 ++- .../content/docs/guides/validation/index.mdx | 105 +++++++++++++++++- .../content/docs/guides/validation/params.mdx | 13 ++- .../docs/guides/validation/search-params.mdx | 13 ++- 5 files changed, 139 insertions(+), 18 deletions(-) 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