diff --git a/docs/1.guide/1.utils.md b/docs/1.guide/1.utils.md index 0c8aefbb6c..7a03f49c2f 100644 --- a/docs/1.guide/1.utils.md +++ b/docs/1.guide/1.utils.md @@ -49,6 +49,7 @@ Nitro also exposes several built-in utils: - `defineCachedFunction(fn, options)`{lang=ts} / `cachedFunction(fn, options)`{lang=ts} - `defineCachedEventHandler(handler, options)`{lang=ts} / `cachedEventHandler(handler, options)`{lang=ts} - `defineRenderHandler(handler)`{lang=ts} +- `defineRouteMeta(options)`{lang=ts} (experimental) - `useRuntimeConfig(event?)`{lang=ts} - `useAppConfig(event?)`{lang=ts} - `useStorage(base?)`{lang=ts} diff --git a/docs/1.guide/2.routing.md b/docs/1.guide/2.routing.md index db14c3690d..839eb898b4 100644 --- a/docs/1.guide/2.routing.md +++ b/docs/1.guide/2.routing.md @@ -192,6 +192,29 @@ Middleware in `middleware/` directory are automatically registered for all route Returning anything from a middleware will close the request and should be avoided! Any returned value from middleware will be the response and further code will not be executed however **this is not recommended to do!** :: +### Route Meta + +You can define route handler meta at build-time using `defineRouteMeta` micro in the event handler files. + +> [!NOTE] +> This feature is currently available in [nightly channel](https://nitro.unjs.io/guide/nightly) only. + +```ts [/api/test.ts] +defineRouteMeta({ + openAPI: { + tags: ["test"], + description: "Test route description", + parameters: [{ in: "query", name: "test", required: true }], + }, +}); + +export default defineEventHandler(() => "OK"); +``` + +::read-more{to="https://swagger.io/specification/v3/"} +This feature is currntly usable to specify OpenAPI meta. See swagger specification for available OpenAPI options. +:: + ### Execution order Middleware are executed in directory listing order. diff --git a/src/imports.ts b/src/imports.ts index fc3824b053..691f3c4384 100644 --- a/src/imports.ts +++ b/src/imports.ts @@ -14,6 +14,7 @@ export const nitroImports: Preset[] = [ "defineNitroPlugin", "nitroPlugin", "defineRenderHandler", + "defineRouteMeta", "getRouteRules", "useAppConfig", "useEvent", diff --git a/src/rollup/config.ts b/src/rollup/config.ts index c1369ee3c9..f11052c7eb 100644 --- a/src/rollup/config.ts +++ b/src/rollup/config.ts @@ -31,6 +31,7 @@ import { timing } from "./plugins/timing"; import { publicAssets } from "./plugins/public-assets"; import { serverAssets } from "./plugins/server-assets"; import { handlers } from "./plugins/handlers"; +import { handlersMeta } from "./plugins/handlers-meta"; import { esbuild } from "./plugins/esbuild"; import { raw } from "./plugins/raw"; import { storage } from "./plugins/storage"; @@ -306,6 +307,11 @@ export const getRollupConfig = (nitro: Nitro): RollupConfig => { // Handlers rollupConfig.plugins.push(handlers(nitro)); + // Handlers meta + if (nitro.options.experimental.openAPI) { + rollupConfig.plugins.push(handlersMeta(nitro)); + } + // Polyfill rollupConfig.plugins.push( virtual( diff --git a/src/rollup/plugins/handlers-meta.ts b/src/rollup/plugins/handlers-meta.ts new file mode 100644 index 0000000000..116686b80d --- /dev/null +++ b/src/rollup/plugins/handlers-meta.ts @@ -0,0 +1,95 @@ +import { readFile } from "node:fs/promises"; +import { transform } from "esbuild"; +import type { Plugin } from "rollup"; +import type { Literal, Expression } from "estree"; +import { extname } from "pathe"; +import { Nitro, NitroEventHandler } from "../../types"; + +const virtualPrefix = "\0nitro-handler-meta:"; + +// From esbuild.ts +const esbuildLoaders = { + ".ts": "ts", + ".js": "js", + ".tsx": "tsx", + ".jsx": "jsx", +}; + +export function handlersMeta(nitro: Nitro) { + return { + name: "nitro:handlers-meta", + async resolveId(id) { + if (id.startsWith("\0")) { + return; + } + if (id.endsWith(`?meta`)) { + const resolved = await this.resolve(id.replace(`?meta`, ``)); + return virtualPrefix + resolved.id; + } + }, + load(id) { + if (id.startsWith(virtualPrefix)) { + const fullPath = id.slice(virtualPrefix.length); + return readFile(fullPath, { encoding: "utf8" }); + } + }, + async transform(code, id) { + if (!id.startsWith(virtualPrefix)) { + return; + } + + let meta: NitroEventHandler["meta"] | null = null; + + try { + const ext = extname(id); + const jsCode = await transform(code, { + loader: esbuildLoaders[ext], + }).then((r) => r.code); + const ast = this.parse(jsCode); + for (const node of ast.body) { + if ( + node.type === "ExpressionStatement" && + node.expression.type === "CallExpression" && + node.expression.callee.type === "Identifier" && + node.expression.callee.name === "defineRouteMeta" && + node.expression.arguments.length === 1 + ) { + meta = astToObject(node.expression.arguments[0] as any); + break; + } + } + } catch (err) { + console.warn( + `[nitro] [handlers-meta] Cannot extra route meta for: ${id}: ${err}` + ); + } + + return { + code: `export default ${JSON.stringify(meta)};`, + map: null, + }; + }, + } satisfies Plugin; +} + +function astToObject(node: Expression | Literal) { + switch (node.type) { + case "ObjectExpression": { + const obj: Record = {}; + for (const prop of node.properties) { + if (prop.type === "Property") { + const key = (prop.key as any).name; + obj[key] = astToObject(prop.value as any); + } + } + return obj; + } + case "ArrayExpression": { + return node.elements.map((el) => astToObject(el as any)).filter(Boolean); + } + case "Literal": { + return node.value; + } + // No default + } +} diff --git a/src/rollup/plugins/handlers.ts b/src/rollup/plugins/handlers.ts index d70b712142..67607cc476 100644 --- a/src/rollup/plugins/handlers.ts +++ b/src/rollup/plugins/handlers.ts @@ -55,16 +55,7 @@ export function handlers(nitro: Nitro) { handlers.filter((h) => h.lazy).map((h) => h.handler) ); - const handlersMeta = getHandlers() - .filter((h) => h.route) - .map((h) => { - return { - route: h.route, - method: h.method, - }; - }); - - const code = ` + const code = /* js */ ` ${imports .map((handler) => `import ${getImportId(handler)} from '${handler}';`) .join("\n")} @@ -89,11 +80,27 @@ ${handlers ) .join(",\n")} ]; - -export const handlersMeta = ${JSON.stringify(handlersMeta, null, 2)} `.trim(); return code; }, + "#internal/nitro/virtual/server-handlers-meta": () => { + const handlers = getHandlers(); + return /* js */ ` + ${handlers + .map( + (h) => `import ${getImportId(h.handler)}Meta from "${h.handler}?meta";` + ) + .join("\n")} +export const handlersMeta = [ + ${handlers + .map( + (h) => + /* js */ `{ route: ${JSON.stringify(h.route)}, method: ${JSON.stringify(h.method)}, meta: ${getImportId(h.handler)}Meta }` + ) + .join(",\n")} + ]; + `; + }, }, nitro.vfs ); diff --git a/src/runtime/index.ts b/src/runtime/index.ts index d44e31ac6a..9072ba8860 100644 --- a/src/runtime/index.ts +++ b/src/runtime/index.ts @@ -5,6 +5,7 @@ export * from "./plugin"; export * from "./task"; export * from "./renderer"; export { getRouteRules, getRouteRulesForPath } from "./route-rules"; +export { defineRouteMeta } from "./meta"; export { useStorage } from "./storage"; export { useEvent } from "./context"; export { defineNitroErrorHandler } from "./error"; diff --git a/src/runtime/meta.ts b/src/runtime/meta.ts new file mode 100644 index 0000000000..78f630a283 --- /dev/null +++ b/src/runtime/meta.ts @@ -0,0 +1,5 @@ +import type { NitroRouteMeta } from "nitropack"; + +export function defineRouteMeta(meta: NitroRouteMeta) { + return meta; +} diff --git a/src/runtime/routes/openapi.ts b/src/runtime/routes/openapi.ts index 1b65ade28d..e4f9c555e3 100644 --- a/src/runtime/routes/openapi.ts +++ b/src/runtime/routes/openapi.ts @@ -7,7 +7,7 @@ import type { PathsObject, } from "openapi-typescript"; import { joinURL } from "ufo"; -import { handlersMeta } from "#internal/nitro/virtual/server-handlers"; +import { handlersMeta } from "#internal/nitro/virtual/server-handlers-meta"; import { useRuntimeConfig } from "#internal/nitro"; // Served as /_nitro/openapi.json @@ -44,8 +44,8 @@ function getPaths(): PathsObject { const paths: PathsObject = {}; for (const h of handlersMeta) { - const { route, parameters } = normalizeRoute(h.route); - const tags = defaultTags(h.route); + const { route, parameters } = normalizeRoute(h.route || ""); + const tags = defaultTags(h.route || ""); const method = (h.method || "get").toLowerCase(); const item: PathItemObject = { @@ -63,6 +63,13 @@ function getPaths(): PathsObject { } else { Object.assign(paths[route], item); } + + if (h.meta?.openAPI) { + paths[route][method] = { + ...paths[route][method], + ...h.meta.openAPI, + }; + } } return paths; diff --git a/src/runtime/virtual/server-handlers-meta.d.ts b/src/runtime/virtual/server-handlers-meta.d.ts new file mode 100644 index 0000000000..817116a557 --- /dev/null +++ b/src/runtime/virtual/server-handlers-meta.d.ts @@ -0,0 +1,8 @@ +import type { OperationObject } from "openapi-typescript"; +import { NitroRouteMeta } from "../../types"; + +export const handlersMeta: { + route?: string; + method?: string; + meta?: NitroRouteMeta; +}[]; diff --git a/src/runtime/virtual/server-handlers.d.ts b/src/runtime/virtual/server-handlers.d.ts index aecad768a5..925f067510 100644 --- a/src/runtime/virtual/server-handlers.d.ts +++ b/src/runtime/virtual/server-handlers.d.ts @@ -12,10 +12,3 @@ export type HandlerDefinition = { }; export const handlers: HandlerDefinition[]; - -export type HandlerMeta = { - route: string; - method?: RouterMethod; -}; - -export const handlersMeta: HandlerMeta[]; diff --git a/src/types/handler.ts b/src/types/handler.ts index 3b6fa6dd29..4f5c366af1 100644 --- a/src/types/handler.ts +++ b/src/types/handler.ts @@ -1,8 +1,14 @@ -import type { EventHandler, H3Event, H3Error } from "h3"; +import type { EventHandler, H3Error, H3Event } from "h3"; +import type { OperationObject } from "openapi-typescript"; import { NitroOptions } from "./nitro"; type MaybeArray = T | T[]; +/** @exprerimental */ +export interface NitroRouteMeta { + openAPI?: OperationObject; +} + export interface NitroEventHandler { /** * Path prefix or route @@ -34,6 +40,11 @@ export interface NitroEventHandler { method?: string; /** + * Meta + */ + meta?: NitroRouteMeta; + + /* * Environments to include this handler */ env?: MaybeArray< diff --git a/test/fixture/api/meta/test.ts b/test/fixture/api/meta/test.ts new file mode 100644 index 0000000000..7fd9aa8018 --- /dev/null +++ b/test/fixture/api/meta/test.ts @@ -0,0 +1,9 @@ +defineRouteMeta({ + openAPI: { + tags: ["test"], + description: "Test route description", + parameters: [{ in: "query", name: "test", required: true }], + }, +}); + +export default defineEventHandler(() => "OK"); diff --git a/test/presets/nitro-dev.test.ts b/test/presets/nitro-dev.test.ts index d30d32ae4c..8b567b703b 100644 --- a/test/presets/nitro-dev.test.ts +++ b/test/presets/nitro-dev.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect } from "vitest"; import { isCI } from "std-env"; +import type { OpenAPI3 } from "openapi-typescript"; import { setupTest, testNitro } from "../tests"; describe.skipIf(isCI)("nitro:preset:nitro-dev", async () => { @@ -21,6 +22,42 @@ describe.skipIf(isCI)("nitro:preset:nitro-dev", async () => { const { status } = await callHandler({ url: "/proxy/example" }); expect(status).toBe(200); }); + + describe("openAPI", () => { + let spec: OpenAPI3; + it("/_nitro/openapi.json", async () => { + spec = ((await callHandler({ url: "/_nitro/openapi.json" })) as any) + .data; + expect(spec.openapi).to.match(/^3\.\d+\.\d+$/); + expect(spec.info.title).toBe("Nitro Test Fixture"); + expect(spec.info.description).toBe("Nitro Test Fixture API"); + }); + + it("defineRouteMeta works", () => { + expect(spec.paths["/api/meta/test"]).toMatchInlineSnapshot(` + { + "get": { + "description": "Test route description", + "parameters": [ + { + "in": "query", + "name": "test", + "required": true, + }, + ], + "responses": { + "200": { + "description": "OK", + }, + }, + "tags": [ + "test", + ], + }, + } + `); + }); + }); } ); });