middleware icon indicating copy to clipboard operation
middleware copied to clipboard

Hono/Zod-OpenAPI question: how to validate a multi-field form data

Open abdurahmanshiine opened this issue 1 year ago • 12 comments

Hey there,

I'm using this package, and I have a request that's of type multipart/form-data. The request body contains two keys, one is a JSON data, and the other one is a File data. I want to validate the JSON data only after parsing it as JSON, but I realized there is no multipart/form-data option, and now I have no idea how to go about this.

This is my code for reference:

export const create = createRoute({
  tags: ["Curriculums"],
  path: curriculumsBasePath,
  method: "post",
  request: {
    body: {
      content: {
        // I want to change this to `multipart/form-data` but typescript tells me that's not an option
        "application/json": {
          schema: CurriculumSchemas.createSchema,
        },
      },
      description,
      required: true,
    },
  },
  responses: // ...
});

Any help is appreciated

abdurahmanshiine avatar Dec 25 '24 11:12 abdurahmanshiine

Hi @abdurahmanshiine

It does not support the type multipart/form-data but it accepts string so you can use multipart/form-data as a string like this:

import { createRoute, z, OpenAPIHono } from '@hono/zod-openapi'

const route = createRoute({
  method: 'post',
  path: '/books',
  request: {
    body: {
      content: {
        'multipart/form-data': {
          schema: z.object({
            formValue: z.string()
          })
        },
        'application/json': {
          schema: z.object({
            jsonValue: z.string()
          })
        }
      }
    }
  },
  responses: {
    200: {
      description: 'Success message'
    }
  }
})

const app = new OpenAPIHono()

app.openapi(route, (c) => {
  const validatedBody = c.req.valid('form')
  return c.json(validatedBody)
})

const form = new FormData()
form.append('formValue', 'foo')

const res = await app.request('/books', {
  method: 'POST',
  body: form
})

const data = await res.json()
console.log(data) // foo

yusukebe avatar Dec 31 '24 08:12 yusukebe

Hey @yusukebe Thanks for your reply. I think you misunderstood me partially. My req body is just a multipart/form-data but one field is used to upload a file, and the other to send a serialized JSON object. My problem now is that only the schema defined under the multipart/form-data receives the req body, but the other - defined under application/json - doesn't. On top of that, typescript doesn't even recognize this string multipart/form-data as a valid field name, and that leads to some type errors. For example, c.req.valid("form") throws this error: Argument of type 'string' is not assignable to parameter of type 'never'.

The solution I found so far is to preprocess the data and deserialize the JSON object before validation, but I still have to use the multipart/form-data as the field name, which typescript isn't recognizing

abdurahmanshiine avatar Dec 31 '24 11:12 abdurahmanshiine

@abdurahmanshiine Thank you for the explanation.

My req body is just a multipart/form-data but one field is used to upload a file, and the other to send a serialized JSON object.

It's not expected usage, so I think this middleware may not fully support that use case. It's difficult to investigate if I don't know the route definition that you did. But again, it's not expected usage.

yusukebe avatar Jan 01 '25 05:01 yusukebe

@yusukebe Oh ok. So what would you recommend me do if I wanted to build an endpoint that accepts raw textual data along with a file?

abdurahmanshiine avatar Jan 01 '25 05:01 abdurahmanshiine

@abdurahmanshiine

How about this? Perhaps you may validate message as a JSON value if you can set the Zod validation rule properly.

import { createRoute, z, OpenAPIHono } from '@hono/zod-openapi'

const route = createRoute({
  method: 'post',
  path: '/',
  request: {
    body: {
      content: {
        'multipart/form-data': {
          schema: z.object({
            file: z.instanceof(File),
            message: z.string()
          })
        }
      },
      required: true
    }
  },
  responses: {
    200: {
      description: 'Success uploading'
    }
  }
})

const app = new OpenAPIHono()

app.openapi(route, (c) => {
  const data = c.req.valid('form')
  // const filedata = data.file
  return c.json({ message: data.message })
})

export default app

yusukebe avatar Jan 01 '25 05:01 yusukebe

Alright, let me try this out

abdurahmanshiine avatar Jan 01 '25 05:01 abdurahmanshiine

@abdurahmanshiine I just got this to work using:

import { Blob } from "buffer";

