UNPKG

serverstruct

Version:

Type safe and modular servers with H3

442 lines (440 loc) 14.3 kB
const require_openapi_scalar = require('./openapi.scalar.cjs'); let getbox = require("getbox"); let h3 = require("h3"); let zod = require("zod"); let zod_openapi = require("zod-openapi"); let zod_openapi_api = require("zod-openapi/api"); //#region src/openapi.ts function createContext(operation) { const paramsSchema = operation.requestParams?.path; const querySchema = operation.requestParams?.query; const headersSchema = operation.requestParams?.header; const cookiesSchema = operation.requestParams?.cookie; const bodyRawSchema = operation.requestBody?.content?.["application/json"]?.schema; const bodySchema = (0, zod_openapi_api.isAnyZodType)(bodyRawSchema) ? bodyRawSchema : void 0; return { schemas: { params: paramsSchema, query: querySchema, headers: headersSchema, cookies: cookiesSchema, body: bodySchema }, params: (event) => paramsSchema ? (0, h3.getValidatedRouterParams)(event, paramsSchema) : Promise.resolve((0, h3.getRouterParams)(event)), query: (event) => querySchema ? (0, h3.getValidatedQuery)(event, querySchema) : Promise.resolve((0, h3.getQuery)(event)), body: (event) => bodySchema ? (0, h3.readValidatedBody)(event, bodySchema) : (0, h3.readBody)(event), reply: (event, status, data, headers) => { event.res.status = status; if (headers) for (const [key, value] of Object.entries(headers)) event.res.headers.set(key, String(value)); return data; }, validReply: (event, status, data, headers) => { const response = operation.responses?.[status]; const responseBodyRawSchema = response?.content?.["application/json"]?.schema; const responseBodySchema = (0, zod_openapi_api.isAnyZodType)(responseBodyRawSchema) ? responseBodyRawSchema : void 0; const responseHeadersRawSchema = response?.headers; const responseHeadersSchema = (0, zod_openapi_api.isAnyZodType)(responseHeadersRawSchema) ? responseHeadersRawSchema : void 0; if (responseBodySchema) { const result = (0, zod.safeParse)(responseBodySchema, data); if (result.success) data = result.data; else throw createValidationError(result.error); } if (responseHeadersSchema) { const result = (0, zod.safeParse)(responseHeadersSchema, headers); if (result.success) headers = result.data; else throw createValidationError(result.error); } event.res.status = status; if (headers) for (const [key, value] of Object.entries(headers)) event.res.headers.set(key, String(value)); return data; } }; } function createValidationError(cause) { return new h3.HTTPError({ cause, status: 500, message: "Response validation failed", unhandled: true, data: { issues: cause.issues, message: "Response validation failed" } }); } const HTTP_METHODS = [ "get", "post", "put", "delete", "patch" ]; /** * Collects OpenAPI operation definitions for document generation. * * Register operations by HTTP method and path. The accumulated `paths` * object can be passed to `createDocument()` to generate the OpenAPI spec. * * Each registration returns a typed {@link RouterContext} for use in route handlers. * * @example * ```ts * const paths = new OpenApiPaths(); * * const getPost = paths.get("/posts/{id}", { ... }); * * // Generate OpenAPI document * createDocument({ openapi: "3.1.0", info: { ... }, paths: paths.paths }); * ``` */ var OpenApiPaths = class { /** Accumulated OpenAPI paths object. */ paths = {}; /** Register an operation for the GET method. */ get(path, operation) { return this.on(["get"], path, operation); } /** Register an operation for the POST method. */ post(path, operation) { return this.on(["post"], path, operation); } /** Register an operation for the PUT method. */ put(path, operation) { return this.on(["put"], path, operation); } /** Register an operation for the DELETE method. */ delete(path, operation) { return this.on(["delete"], path, operation); } /** Register an operation for the PATCH method. */ patch(path, operation) { return this.on(["patch"], path, operation); } /** Register an operation for all standard HTTP methods (get, post, put, delete, patch). */ all(path, operation) { return this.on(HTTP_METHODS, path, operation); } /** Register an operation for specific HTTP methods. */ on(methods, path, operation) { const item = {}; for (const method of methods) if (!this.paths[path]?.[method]) item[method] = operation; this.paths[path] = { ...this.paths[path], ...item }; return createContext(operation); } /** * Mount all paths from `sub` with a base prefix. * * Existing entries on the same path and method are not overwritten. * * @example * ```ts * const subPaths = new OpenApiPaths(); * * subPaths.get("/", { operationId: "getUsers", responses: {} }); * * const basePaths = new OpenApiPaths(); * basePaths.mount("/users", subPaths); * ``` */ mount(base, sub) { if (base.endsWith("/")) base = base.slice(0, -1); for (const [path, item] of Object.entries(sub.paths)) { const fullPath = path === "/" ? base : base + path; for (const [method, operation] of Object.entries(item)) this.on([method], fullPath, operation); } } }; /** * Combines OpenAPI path registration with H3 route registration. * * Each method registers the operation in {@link OpenApiPaths} (converting the * H3 path syntax to OpenAPI format) and simultaneously registers the route * handler on the H3 app. The handler receives the typed {@link RouterContext}. * * @example * ```ts * const router = useRouter(app); * * router.get("/posts/:id", { * operationId: "getPost", * requestBody: jsonRequest(inputSchema), * responses: { * 200: jsonResponse(outputSchema, { description: "Success" }), * }, * }, async (event, ctx) => { * const body = await ctx.body(event); * return ctx.reply(event, 200, { message: "ok" }); * }); * ``` */ var OpenApiRouter = class OpenApiRouter { static key = Symbol("OpenApiRouter.key"); /** * Returns the existing router for `app`, or creates and attaches a new one. * Multiple calls on the same app return the same instance. */ static from(app) { const existing = app[OpenApiRouter.key]; const router = existing || new OpenApiRouter(app, new OpenApiPaths()); if (!existing) app[OpenApiRouter.key] = router; return router; } constructor(_app, _paths) { this._app = _app; this._paths = _paths; } /** Register a route and operation for the GET method. */ get(path, operation, handler, opts) { const ctx = this._paths.get(toOpenApiPath(path), operation); this._app.get(path, (event) => handler(event, ctx), opts); return this; } /** Register a route and operation for the POST method. */ post(path, operation, handler, opts) { const ctx = this._paths.post(toOpenApiPath(path), operation); this._app.post(path, (event) => handler(event, ctx), opts); return this; } /** Register a route and operation for the PUT method. */ put(path, operation, handler, opts) { const ctx = this._paths.put(toOpenApiPath(path), operation); this._app.put(path, (event) => handler(event, ctx), opts); return this; } /** Register a route and operation for the DELETE method. */ delete(path, operation, handler, opts) { const ctx = this._paths.delete(toOpenApiPath(path), operation); this._app.delete(path, (event) => handler(event, ctx), opts); return this; } /** Register a route and operation for the PATCH method. */ patch(path, operation, handler, opts) { const ctx = this._paths.patch(toOpenApiPath(path), operation); this._app.patch(path, (event) => handler(event, ctx), opts); return this; } /** Register a route and operation for all standard HTTP methods. */ all(path, operation, handler, opts) { const ctx = this._paths.all(toOpenApiPath(path), operation); this._app.all(path, (event) => handler(event, ctx), opts); return this; } /** Register a route and operation for specific HTTP methods. */ on(methods, path, operation, handler, opts) { const ctx = this._paths.on(methods, toOpenApiPath(path), operation); for (const method of methods) this._app.on(method, path, (event) => handler(event, ctx), opts); return this; } /** * Registers routes and operations for standalone {@link Route} definitions. */ route(...routes) { for (const route$1 of routes) this._app.register(route$1(this._paths)); return this; } mount(arg1, arg2) { if (typeof arg1 === "string") { const subRouter = arg2[OpenApiRouter.key]; this._app.mount(arg1, arg2); if (subRouter) this._paths.mount(arg1, subRouter._paths); } else for (const [base, ctor] of Object.entries(arg2)) this.mount(base, arg1.get(ctor)); return this; } /** Returns the accumulated OpenAPI paths object. */ paths() { return this._paths.paths; } /** * Mounts a handler at `path` that serves the OpenAPI document. * Also mounts a Scalar API reference UI at `{path}/reference` by default. * Pass `reference: false` to disable, or provide options to configure it. */ document(path, options) { if (!path.startsWith("/")) path = "/" + path; const { reference, options: docOptions, ...zodOpenApiObject } = options; this._app.get(path, (event) => { const base = event.url.pathname.slice(0, -path.length); const paths = base ? Object.fromEntries(Object.entries(this._paths.paths).map(([path$1, item]) => [`${base}${path$1}`, item])) : this._paths.paths; return (0, zod_openapi.createDocument)({ ...zodOpenApiObject, paths }, docOptions); }); if (reference !== false) { const refPath = reference?.path || `${path}/reference`; this._app.get(refPath, (event) => { const base = event.url.pathname.slice(0, -refPath.length); return require_openapi_scalar.apiReference({ config: { ...reference?.configuration, url: `${base}${path}` }, pageTitle: reference?.pageTitle, cdn: reference?.cdn }, reference?.customTheme); }); } return this; } }; /** * Creates an {@link OpenApiRouter} that combines H3 route registration with OpenAPI path collection. * * Multiple calls on the same app return the same instance. * * @param app - H3 application instance. * @returns An {@link OpenApiRouter} instance. * * @example * ```ts * const router = useRouter(app); * * router.get("/posts/:id", { * operationId: "getPost", * responses: { 200: jsonResponse(postSchema, { description: "Success" }) }, * }, async (event, ctx) => { * const { id } = await ctx.params(event); * return ctx.reply(event, 200, { id }); * }); * ``` */ function useRouter(app) { return OpenApiRouter.from(app); } /** * Creates a Route constructor. * * `setup` is called once with the Box to resolve dependencies. Return a handler function * directly, or an object with a `handler` and other route options (e.g. `meta`, `middleware`). * * @param options.setup - Returns the handler or `{ handler, ...RouteOptions }`. * @returns A Constructor that produces a {@link Route}. Not cached by Box. * * @example * ```ts * const getPost = route({ * method: "get", * path: "/posts/:id", * operation: { * operationId: "getPost", * responses: { 200: jsonResponse(postSchema, { description: "Success" }) }, * }, * setup(box) { * const db = box.get(Database); * return async (event, ctx) => { * const { id } = await ctx.params(event); * return ctx.reply(event, 200, await db.getPost(id)); * }; * }, * }); * * const getPostRoute = box.get(getPost); * * const router = useRouter(app); * router.route(getPostRoute); * ``` */ function route(options) { return (0, getbox.computed)((box) => { const { method, path, operation, setup } = options; const methods = typeof method === "string" ? [method] : method; const res = setup(box); const { handler, ...opts } = typeof res === "function" ? { handler: res } : res; return (0, h3.definePlugin)((app, paths) => { const ctx = paths.on(methods, toOpenApiPath(path), operation); for (const method$1 of methods) app.on(method$1, path, (event) => handler(event, ctx), opts); }); }); } /** Builder for OpenAPI metadata passed to `.meta()` on Zod schemas. */ const metadata = (meta) => meta; /** * Build a typed `requestBody` object with `application/json` content. * * Additional media type options (e.g. `example`) can be passed via `opts.content`. * * @example * ```ts * jsonRequest(inputSchema) * jsonRequest(inputSchema, { description: "Create a post", content: { example: { title: "Hello" } } }) * ``` */ function jsonRequest(schema, opts) { const { content, ...rest } = opts || {}; return { required: true, ...rest, content: { "application/json": { schema, ...content } } }; } /** * Build a typed response object with `application/json` content. * * Additional media type options (e.g. `example`) can be passed via `opts.content`. * * @example * ```ts * jsonResponse(outputSchema, { description: "Success" }) * jsonResponse(outputSchema, { * description: "Success", * headers: z.object({ "x-request-id": z.string() }), * }) * ``` */ function jsonResponse(schema, opts) { const { content, headers, ...rest } = opts; return { ...rest, headers, content: { "application/json": { schema, ...content } } }; } /** * Convert H3 path syntax to OpenAPI path syntax. * * - `/:name` → `/{name}` * - `/*` → `/{param}` * - `/**` → `/{path}` */ function toOpenApiPath(route$1) { if (!route$1.startsWith("/")) route$1 = "/" + route$1; return route$1.split("/").map((segment) => { if (segment.startsWith(":")) return `{${segment.slice(1)}}`; else if (segment === "*") return "{param}"; else if (segment === "**") return "{path}"; else return segment; }).join("/"); } /** * Creates a typed schemas object for grouping route schemas together. * * The common keys are `params`, `query`, `headers`, `cookies`, `body`, and * `response`. Other schema properties can also be added. */ function schemas(s) { return s; } //#endregion exports.OpenApiPaths = OpenApiPaths; exports.OpenApiRouter = OpenApiRouter; Object.defineProperty(exports, 'createDocument', { enumerable: true, get: function () { return zod_openapi.createDocument; } }); exports.jsonRequest = jsonRequest; exports.jsonResponse = jsonResponse; exports.metadata = metadata; exports.route = route; exports.schemas = schemas; exports.useRouter = useRouter;