Skip to content

Commit

Permalink
feat: experimental defineRouteMeta (#2102)
Browse files Browse the repository at this point in the history
Co-authored-by: Pooya Parsa <pooya@pi0.io>
Co-authored-by: Pooya Parsa <pyapar@gmail.com>
  • Loading branch information
3 people authored Apr 10, 2024
1 parent f0997c1 commit da05b8d
Show file tree
Hide file tree
Showing 14 changed files with 227 additions and 23 deletions.
1 change: 1 addition & 0 deletions docs/1.guide/1.utils.md
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
23 changes: 23 additions & 0 deletions docs/1.guide/2.routing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions src/imports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export const nitroImports: Preset[] = [
"defineNitroPlugin",
"nitroPlugin",
"defineRenderHandler",
"defineRouteMeta",
"getRouteRules",
"useAppConfig",
"useEvent",
Expand Down
6 changes: 6 additions & 0 deletions src/rollup/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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(
Expand Down
95 changes: 95 additions & 0 deletions src/rollup/plugins/handlers-meta.ts
Original file line number Diff line number Diff line change
@@ -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<string, any> = {};
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
}
}
31 changes: 19 additions & 12 deletions src/rollup/plugins/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")}
Expand All @@ -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
);
Expand Down
1 change: 1 addition & 0 deletions src/runtime/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
5 changes: 5 additions & 0 deletions src/runtime/meta.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import type { NitroRouteMeta } from "nitropack";

export function defineRouteMeta(meta: NitroRouteMeta) {
return meta;
}
13 changes: 10 additions & 3 deletions src/runtime/routes/openapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 = {
Expand All @@ -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;
Expand Down
8 changes: 8 additions & 0 deletions src/runtime/virtual/server-handlers-meta.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import type { OperationObject } from "openapi-typescript";
import { NitroRouteMeta } from "../../types";

export const handlersMeta: {
route?: string;
method?: string;
meta?: NitroRouteMeta;
}[];
7 changes: 0 additions & 7 deletions src/runtime/virtual/server-handlers.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,3 @@ export type HandlerDefinition = {
};

export const handlers: HandlerDefinition[];

export type HandlerMeta = {
route: string;
method?: RouterMethod;
};

export const handlersMeta: HandlerMeta[];
13 changes: 12 additions & 1 deletion src/types/handler.ts
Original file line number Diff line number Diff line change
@@ -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 | T[];

/** @exprerimental */
export interface NitroRouteMeta {
openAPI?: OperationObject;
}

export interface NitroEventHandler {
/**
* Path prefix or route
Expand Down Expand Up @@ -34,6 +40,11 @@ export interface NitroEventHandler {
method?: string;

/**
* Meta
*/
meta?: NitroRouteMeta;

/*
* Environments to include this handler
*/
env?: MaybeArray<
Expand Down
9 changes: 9 additions & 0 deletions test/fixture/api/meta/test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
defineRouteMeta({
openAPI: {
tags: ["test"],
description: "Test route description",
parameters: [{ in: "query", name: "test", required: true }],
},
});

export default defineEventHandler(() => "OK");
37 changes: 37 additions & 0 deletions test/presets/nitro-dev.test.ts
Original file line number Diff line number Diff line change
@@ -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 () => {
Expand All @@ -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",
],
},
}
`);
});
});
}
);
});

0 comments on commit da05b8d

Please sign in to comment.