UNPKG

serverstruct

Version:

Type safe and modular servers with H3

511 lines (405 loc) 13.8 kB
# Serverstruct OpenAPI OpenAPI integration for [serverstruct](https://github.com/eriicafes/serverstruct) with [zod-openapi](https://github.com/samchungy/zod-openapi). Define OpenAPI operations alongside your route handlers using Zod schemas. Request parameters and body are validated at runtime, and responses are fully typed. ## Installation ```sh npm i zod zod-openapi ``` ## Quick Start ```typescript import { application, serve } from "serverstruct"; import { z } from "zod"; import { jsonRequest, jsonResponse, schemas, useRouter, } from "serverstruct/openapi"; class PostSchemas { static createPost = schemas({ body: z.object({ title: z.string() }), response: z.object({ title: z.string() }), }); } const app = application((app) => { const router = useRouter(app); router.post( "/posts", { operationId: "createPost", requestBody: jsonRequest(PostSchemas.createPost.body), responses: { 201: jsonResponse(PostSchemas.createPost.response, { description: "Post created", }), }, }, async (event, ctx) => { const body = await ctx.body(event); // body is typed as { title: string } return ctx.reply(event, 201, { title: body.title }); }, ); router.document("/docs", { openapi: "3.1.0", info: { title: "My API", version: "1.0.0" }, }); }); serve(app, { port: 3000 }); ``` ## Router `useRouter(app)` returns an `OpenApiRouter` that combines OpenAPI path registration with H3 route registration. Each method registers the operation on the OpenAPI paths and the handler on the H3 app simultaneously. | Method | Description | | ---------- | ----------------------------------------------------------- | | `get` | Register a GET route | | `post` | Register a POST route | | `put` | Register a PUT route | | `delete` | Register a DELETE route | | `patch` | Register a PATCH route | | `all` | Register a route for all standard HTTP methods | | `on` | Register a route for specific HTTP methods | | `route` | Register standalone [`Route`](#route) definitions | | `mount` | Mount a sub-app and include its OpenAPI paths | | `paths` | Return the accumulated OpenAPI paths object | | `document` | Serve the OpenAPI document and optional Scalar reference UI | Use `router.mount()` to mount an app that has paths defined with `useRouter`. ```typescript import { application, controller, serve } from "serverstruct"; import { z } from "zod"; import { jsonRequest, jsonResponse, useRouter } from "serverstruct/openapi"; const postsController = controller((app) => { const router = useRouter(app); router.post( "/", { operationId: "createPost", requestBody: jsonRequest(z.object({ title: z.string() })), responses: { 201: jsonResponse(z.object({ title: z.string() }), { description: "Post created", }), }, }, async (event, ctx) => { const body = await ctx.body(event); // body is typed as { title: string } return ctx.reply(event, 201, { title: body.title }); }, ); }); const app = application((app, box) => { const router = useRouter(app); // mount sub-app router.mount("/posts", box.get(postsController)); // or mount multiple at once router.mount(box, { "/posts": postsController, }); router.document("/docs", { openapi: "3.1.0", info: { title: "My API", version: "1.0.0" }, }); }); serve(app, { port: 3000 }); ``` ## Route `route()` creates a standalone route definition. Each route defines its HTTP method, path, operation, and handler together. ```typescript import { application, serve } from "serverstruct"; import { z } from "zod"; import { jsonRequest, jsonResponse, route, useRouter, } from "serverstruct/openapi"; const getPost = route({ method: "get", path: "/posts/:id", operation: { operationId: "getPost", requestParams: { path: z.object({ id: z.string() }), }, responses: { 200: jsonResponse(postSchema, { description: "Post found" }), }, }, setup(box) { const db = box.get(Database); // db from Box return async (event, ctx) => { const { id } = await ctx.params(event); return ctx.reply(event, 200, await db.findPost(id)); }; }, }); const createPost = route({ method: "post", path: "/posts", operation: { operationId: "createPost", requestBody: jsonRequest(z.object({ title: z.string() })), responses: { 201: jsonResponse(postSchema, { description: "Post created" }), }, }, setup(box) { const db = box.get(Database); return { meta: { auth: true }, async handler(event, ctx) { const body = await ctx.body(event); return ctx.reply(event, 201, await db.createPost(body)); }, }; }, }); const app = application((app, box) => { const router = useRouter(app); const getPostRoute = box.get(getPost); const createPostRoute = box.get(createPost); router.route(getPostRoute, createPostRoute); }); serve(app, { port: 3000 }); ``` ## Router Context Every operation registration returns a `RouterContext` with typed request and response helpers. ### Schemas `ctx.schemas` exposes the raw Zod schemas extracted from the operation, for use with H3 validation utilities directly: ```typescript ctx.schemas.params; // requestParams.path schema ctx.schemas.query; // requestParams.query schema ctx.schemas.headers; // requestParams.header schema ctx.schemas.cookies; // requestParams.cookie schema ctx.schemas.body; // requestBody application/json schema ``` ### Request Validation `ctx.params()`, `ctx.query()`, and `ctx.body()` validate and parse incoming request data using the operation's schemas. When no schema is defined they return the raw value. A validation failure throws an `HTTPError` with status 400. ```typescript router.post( "/posts/:id/comments", { operationId: "createComment", requestParams: { path: z.object({ id: z.coerce.number() }), }, requestBody: jsonRequest(z.object({ text: z.string().min(1) })), responses: { 201: jsonResponse(z.object({ id: z.number(), text: z.string().min(1) }), { description: "Created", }), }, }, async (event, ctx) => { const { id } = await ctx.params(event); // { id: number } const body = await ctx.body(event); // { text: string } return ctx.reply(event, 201, await db.createComment(id, body)); }, ); ``` ### Response Validation `ctx.reply()` sets the response status and optional headers and returns typed response data. Types are inferred per status code from the operation's `responses` but not validated at runtime. ```typescript return ctx.reply(event, 201, { id: 1, text: "" }); // text fails z.string().min(1) but passes silently ``` `ctx.validReply()` works the same way but also validates the response body and headers against their schemas at runtime. ```typescript return ctx.validReply(event, 201, { id: 1, text: "" }); // Throws a 500 HTTPError ``` ## OpenApiPaths `OpenApiPaths` is used internally by `OpenApiRouter`. It can also be used directly for lower-level control. Register operations by HTTP method and use the returned `RouterContext` for typed request handling. Use `mount()` to merge paths from another `OpenApiPaths` instance under a base prefix. ```typescript import { createDocument, OpenApiPaths } from "serverstruct/openapi"; const usersPaths = new OpenApiPaths(); const getUser = usersPaths.get("/{id}", { operationId: "getUser", requestParams: { path: z.object({ id: z.string() }), }, responses: { 200: { description: "User found", content: { "application/json": { schema: userSchema } }, }, }, }); const paths = new OpenApiPaths(); paths.mount("/users", usersPaths); // /users/{id} is now in paths const app = application((app) => { app.get("/users/:id", async (event) => { const { id } = await getUser.params(event); return getUser.reply(event, 200, { id, name: "Alice" }); }); // Serve the OpenAPI document app.get("/docs", () => createDocument({ openapi: "3.1.0", info: { title: "My API", version: "1.0.0" }, paths: paths.paths, }), ); }); ``` ## Path Conversion `useRouter` and `route()` both accept H3 path syntax and automatically convert it to OpenAPI format: | H3 | OpenAPI | | -------- | ---------- | | `/:name` | `/{name}` | | `/*` | `/{param}` | | `/**` | `/{path}` | When using `OpenApiPaths` directly, paths should be written in OpenAPI format (`/users/{id}`). ## Helpers ### jsonRequest Build a `requestBody` object with `application/json` content: ```typescript { operationId: "createPost", requestBody: jsonRequest(z.object({ title: z.string() })), } // With additional options { operationId: "createPost", requestBody: jsonRequest( z.object({ title: z.string() }), { description: "Create a post", content: { example: { title: "Hello" } }, }, ), } ``` ### jsonResponse Build a response object with `application/json` content and optional headers: ```typescript { operationId: "getUser", responses: { 200: jsonResponse(userSchema, { description: "User found" }), 404: jsonResponse(errorSchema, { description: "Not found" }), }, } // With headers { operationId: "getUser", responses: { 200: jsonResponse(userSchema, { description: "User found", headers: z.object({ "x-request-id": z.string() }), }), }, } ``` ### schemas Create a typed schemas object for grouping route schemas: ```typescript class UserSchemas { static getUser = schemas({ params: z.object({ id: z.string() }), response: userSchema, notFound: errorSchema, }); } ``` Use the grouped schemas when building operations: ```typescript { operationId: "getUser", requestParams: { path: UserSchemas.getUser.params, }, responses: { 200: jsonResponse(UserSchemas.getUser.response, { description: "User found", }), 404: jsonResponse(UserSchemas.getUser.notFound, { description: "Not found", }), }, } ``` ### metadata zod-openapi provides OpenAPI types for the `.meta()` method when importing from `"zod/v4"`. Use the `metadata` helper to get the same options: ```typescript import { z } from "zod"; import { metadata } from "serverstruct/openapi"; const userSchema = z.object({ id: z.string(), name: z.string() }).meta( metadata({ description: "A user object", example: { id: "1", name: "Alice" }, }), ); ``` See [zod-openapi](https://github.com/samchungy/zod-openapi) for available metadata options. ## Generating the Document `router.document()` mounts a handler that serves the OpenAPI document and a [Scalar](https://github.com/scalar/scalar) API reference UI at `{path}/reference` by default: ```typescript router.document("/docs", { openapi: "3.1.0", info: { title: "My API", version: "1.0.0" }, }); // GET /docs → OpenAPI document // GET /docs/reference → Scalar UI ``` Pass `reference: false` to disable the reference UI, or configure it: ```typescript router.document("/docs", { openapi: "3.1.0", info: { title: "My API", version: "1.0.0" }, reference: { path: "/reference" }, }); ``` To generate the document manually, use `createDocument` (re-exported from `zod-openapi`) with `router.paths()`: ```typescript app.get("/docs", () => createDocument({ openapi: "3.1.0", info: { title: "My API", version: "1.0.0" }, paths: router.paths(), }), ); ``` ## Scalar API Reference > **Experimental:** This API is experimental and may change in future versions. Serve an interactive API documentation UI powered by [Scalar](https://github.com/scalar/scalar). ```sh npm i @scalar/core ``` `router.document()` mounts a Scalar reference at `{path}/reference` by default. To serve it manually, use `apiReference` from `serverstruct/openapi/scalar`: ```typescript import { apiReference } from "serverstruct/openapi/scalar"; app.get("/reference", () => apiReference({ config: { url: "http://localhost:3000/docs" }, }), ); ``` The `config.url` should point to the endpoint serving your OpenAPI document (see [Generating the Document](#generating-the-document)). | Option | Description | | ------------- | ------------------------------------------------------- | | `config` | Scalar API reference configuration | | `pageTitle` | Page title. Defaults to `"Scalar API Reference"` | | `cdn` | CDN URL for the standalone bundle. Defaults to jsDelivr | | `customTheme` | Custom CSS theme for the Scalar UI | ## Client Type Generation Use [openapi-typescript](https://openapi-ts.dev/) to generate TypeScript types from your running document endpoint: ```sh npm i -D openapi-typescript npx openapi-typescript http://localhost:3000/docs -o ./schema.ts ``` Then use [openapi-fetch](https://openapi-ts.dev/openapi-fetch/) to create a fully typed fetch client from the generated `paths` type: ```sh npm i openapi-fetch ``` ```typescript import createClient from "openapi-fetch"; import type { paths } from "./schema.ts"; const client = createClient<paths>({ baseUrl: "http://localhost:3000" }); const { data, error } = await client.GET("/posts/{id}", { params: { path: { id: "1" } }, }); ``` Request parameters, request body, and response data are all typed from the generated schema.