Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Response serialization and validation #2439

Open
sher opened this issue Mar 30, 2024 · 6 comments
Open

Response serialization and validation #2439

sher opened this issue Mar 30, 2024 · 6 comments
Labels
enhancement New feature or request.

Comments

@sher
Copy link

sher commented Mar 30, 2024

What is the feature you are proposing?

Certain frameworks (eg: fastify) provide response validation for success or error payloads.
Is there support for such feature in hono? If not, is it something planned?

Context

Apart from obvious advantages like schema-first approach, this feature would greatly help securing API internals.
For example, when you have multiple unique IDs for DB records and you want to make sure that only external_id IDs are ever returned without accidentally leaking internal_id IDs.

@sher sher added the enhancement New feature or request. label Mar 30, 2024
@sher sher changed the title Response validation Response serialization and validation Mar 30, 2024
@yusukebe
Copy link
Member

Hi @sher

Thank you for sharing your opinion.

Is there support for such feature in hono? If not, is it something planned?

Hono does not have a future now. But there have been some discussions before. We tried to implement response validation in Zod OpenAPI, but declined because we thought we needed to work on Hono's core.

honojs/middleware#184

I think it would be better to make the Response Validator as thin as the current hono/validator is very thin, and let other libraries do the real validation.

@sher
Copy link
Author

sher commented Mar 30, 2024

Thanks @yusukebe

Agree, that the hono API should be as thin as possible and delegate validation to whatever library user wants to use. Currently handlers can accept multiple request validation functions. As the sequence of serialization and validation is reversed in response compared to request, it seems better to handle this inside every handler function before returning data.

const schema = z.object({
  email: z.string().email(),
  password: z.string(),
});

app.post('/me', async function (c) {
  const user = D1.findOne({email: c.body.email});
  const parsed = schema.safeParse(user);
    if (!parsed.success) {
      return c.text('Invalid!', 401);
    }
    return parsed.data;
});

But still this approach is not ideal, we want to specify the shape that has to be returned on every case and let the schema validator to handle the situation.

This makes the task bit difficult, because we have to have a common response shape declaration/specification "language", for example json-schema shape. Then the validator should take the shape and validate data against it.

@sher
Copy link
Author

sher commented Mar 30, 2024

@yusukebe

How about adding response:* keys to validators like this?

const route = app.on(
  'LIST_OF_USERS',
  '/users',
  validator('response:200', (value, c) => {
    const parsed = schema.safeParse(value);
    if (!parsed.success) {
      return c.text('Invalid!', 401);
    }
    return parsed.data;
  }),
  validator('response:400', () => {
    return {
      q: 'bar',
    };
  }),
  (c) => {
    return c.json({
      success: true,
    });
  }
);

@yusukebe
Copy link
Member

yusukebe commented Apr 3, 2024

@sher

My idea is to create another validator like responseValidator to validate response content. Below are a rough implementation. I think it's difficult to infer the response type in the handler, and the code will be complicated. But if it can run correctly, it will be very convenient.

const validationErrorResponse = (reason: string) => {
  return new Response(reason, {
    status: 401, // We have to set proper status code
  })
}
type ResponseValidatorFnResponse<T = any> =
  | {
      success: true
      data: T
    }
  | {
      success: false
      reason: string
    }

type ResponseValidatorFn<T = any> = (
  value: any
) => ResponseValidatorFnResponse<T> | Promise<ResponseValidatorFnResponse<T>>

const responseValidator =
  (status: number, validationFn: ResponseValidatorFn) => async (c, next) => {
    await next()
    if (!c.res.headers.get('content-type')?.startsWith('application/json')) {
      c.res = validationErrorResponse('The content is not JSON')
      return
    }
    if (c.res.status !== status) {
      c.res = validationErrorResponse(`The status is not ${status}`)
      return
    }
    const value = await c.res.json() // Maybe we have to clone the response body.
    const result = await validationFn(value)
    if (result.success === false) {
      c.res = validationErrorResponse(result.reason)
      return
    }
    const newResponse = new Response(JSON.stringify(result.data))
    c.res = newResponse
  }

const schema = z.object({
  success: z.boolean(),
})

