serverstruct
Version:
Type safe and modular servers with H3
442 lines (440 loc) • 14.3 kB
JavaScript
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;