middleware icon indicating copy to clipboard operation
middleware copied to clipboard

[zod-openapi] The recommended way to apply middleware loses Context type info

Open wfjsw opened this issue 1 year ago • 19 comments

I want to use a middleware to somehow change the type of the context:

const app = new Hono<HonoEnv>();
type AuthenticationVariable = {
  user?: string;
};

export type WithAuthenticatedRequest = {
  Variables: AuthenticationVariable;
};

export const requireAuth = createMiddleware<HonoEnv & WithAuthenticatedRequest>(...)
// The ctx here transforms from Context<HonoEnv> to Context<HonoEnv & WithAuthenticatedRequest>
app.get('/protected', requireAuth, async (ctx) => {});

However, this is currently impossible to do with the use approach:

const app = new OpenAPIHono<HonoEnv>();

app.use(route.getRoutingPath(), requireAuth);

// The ctx here only has Context<HonoEnv>
app.openapi(route, async (ctx) => {});

And I cannot find apparent workaround. If I manually type the ctx, it will lose input/output info.

wfjsw avatar Jul 11 '24 16:07 wfjsw

Yes, I am encountering this as well. I also do not like the approach mentioned in the docs, where you have to specify the middleware in createRoute call. It kind of breaks the separation of concerns for me.

@yusukebe would it be possible to have something like app.use(middleware).openapi(route, async (c) => yourcode) ?

askorupskyy avatar Jul 14 '24 10:07 askorupskyy

Hey @wfjsw I think I figured it out.

What you need to do is create a router with env included, like:

const authRouter = new OpenAPIHono<{ Variables: ContextVariables }>();

Which on one hand sucks, because you'll need to double-check if the variable in the context exists (without openapi, if the middleware is used, then ts would 100% know it's there)

However, with this you'll now have better type-completion on the front-end so you'll be able to catch your errors better.

You need to define your error message as such in the definition:

401: {
  description: 'Unauhorized',
  content: {
    'application/json': { schema: z.object({ message: z.string() }) },
  },
},

And then return in the router:

if (!c.var.user?.id) {
  return c.json({ message: 'Unauthorized' }, 401);
}

Which is not ideal, but still gives you auto-completion pretty much everywhere. I think ideally Hono should have type-safe middleware implemented.

askorupskyy avatar Jul 14 '24 12:07 askorupskyy

Hi @wfjsw

However, this is currently impossible to do with the use approach: And I cannot find apparent workaround. If I manually type the ctx, it will lose input/output info.

I can understand what you want to do well. But unfortunately, you can only do that even if it is ZodOpenAPI or Hono.

const app = new Hono()

app.use(requireAuth)
app.get('/foo', (c) => {
  c.get('') // NG: Can't infer `user`
})

This is because app can't keep the type of requireAuth. And if you are using Hono it can infer with that syntax:

const app = new Hono()

app.use(requireAuth).get('/foo', (c) => {
  c.get('user') // OK: Can infer user
})

However, you can't write the following with ZodOpenAPI.

const app = new OpenAPIHono();

app.use(route.getRoutingPath(), requireAuth).openapi // NG

This is because the app is always Hono instead of OpenAPIHono. Unfortunately, this is a TypeScript limitation.

So, the only way we can do this is to pass it as generics for OpenAPIHono.

const app = new OpenAPIHono<HonoEnv & WithAuthenticatedRequest>()

yusukebe avatar Jul 19 '24 14:07 yusukebe

