h3 icon indicating copy to clipboard operation
h3 copied to clipboard

defineRoute

Open productdevbook opened this issue 6 months ago • 21 comments

Describe the feature

In CLI and codebase scanning architectures, type inference and code merging often create inconsistencies with methods like .get, .post, etc., leading to a different set of challenges in every project. To prevent this and achieve a strictly consistent system, introducing a new structured approach unlocks many bottlenecks. In the modern ecosystem, support for StandardSchema significantly simplifies module development—even in specialized modules like OpenAPI and others.

import { H3, defineEventHandler, defineRoute, readBody, readValidatedBody } from "h3";
import { z } from "zod";

export const book = defineRoute({
  method: "GET",
  route: "/api/books",
  routerParams: z.object({
    id: z.string(),
  }),
  queryParams: z.object({
    page: z.string().optional(),
    limit: z.string().optional(),
  }),
  input: z.object({ 
    title: z.string(),
  }),
  output: z.object({
    message: z.string(),
    name: z.string(),
  }),
  handler: async (event)=> {
    const data = await readBody(event);
    const data2 = await readValidatedBody(event)

    return {
      message: "Book list",
      name: ''
    }
  }
});


const app = new H3();

app.addRoute(book);

Additional information

  • [x] Would you be willing to help implement this feature?

productdevbook avatar Jun 05 '25 05:06 productdevbook

This looks familiar from libraries like https://github.com/ts-rest/ts-rest and https://github.com/unnoq/orpc I think it would be great to make this compatible with these too.

I'd personally like if there were:

  • headers support with Record<string, StandardSchemaV1>
  • output would be a Record<StatusCode, StandardSchemaV1> so with a someResponseHelper(<statusCode>, <my-response>) we could validate the output types before returning them from the handler (and the handler would by default allow a union of these to be returned)
  • the route would be also defineable without a handler that can be later "implemented" with one, so clients (in browser) can also use the defined contracts without any server code leaking into that
import { H3, defineEventHandler, defineRoute, readBody, readValidatedBody } from "h3";
import { z } from "zod";

export const book = defineRoute({
  method: "GET",
  route: "/api/books",
  routerParams: z.object({
    id: z.string(),
  }),
  queryParams: z.object({
    page: z.string().optional(),
    limit: z.string().optional(),
  }),
  headers: {
    "x-custom-header": z.string().optional(),
  },
  input: z.object({
    title: z.string(),
  }),
  output: {
    200: z.object({
      message: z.string(),
      name: z.string(),
    }),
    204: z.void(),
    404: z.object({
      message: z.string(),
      statusCode: z.literal(404),
    })
  },
  handler: async (event) => {
    const data = await readBody(event);
    const data2 = await readValidatedBody(event);

    return {
      message: "Book list",
      name: "",
    };
  },
});

const app = new H3();

app.addRoute(book);

zsilbi avatar Jun 05 '25 06:06 zsilbi

defineRoute looks very clean way to declare routes and register later, especially for composition in an H3-only project.

My main concern would be that we will eventually put too much opinion into defineRoute util, which makes it less flexible or extendable. Universal (client) route definitions are an interesting example @zsilbi mentioned. Also, in Nitro, we have a separate defineRouteMeta that is statically extractable.

I am thinking we could take a more generic approach to this, unblocking h3 core first.

app.register(spec)

Right now, we do have several methods app.use (for middleware), app.[method]/app.all/app.on for routes, and app.mount for fetch-compatible handlers.

While they are ergonomic to register things in place (one file), they are not declarative enough to compose from multiple places, which makes a proposal like app.addRoute(defineRoute()) immediately sensible.

From H3 core itself, we could support a declarative object register method. And this object could be made with different abstractions, an addRoute that only specifies route/method or different ways of validation, or RPC, etc.

It could start as simple as something like this:

app.register({ route: { method, route, handler } })

Then a defineRoute util, either from h3 core, an external/opininated library or nitro, can generate this object (app.register(defineRoute({}))), or even multiple versions of it, for example nitro can make one without handler but only meta for open API and on with route, method and validators for client-side.

pi0 avatar Jun 05 '25 06:06 pi0

I have ended up adding a more generic plugin interface in #1098

