Allow dynamic code within `defineRouteMeta`
Related:
- #2641
- #2912
Experimental route meta (#2102) (docs) only allows static definition like this:
defineRouteMeta({
openAPI: {
tags: ["test"],
description: "Test route description",
parameters: [{ in: "query", name: "test", required: true }],
},
});
However, users might want to generate it with other tools like zod.
In order to support this, we need to update handlers-meta rollup plugin to detect defineRouteMeta and actually evaluate (run) the code necessary in place since by-design this information should be statically extracted into the bundle.
There is my openapi example project: https://github.com/Dg0230/nitro-openapi-example.
I've been playing around with jiti and got the following to work:
Updated internals:
src/build/plugins/handlers-meta.ts
import { readFile } from 'node:fs/promises'
import type { Expression, Literal } from 'estree'
import type { Nitro, NitroEventHandler } from 'nitropack'
import type { Plugin } from 'rollup'
import { createJiti } from 'jiti'
const virtualPrefix = '\0nitro-handler-meta:'
export function handlersMeta(nitro: Nitro) {
const jiti = createJiti(nitro.options.rootDir, {
alias: nitro.options.alias,
})
return {
name: 'nitro:handlers-meta',
async resolveId(id, importer, resolveOpts) {
if (id.startsWith('\0')) {
return
}
if (id.endsWith(`?meta`)) {
const resolved = await this.resolve(id.replace(`?meta`, ``), importer, resolveOpts)
if (!resolved) {
return
}
return virtualPrefix + resolved.id
}
},
load(id) {
if (id.startsWith(virtualPrefix)) {
const fullPath = id.slice(virtualPrefix.length)
return readFile(fullPath, { encoding: 'utf8' })
}
},
async transform(_, id) {
if (!id.startsWith(virtualPrefix)) {
return
}
let meta: NitroEventHandler['meta'] | null = null
try {
// how to handle auto imports?
// @ts-ignore
globalThis.defineEventHandler = globalThis.defineEventHandler || (() => {})
// @ts-ignore
globalThis.defineRouteMeta = globalThis.defineRouteMeta || ((args) => args)
const handlerFilePath = id.replace(virtualPrefix, '')
meta = await jiti.import(handlerFilePath).then((r) => {
return 'meta' in r ? (r.meta as NitroEventHandler['meta']) : null
})
} catch (error) {
nitro.logger.warn(`[handlers-meta] Cannot extra route meta for: ${id}: ${error}`)
}
return {
code: `export default ${JSON.stringify(meta)};`,
map: null,
}
},
} satisfies Plugin
}
Usage:
server/api/hello-world.ts
export default defineEventHandler(() => {
return {
greeting: 'Hello World',
}
})
const schema = {
type: 'object' as const,
properties: {
greeting: {
type: 'string' as const,
},
},
}
export const meta = defineRouteMeta({
openAPI: {
summary: 'Hello World',
tags: ['hello'],
responses: {
'200': {
description: 'Greeting',
content: {
'application/json': {
schema,
},
},
},
},
},
})
Issues:
The only missing piece is how to handle Nitro's auto imports because they are not available in jiti's context.
Got auto imports to work! Now it's only missing #nitro-internal-virtual/ exports:
import type { Nitro, NitroEventHandler } from 'nitropack'
import type { Plugin } from 'rollup'
import { createJiti } from 'jiti'
const virtualPrefix = '\0nitro-handler-meta:'
export function handlersMeta(nitro: Nitro) {
const jiti = createJiti(nitro.options.rootDir, {
alias: nitro.options.alias,
moduleCache: false,
fsCache: false,
})
return {
name: 'nitro:handlers-meta',
async resolveId(id, importer, resolveOpts) {
if (id.startsWith('\0')) {
return
}
if (id.endsWith(`?meta`)) {
const resolved = await this.resolve(id.replace(`?meta`, ``), importer, resolveOpts)
if (!resolved) {
return
}
return virtualPrefix + resolved.id
}
},
load(id) {
if (id.startsWith(virtualPrefix)) {
const fullPath = id.slice(virtualPrefix.length)
return this.load({
id: fullPath,
}).then((r) => r.code)
}
},
async transform(code, id) {
if (!id.startsWith(virtualPrefix)) {
return
}
let meta: NitroEventHandler['meta'] | null = null
try {
const handlerFilePath = id.slice(virtualPrefix.length)
const evaluated = jiti.evalModule(code, {
id: handlerFilePath,
})
meta =
evaluated && typeof evaluated === 'object' && 'meta' in evaluated
? (evaluated.meta as NitroEventHandler['meta'])
: null
} catch (error) {
nitro.logger.warn(`[handlers-meta] Cannot extra route meta for: ${id}: ${error}`)
}
return {
code: `export default ${JSON.stringify(meta)};`,
map: null,
}
},
} satisfies Plugin
}
Is there a workaround available atm?
@LouisHaftmann what issue are you facing with #nitro-internal-virtual/?
I was looking for generating the api documentation starting from the validation of each route via tools like @standard-community/standard-json or @standard-community/standard-openapi
Jiti cannot resolve imports from #nitro-internal-virtual/ @sandros94
Jiti cannot resolve imports from
#nitro-internal-virtual/@sandros94
but #nitro-internal-virtual/* is just an alias, which iirc jiti does support in its configs (tho I never used jiti directly atm). I'm taking a look at its src
EDIT: oh, it is something you already pass via alias: nitro.options.alias 🤔
#nitro-internal-virtual/ is also used for rollup virtual files which cannot be easily accessed/resolved by jiti
Has there been any progress on this?
Hello, Witout the possibility to derive schema from object in the code, this defineRouteMeta function is not as usefull and lead to code duplication. is there any update on this feature ?
Would now be a good time to consider maybe another way to accomplish this?
We could accept ZOD (and other major validators) in definePageMeta instead of JSON Schema directly... maybe via a custom transformer function that runs at build time?
I find this really necessary. I’m using Zod and can’t dynamically pass a variable to the schema in defineRouteMeta. Could you please update it?
I have tried to find a solution for this problem here, but I still haven't found it yet. My attempt was to traverse the AST object, and every time it finds an 'Identifier' that has not yet been bound, it fires a hook from inside a callback in astToObject, which lets us know the name of that identifier. My objective with this is to be able to handle the import or variable by name in the object. This is how I made this callback:
--- a/src/build/plugins/route-meta.ts
+++ b/src/build/plugins/route-meta.ts
@@ -61,7 +61,9 @@ export function routeMeta(nitro: Nitro) {
node.expression.callee.name === "defineRouteMeta" &&
node.expression.arguments.length === 1
) {
- meta = astToObject(node.expression.arguments[0] as any);
+ meta = astToObject(node.expression.arguments[0] as any, {
+ hook: (name) => // Here comes the transverse
+ });
break;
}
}
@@ -79,24 +81,29 @@ export function routeMeta(nitro: Nitro) {
} satisfies Plugin;
}
-function astToObject(node: Expression | Literal): any {
+interface AstToObjectSettings { hook: (name: string) => any }
+
+function astToObject(node: Expression | Literal, settings?: Partial<AstToObjectSettings>): any {
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 ?? (prop.key as any).value;
- obj[key] = astToObject(prop.value as any);
+ obj[key] = astToObject(prop.value as any, settings);
}
}
return obj;
}
case "ArrayExpression": {
- return node.elements.map((el) => astToObject(el as any)).filter(Boolean);
+ return node.elements.map((el) => astToObject(el as any, settings)).filter(Boolean);
}
case "Literal": {
return node.value;
}
+ case "Identifier": {
+ return astToObject(settings?.hook?.(node.name), settings)
+ }
// No default
}
}
H3 v2 has a meta property available on the handler now. Presumably Nitro v3 could use that instead, letting you supply standard-schema compatible schemas in lieu of JSON schema types, generating the OpenAPI doc on demand for the first request, then caching it if required. Could also be leveraged for type generation?
@pi0 Could you please elaborate why meta information, specifically the openAPI info should be statically extracted? You mentioned this should be done by design, but IMO this only complicates the implementation needed to support this feature, furthermore I'm not sure if we actually can evaluate all transformation code needed by this usecase. Couldn't we implement this using runtime behaviour? In nestjs, class decorators are used to embed swagger property info which is done at compile time (TS Decorators) but at runtime, these info are collected and then fed into the endpoint handler (/swagger for example)