nitro icon indicating copy to clipboard operation
nitro copied to clipboard

Allow dynamic code within `defineRouteMeta`

Open pi0 opened this issue 1 year ago • 14 comments

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.

pi0 avatar Jan 07 '25 18:01 pi0

There is my openapi example project: https://github.com/Dg0230/nitro-openapi-example.

Dg0230 avatar Mar 01 '25 04:03 Dg0230

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.

LouisHaftmann avatar May 06 '25 14:05 LouisHaftmann

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
}

LouisHaftmann avatar May 06 '25 16:05 LouisHaftmann

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

sandros94 avatar May 12 '25 14:05 sandros94

Jiti cannot resolve imports from #nitro-internal-virtual/ @sandros94

LouisHaftmann avatar May 12 '25 15:05 LouisHaftmann

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 🤔

sandros94 avatar May 12 '25 15:05 sandros94

#nitro-internal-virtual/ is also used for rollup virtual files which cannot be easily accessed/resolved by jiti

LouisHaftmann avatar May 12 '25 15:05 LouisHaftmann

Has there been any progress on this?

ExistentialAlex avatar Jun 25 '25 13:06 ExistentialAlex

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 ?

BYohann avatar Jul 05 '25 10:07 BYohann

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?

dan-hale avatar Aug 06 '25 04:08 dan-hale

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?

lukhaiminh avatar Aug 20 '25 07:08 lukhaiminh

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
   }
 }

TutorFx avatar Oct 18 '25 04:10 TutorFx

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?

ceigey avatar Nov 02 '25 15:11 ceigey

@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)

Saeid-Za avatar Nov 24 '25 15:11 Saeid-Za