Imaginary implementation example for app.register(route) using experimental defineValidatedHandler (#1097)

// Plugin
const defineRoute = (routeObj) => definePlugin((app) => {
  app.on(routeObj.method || "", routeObj.route, defineValidatedHandler(routeObj))
})

// Define routes
const book = defineRoute({ method: "get", route: "/book", handler: () => "Hello World!" })

// Register Routes (as plugin)
const app = new H3().register(book())

pi0 avatar Jun 09 '25 20:06 pi0

actually my plugins dream was like this.

import { H3, defineEventHandler, defineRoute, readBody, readValidatedBody } from "h3";
import { z } from "zod";

export const book = defineRoute({
  method: "GET",
  route: "/api/books",
  plugins: [
   (event)=> {},
  ],

  ......

productdevbook avatar Jun 10 '25 02:06 productdevbook

  • middleware can hook to request (event)
  • plugins can hook to h3 instance (app)
    • and they can add middleware too!

In your example this works as intended:

export const book = defineRoute({
  method: "GET",
  route: "/api/books",
  middleware: [
   (event)=> {},
  ],

pi0 avatar Jun 10 '25 09:06 pi0

I think a name change is necessary.

middleware -> plugin

export const book = defineRoute({
  method: "GET",
  route: "/api/books",
  plugin: [
   (event)=> {},
  ],

and Plugin -> Module

// Module
const defineRoute = (routeObj) => defineModule((app) => {
  app.on(routeObj.method || "", routeObj.route, defineValidatedHandler(routeObj))
})

productdevbook avatar Jun 10 '25 11:06 productdevbook

Middleware is already a well-known term for request interception, we cannot just change it.

Plugin is also already a well-known term for extending libraries (similar to vue, etc)

I get you might be coming from Nitro (and Nuxt) context about term "Module". We had to introduce that term because Nuxt already had the concept of "Plugins" for runtime extensions (similar to here).

Apart from disussing about names, i think last part of this isue to be done is to export small defineRoute utility like you proposed.

pi0 avatar Jun 10 '25 11:06 pi0

I think defineRoute should definitely come out of here so that we can record steady progress in all kinds of projects.

productdevbook avatar Jun 10 '25 12:06 productdevbook

I agree! (releasing beta.0 shortly) we can finish work on defineRoute together once you made PR for beta.1

pi0 avatar Jun 10 '25 12:06 pi0

I've been thinking about this defineRoute idea and wanted to throw out another approach that might be pretty neat - what if we could define multiple HTTP methods for the same path all in one go?

const userCRUDRoute = defineRoute({
  path: '/users/:id?',
  schemas: {
    params: z.object({
      id: z.string().uuid().optional()
    })
  },
  methods: {
    GET: {
      schema: {
        response: z.array(UserSchema).or(UserSchema)
      },
      handler: async (event) => {
        const { id } = event.context.params
        return id ? await getUserById(id) : await getAllUsers()
      }
    },
    POST: {
      schema: {
        body: CreateUserSchema,
        response: UserSchema
      },
      handler: async (event) => {
        const body = await readBody(event)
        return await createUser(body)
      }
    },
    PUT: {
      schema: {
        body: UpdateUserSchema,
        response: UserSchema
      },
      handler: async (event) => {
        const { id } = event.context.params
        const body = await readBody(event)
        return await updateUser(id, body)
      }
    }
  }
})

This feels like it could fix those type inference headaches and make the code way more organized. Thoughts? Does this sound like something that could work, or am I way off base here? 🤔

OskarLebuda avatar Jun 10 '25 12:06 OskarLebuda

@OskarLebuda I have tested it and it is impossible to separate it on the cli side and to create tools like OpenAPI, or to combine path + methods in the object and type safety.

If we take advantage of the object's first-order merging power, everything is fast and error-free.

productdevbook avatar Jun 10 '25 12:06 productdevbook

@OskarLebuda I have tested it and it is impossible to separate it on the cli side and to create tools like OpenAPI, or to combine path + methods in the object and type safety.

I see - thnx 🙏🏻

OskarLebuda avatar Jun 10 '25 12:06 OskarLebuda

Thanks for sharing the idea @OskarLebuda, I was actually thinking the same.

@productdevbook I get that it makes the most sense for generated routes to be separated. Also, for performance reasons (lazy loading), we split routes with different methods in Nitro like this.

But respecting all different usecases (sometimes it is much more convenient to define methods in same export), let's support BOTH (why not!).

Either a handler for all or additional methods for method-specific handlers.

fetchdts also supports both when later we want to work on type inference.

pi0 avatar Jun 10 '25 12:06 pi0

Yes, we are open to all ideas, always share. The only thing that needs to be considered is how to merge the same path, method etc structures from different files, npm packages. We should think of interface merge events when you try to merge one url with another url. Because for one url there will be defineRoute coming from different modules and npm packages. and we will want to merge them in the same place. There are cases where they merge into the same url methods.

there is also this situation, if we allow everything, then when we write a CLI service for all users using h3, it will be complicated.

productdevbook avatar Jun 10 '25 12:06 productdevbook

defineRoute internally calls this.on which uses rou3. It makes a tree of all possible routes and matching methods for complicated part of merging.

In your CLI framework, you can limit options when it makes best sense. that's power of layers.

pi0 avatar Jun 10 '25 12:06 pi0

@pi0 You're right, I shouldn't divide opinions. They should test it and maybe they'll find a better way. @OskarLebuda We can test your idea and if you are sure, we can decide here to support it and work for the future.

productdevbook avatar Jun 10 '25 13:06 productdevbook

@pi0 You're right, I shouldn't divide opinions. They should test it and maybe they'll find a better way. @OskarLebuda We can test your idea and if you are sure, we can decide here to support it and work for the future.

If you need any help - let me know 🙌🏻

OskarLebuda avatar Jun 10 '25 13:06 OskarLebuda

@productdevbook Can you make a PR to add a minimal version of defineRoute / defineWebSocketRoute? (it can be added to src/utils/route.ts

pi0 avatar Jul 02 '25 08:07 pi0

I created a pr. Let's check it. https://github.com/h3js/h3/pull/1143 :D defineWebSocketRoute I will send it later as a separate pr

productdevbook avatar Jul 03 '25 03:07 productdevbook

I created a pr. Let's check it. defineWebSocketRoute - https://github.com/h3js/h3/pull/1149

productdevbook avatar Jul 07 '25 08:07 productdevbook

I think it would be helpful to reference how the hono framework approaches this problem This feature set is crucial for me. I would reference this implementation as we figure out the best way to do it.

One thing to note is the integration between Zod Validation, Schema definition, and Route definition. This way validation and schema only has to be defined once.

dan-hale avatar Oct 09 '25 18:10 dan-hale