Hono/Zod-OpenAPI question: how to validate a multi-field form data
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
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
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 Thank you for the explanation.
My req body is just a
multipart/form-databut 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 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
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
Alright, let me try this out
@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".
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?
Mine works:
c.req.valid("form").files
This shows up as Blob | Blob[] for me.
I'm using TypeScript 5.6.2.
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?
@abdurahmanshiine
ZodMediaType does not support multipart/form-data but it can be inferred correctly in this Zod OpenAPI:
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 })
})
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