// ...

        request: {
        headers: HeadersSchema,
        body: {
            content: {
                "multipart/form-data": {
                    schema: z.object({
                        files: z
                            .instanceof(Blob)
                            .array()
                            .openapi({ format: "binary" })
                            .or(
                                z.instanceof(Blob).openapi({ format: "binary" })
                            ),
                    }),
                },
            },
        },
    },

The instanceof example above gave me the idea, but File was an interface and didn't work. Blob worked, though.

Obviously I'm uploading an array of files, so I need the array option; if you're just uploading one file, you can probably use the part in the "or".

TimMensch avatar Jan 01 '25 19:01 TimMensch

Hey @TimMensch I tried this solution, but the issue is that the type inference is getting lost. When I try c.req.valid("form") in the handler, it doesn't recognize it, and it throws this error Argument of type 'string' is not assignable to parameter of type 'never'. Did you find anyway around that?

abdurahmanshiine avatar Jan 02 '25 10:01 abdurahmanshiine

Mine works:

c.req.valid("form").files

This shows up as Blob | Blob[] for me.

I'm using TypeScript 5.6.2.

TimMensch avatar Jan 03 '25 01:01 TimMensch

Hey @yusukebe,

I think the issue is arising from this type ZodMediaType which I believe is defined by the zod-to-openapi package. This type doesn't allow multipart/form-data, which causes the c.req.valid() not to recognize the type "form", if I'm not mistaken. Is there anything you could do about that? Uploading Screenshot 2025-01-04 130005.png…

abdurahmanshiine avatar Jan 04 '25 10:01 abdurahmanshiine

@abdurahmanshiine

ZodMediaType does not support multipart/form-data but it can be inferred correctly in this Zod OpenAPI:

CleanShot 2025-01-05 at 18 47 11@2x

CleanShot 2025-01-05 at 18 46 32@2x

Code:

import { createRoute, z, OpenAPIHono } from '@hono/zod-openapi'

const route = createRoute({
  method: 'post',
  path: '/',
  request: {
    body: {
      content: {
        'multipart/form-data': {
          schema: z.object({
            message: z.string()
          })
        }
      }
    }
  },
  responses: {
    200: {
      description: 'success!'
    }
  }
})

const app = new OpenAPIHono()

app.openapi(route, (c) => {
  const data = c.req.valid('form')
  return c.json({ message: data.message })
})

yusukebe avatar Jan 05 '25 09:01 yusukebe

I don't know why (still a noob) but my openapi wasn't working when using z.file() or z.instanceof(File) for validation. So I tried something different. Just posting in case zod-openapi docs breaks when you try to validate files.

import { createRoute, z } from "@hono/zod-openapi";
import app from "./app.ts";  // where app = new OpenAPIHono<{ _Bindings , Varaibles, ..... etc }>( )

const MAX_FILE_SIZE = 2 * 1024 * 1024;
const ACCEPTED_IMAGE_TYPES = ["image/png"]

const testRoutes = app; 

testRoutes
    .openapi(

        createRoute({
            method: "post",
            path: "/auth/test",
            tags: ["test"],
            summary: "testing file with post",
            description: 'test',
            request: {
                body: {
                    content: {
                        "multipart/form-data": {
                            schema: z.object({
                                message: z.string().openapi({ example: "Img caption text." }),
                                img: z
                                    .any()
                                    .refine((file) => file?.size <= MAX_FILE_SIZE, `Max image size is 5MB.`)
                                    .refine(
                                        (file) => ACCEPTED_IMAGE_TYPES.includes(file?.type),
                                        "Only .png formats are supported."
                                    )
                            })
                        }
                    },
                    required: true
                }
            },
            responses: {
                200: {
                    description: 'login successfull response',
                    content: {
                        'application/json': {
                            schema: z.object({
                                message: z.string()
                            })
                        },
                    },
                },
                400: {
                    description: 'wrong credientials',
                    content: {
                        'application/json': {
                            schema: z.object({
                                message: z.string()
                            })
                        },
                    },
                }
            },
        }),

        (c) => {
            const b = c.req.valid('form');
            console.log(b); // for checking purposes
            return c.json({ message: "Hi user " + b.message }, 200);
        }
    )

export default testRoutes;

Hope this helps

nthadrien avatar Sep 10 '25 02:09 nthadrien