app.get(
  '/results',
  responseValidator(200, (value) => {
    const parsed = schema.safeParse(value)
    if (!parsed.success) {
      return {
        success: false,
        reason: parsed.error.message,
      }
    }
    return {
      success: true,
      data: parsed.data,
    }
  }),
  (c) => {
    return c.json({
      // Is not typed currently
      success: true,
    })
  }
)

@marceloverdijk
Copy link
Contributor

I have the exact same case, I don't want some fields accidentialy leaking to the client.
It would be great to have compile time validation as well.

@elibolonur
Copy link

elibolonur commented Dec 18, 2024

Hey there,

I'm dealing with an issue in my project where I'm using Zod for schema validation and Prisma for database interactions within a Hono TypeScript API. I would like TypeScript to throw an error if there's a mismatch between the returned/fetched types (especially when Im playing with relations). For some types it works however, for response types it doesnt seem to work and also it doesnt infer correct type for the response types in openapi schema.

How I am tackling the validation of the 200 response type is simply by using const validatedUser = userGetSchema.safeParse(user) because otherwise, the OpenAPI schema does not match the response type for a 200 status code. As you may have guessed, this doesnt show any errors in the UI, unless I try to use the endpoint. Changing the select arguments of prisma.findMany either changes the response type in the OpenAPI schema or throws an error. Also defining the schema is not really affecting what prisma queries are doing either (normal I guess since it fetches all fields by default unless its defined like I do).

However anything request related stuff are working for example calling request: { body: jsonContentRequired(userCreateSchema) } but not the response types. So to be able to show correct types I need to call each safeParse for zod schema before returning the data, which eventually makes me land in this issue :) Is there a more clearer way than what I am doing below for now? Maybe a middleware? Or is this the only way for now? I may be doing something completely wrong as well, but just wanted to see if there is anything else I could do here and if there is a recommended way to achieve this tight coupling between Hono route, Zod schemas and Prisma queries with the help of rsponse serialization/validation.

Here's an example of my code:

// example schema
export const userGetSchema = UserSchema
  .omit({ password: true, salt: true })
  .extend({ settings: UserSettingsSchema.partial().nullable().optional() })
  .openapi('UserResponse')

type UserResponse = z.infer<typeof userGetSchema>

// example router
export const getOne = createRoute({
  path: '/users/{id}',
  method: 'get',
  tags,
  request: { params: IdParamsSchema },
  responses: {
    [HttpStatusCodes.OK]: jsonContent(userGetSchema, 'A user'),
    [HttpStatusCodes.NOT_FOUND]: jsonContent(notFoundSchema, 'User not Found' ),
    [HttpStatusCodes.UNPROCESSABLE_ENTITY]: jsonContent(
      createErrorSchema(IdParamsSchema),
      'Validation error',
    ),
  },
})

type GetOneRoute = typeof getOne

// example controller
const getOne: AppRouteHandler<GetOneRoute> = async (c) => {
  const { id } = c.req.valid('param')

  const user = await prisma.user.findUnique({
    where: { id },
    select: {
      ...defaultUserFieldsToReturn,
      settings: {
        select: {
          id: true,
          language: true,
          country: true,
        },
      },
      // ... other relations ...
    },
  })

  if (!user) {
    return c.json(
      { message: HttpStatusPhrases.NOT_FOUND },
      HttpStatusCodes.NOT_FOUND,
    )
  }

  const validation = userGetSchema.safeParse(user)

  if (!validation.success) {
    // here it actually works properly, if I dont include success, typescript screams
    return c.json({ success: false, error: JSON.parse(validation.error.message) }, HttpStatusCodes.UNPROCESSABLE_ENTITY)
  }

  // however here it is already parsed, so this will never complain actually
  // also I dont know how I can match this to prisma result
  return c.json(validation.data, HttpStatusCodes.OK)
}

export { getOne }

What I've Tried:

  • Parse the user data using userGetSchema.safeParse(user) to validate and return the types properly. This is important because otherwise, the fields don't show up correctly in the API reference or OpenAPI schema generated by Scalar.
  • Strongly couple Prisma query (findUnique and findMany) with Zod schemas to ensure type safety even before coming to the safeParse step so that it would actually show if zod schema & prisma select/include/attrs not matching to with no luck

Additional Information:

Environment:
Hono version: 4.6.13
Prisma version: 6.0.1
TypeScript version: 5.4.5
Zod version: 3.24.1

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
enhancement New feature or request.
Projects
None yet
Development

No branches or pull requests

4 participants