Facing the same issue :(

sventi555 avatar Jul 22 '24 19:07 sventi555

So, the only way we can do this is to pass it as generics for OpenAPIHono.

const app = new OpenAPIHono<HonoEnv & WithAuthenticatedRequest>()

I kinda get this for keeping the types, but how do you apply the middleware? Is it:

const route = createRoute({
  middlewares: [withAuthenticatedRequest]
})

const endpoint = new OpenApiHono().openapi(route, (c) => {
   // ...
})

Blankeos avatar Oct 23 '24 19:10 Blankeos

@Blankeos yeah that's how I do it. The other way is via use() but it applies to the route (all GET, POST, PATCH, etc) and not specific endpoint

askorupskyy avatar Oct 23 '24 19:10 askorupskyy

Ahh true. Thanks @askorupskyy

Yeah I settled on a trpc-like procedure instead:

export const h = {
   get public() {
      return new OpenApiHono()
   },
   get private() {
      const app = new OpenApiHono<MyMiddlewareBindings>();
   
      app.use(myMiddleware);
   
      return app;
   }
} 

const route = createRoute({
    // ...
});

const endpoint = h.private.openapi(route, async (c) => {
   // ...
})

The typesafety works so far, but have not tested it yet.


Update: Okay I have tested, and it sucks to use app.use() here before defining the route.

Mainly because doing this:

const myController = new OpenApiHono()
   .route("/", myEndPoint1) // the middleware used by this, will go below (e.g. private)
   .route("/", myEndPoint2) // even if this is public, it will use the private middleware.

Blankeos avatar Oct 23 '24 20:10 Blankeos

This is my rough workaround for now, I using this approach to solve the problem temporarily.

// OpenAPIHonoFactory.ts
import { createRoute, OpenAPIHono, z } from "@hono/zod-openapi"
import type { Env as HonoEnv, MiddlewareHandler } from "hono"
import type { IsAny } from "hono/utils/types"

type OpenApiRoute = ReturnType<typeof createRoute>

export class OpenAPIHonoFactory<E extends HonoEnv = HonoEnv> {
  constructor(public route: OpenApiRoute) {}

  middlewares: MiddlewareHandler[] = []

  createApp() {
    const app = new OpenAPIHono<E>()

    for (const middleware of this.middlewares) {
      app.use(this.route.path, middleware)
    }

    return app
  }

  use<ExtraEnv extends HonoEnv>(
    middleware: MiddlewareHandler<ExtraEnv>,
  ): IsAny<ExtraEnv> extends true
    ? OpenAPIHonoFactory<E>
    : OpenAPIHonoFactory<E & ExtraEnv> {
    this.middlewares.push(middleware)
    return this as any
  }
}

Here’s how to use it:

import { createRoute } from "@hono/zod-openapi"
import { OpenAPIHonoFactory } from "./OpenAPIHonoFactory"
import type { MiddlewareHandler } from "hono"

const route = createRoute({
  path: "/",
  method: "get",
  responses: {
    200: {
      description: "OK",
    },
  },
})

const factory = new OpenAPIHonoFactory(route)

const myMiddleware1: MiddlewareHandler<{ Variables: { foo: string } }> = (
  c,
  next,
) => {
  c.set("foo", "bar")
  return next()
}

const myMiddleware2: MiddlewareHandler<{
  Variables: { hi: (name: string) => void }
}> = (c, next) => {
  c.set("hi", (name: string) => console.log(`hello ${name}`))
  return next()
}

const app = factory
  .use(myMiddleware1)
  .use(myMiddleware2)
  .createApp()
  .openapi(route, (c) => {
    c.var.hi("world")
    return c.text(c.var.foo)
  })

export default app

image

I might create another package for this purpose. Take a look at this. https://github.com/honojs/middleware/issues/758#issuecomment-2387632283

maou-shonen avatar Nov 01 '24 08:11 maou-shonen

The initial issue is now solved with @hono/[email protected], right?

https://github.com/honojs/middleware/issues/715#issuecomment-2461800286

Here my suggested edit to the docs to make this more clear: https://github.com/honojs/middleware/pull/812/files

oberbeck avatar Nov 07 '24 10:11 oberbeck

The initial issue is now solved with @hono/[email protected], right?

setting middleware in the route definition indeed works. The issue remains when setting it globally for an OpenAPIHono instance. e.g.

const authApp = new OpenAPIHono();
authApp.use(bearerAuth);

authApp.get('/user', (c) => {
  const user = c.get('user'); // <--- Argument of type '"user"' is not assignable to parameter of type 'never'. ts(2769)
  return c.json({user}, 200);
});

// or
authApp.route('/', user); 

mbrevda avatar Jul 22 '25 09:07 mbrevda

@mbrevda

Where is the user type defined?

yusukebe avatar Jul 22 '25 13:07 yusukebe

Yeah - sorry. That's defined in middleware, elsewhere:

export const bearerAuth = createMiddleware<{Variables: {user: LocalsUser}}>(async (c, next) => {
  c.set('user', await authMiddleware(c.req.header('Authorization')));
  await next();
});

mbrevda avatar Jul 22 '25 13:07 mbrevda

@mbrevda

Thanks. Unfortunately, it's impossible. It's not only a Zod OpenAPI issue, but hono also can't do it. So, you have to use the chain pattern.

https://hono.dev/docs/guides/best-practices#if-you-want-to-use-rpc-features

yusukebe avatar Jul 22 '25 13:07 yusukebe

Thanks. Can app.openapi be chained?

mbrevda avatar Jul 22 '25 13:07 mbrevda

@mbrevda Yes.

Image

yusukebe avatar Jul 22 '25 13:07 yusukebe

thanks. I meant to ask: can we chain app.openapi(createRoute({})) and then another somehow, and ofcourse, keep the types

mbrevda avatar Jul 22 '25 13:07 mbrevda

@mbrevda

Please show the specific code.

yusukebe avatar Jul 22 '25 13:07 yusukebe

Hi, I've proposed a PR related to this: https://github.com/honojs/hono/pull/4428

With this PR:

  • No need to define your own Variables/Bindings or augment ContextVariableMap (like hono/request-id did)
  • Just define your middleware with .use() and add the inferred Hono's Env to your other OpenAPIHonos.

I think this would be safer because changing/removing .use(middleware) will remove context variables also. Whereas if you define it in your own type, TS won't complain, leading to call to c.var.xx being unnoticedly broken in runtime.

This is the pattern I'll use if this gets merged: (actually i already used it, patching hono locally with pnpm with the changes in the pr)

// base-app.ts
const _baseApp = new OpenAPIHono();

const baseAppWithMiddlewares = _baseApp
  .use(pinoLogger()) // Adds logger into c.var
  .use(cors())
  // This middleware below adds some more variables
  .use(
    "*",
    createMiddleware<{
      Variables: {
        user: ... | null;
        session: ... | null;
      };
    }>(async (c, next) => {
      // better-auth things...
    }),
  );

// baseAppWithMiddlewares type is now `Hono<{ ... }>`,
// adding `.use()` changes the type...

// ✅ Using ["~env"], we can infer `Env` automatically from the app with middlewares
type AppEnv = (typeof baseAppWithMiddlewares)["~env"];

(_baseApp as OpenAPIHono<AppEnv>).notFound((c) => {
  c.var.logger.error("404 detected"); // ✅ available
  // return error res...
});

(_baseApp as OpenAPIHono<AppEnv>).onError((error, c) => {
  c.var.logger.error(error.message); // ✅ available
  // return error res...
});

// expose OpenAPIHono with correct env context, without having to define the types:

export const baseApp = _baseApp as OpenAPIHono<AppEnv>; // will be used in app's entry point
export const createRoute = () => new OpenAPIHono<AppEnv>();
// auth.route.ts

export const authRoutes = createRoute().openapi(
  someRoute,
  (c) => {
    c.var.logger // ✅ available when creating route
  }
)

masnormen avatar Sep 27 '25 13:09 masnormen

Q: How to make middleware aware of the validated request body from the route schema?

Description

I’m trying to make my middleware aware of the validated request body defined in the route schema. Inside my route handler, c.req.valid('json') correctly infers and validates the body according to the schema, but inside middleware, it does not.

Here’s the route definition:

export const createCemeteryRoute = createRoute({
  tags,
  path: basePath,
  method: 'post',
  summary: 'Create a new cemetery',
  description: 'Authenticated users can create a new cemetery.',
  middleware: [
    authMiddleware(),
    createMiddleware(async (c, next) => {
      const data = await c.req.valid('json'); // <-- not aware of the schema here
      const resourceId = data.cityId;

      return restrictAccessMiddleware({
        check: 'role-and-geo',
        accessLevel: 'city',
        resourceId,
      })(c, next);
    }),
  ],
  request: {
    headers: authorizationHeaderSchema,
    body: jsonContentRequired(
      drizzleZodCemeteriesSchema.insert,
      'The cemetery to create'
    ),
  },
  responses: {
    [CREATED_CODE]: jsonContent(
      drizzleZodCemeteriesSchema.select,
      'The created cemetery'
    ),
    [UNPROCESSABLE_ENTITY_CODE]: jsonContent(
      createErrorSchema(drizzleZodCemeteriesSchema.insert),
      'The validation error(s)'
    ),
    [INTERNAL_SERVER_ERROR_CODE]: jsonContent(
      internalServerErrorSchema,
      'Failed to create cemetery'
    ),
    [UNAUTHORIZED_CODE]: jsonContent(
      unauthorizedSchema,
      'Missing or invalid authentication'
    ),
    [FORBIDDEN_CODE]: jsonContent(
      forbiddenSchema,
      'Authenticated but not allowed to access this resource'
    ),
  },
});

export type CreateCemeteryRoute = typeof createCemeteryRoute;

In the route handler, this works perfectly:

export const createCemeteryHandler: AppRouteHandler<CreateCemeteryRoute> = async c => {
  const data = c.req.valid('json'); // works fine here
  const database = c.get('drizzle');

  const { data: createdData, error } = await createCemetery(database, data);

  if (error) {
    return c.json(
      { success: false, message: 'Failed to create cemetery' },
      INTERNAL_SERVER_ERROR_CODE
    );
  }

  return c.json(createdData, CREATED_CODE);
};

Expected Behavior

c.req.valid('json') inside middleware should be aware of (and typed according to) the route’s request.body schema, just like in the handler.

Actual Behavior

In middleware, c.req.valid('json') doesn’t have type inference or schema awareness, it returns a generic type instead of the inferred validated data type.

Question

Is there a recommended way to make middleware aware of the validated request body defined in the route schema? Or is this currently unsupported by design? Or a suggested workaround, maybe?

alwalxed avatar Oct 05 '25 10:10 alwalxed