Mr. X
Mr. X

Reputation: 897

Fastify multipart/form-data error: "body must be object"

I'm using fastify-multer and JSON Schema to submit multipart form data that may include a file. No matter what I do, Fastify keeps giving me a bad response error:

{
  "statusCode": 400,
  "error": "Bad Request",
  "message": "body must be object"
}

Here is my index.ts:

const server = fastify();
server.register(require("@fastify/cors"));
server.register(multer.contentParser).after(() => {
    if (!isProdEnv) {
        server.register(require("@fastify/swagger"), {
            /* ... */
        });
    }
    server.register(require("@fastify/auth")).after(() => {
        server.decorate("authenticateRequest", authenticateRequest);
        server.decorate("requireAuthentication", requireAuthentication);
        server.addHook("preHandler", server.auth([server.authenticateRequest]));
        server.register(indexRouter);
        server.register(authRouter, { prefix: "/auth" });
        server.register(usersRouter, { prefix: "/users" });
        server.register(listsRouter, { prefix: "/lists" });
        server.register(postsRouter, { prefix: "/posts" });
        server.register(searchRouter, { prefix: "/search" });
        server.register(settingsRouter, { prefix: "/settings" });
    });
});
server.setErrorHandler((err, req, res) => {
    req.log.error(err.toString());
    res.status(500).send(err);
});

And the /posts/create endpoint:

const postsRouter = (server: FastifyInstance, options: FastifyPluginOptions, next: HookHandlerDoneFunction) => {
    server.post(
        "/create",
        {
            schema: {
                consumes: ["multipart/form-data"],
                body: {
                    content: {
                        type: "string"
                    },
                    media: {
                        type: "string",
                        format: "binary"
                    },
                    "media-description": {
                        type: "string"
                    }
                }
            },
            preHandler: [server.auth([server.requireAuthentication]), uploadMediaFileToCloud]
        },
        postsController.createPost
    );
    next();
};

export default postsRouter;

Request CURL:

curl -X 'POST' \
  'http://localhost:3072/posts/create' \
  -H 'accept: */*' \
  -H 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJoYW5kbGUiOiJ1bGtrYSIsInVzZXJJZCI6IjYyNGQ5NmY4NzFhOTI2OGY2YzNjZWExZCIsImlhdCI6MTY1NzEwNTg5NCwiZXhwIjoxNjU3NDA1ODk0fQ.A5WO3M-NhDYGWkILQLVCPfv-Ve-e_Dlm1UYD2vj5UrQ' \
  -H 'Content-Type: multipart/form-data' \
  -F 'content=Test.' \
  -F '[email protected];type=image/png' \
  -F 'media-description=' \

Why is this not working?

Upvotes: 4

Views: 11345

Answers (2)

Robey Pointer
Robey Pointer

Reputation: 251

You can now do this with @fastify/multipart by asking it to attach all multipart form fields to an object as if it was JSON, using the magic value "keyValues":

server.register(multipart, {
  attachFieldsToBody: "keyValues",
  ...
});

Mark the file(s) as type "object" in the schema:

const SCHEMA_API_UPLOAD = {
  consumes: [ "multipart/form-data" ],
  body: {
    type: "object",
    properties: {
      file: { type: "object" },
      description: { type: "string" },
    }
  }
};

Then each form field will be a field on the request.body object:

server.post("/upload", { schema: SCHEMA_API_UPLOAD }, async (request, reply) => {
    console.log(request.body);
});

And you can see the object via curl:

$ curl -v localhost:8080/upload -F description="something good" -F [email protected]

...

[20250109-20:02:50.501] INF (uy33kj8cjzw6) [127.0.0.1:48898] POST /upload
[20250109-20:02:50.502] DEB (uy33kj8cjzw6) starting multipart parsing
[20250109-20:02:50.502] TRA (uy33kj8cjzw6) Providing options to busboy
{
  description: 'something good',
  file: <Buffer ff d8 ff e0 ... 61483 more bytes>
}

Upvotes: 2

Mr. X
Mr. X

Reputation: 897

EDIT 2: Apparently, there is a really easy solution for this: Use multer in the preValidation hook instead of preHandler. So, a piece of working code will look like this:

server.register(multer.contentParser).after(() => {
    server.register(
        (instance: FastifyInstance, options: FastifyPluginOptions, next: HookHandlerDoneFunction) => {
            instance.post(
                "/create",
                {
                    schema: {
                        consumes: ["multipart/form-data"],
                        body: {
                            type: "object",
                            properties: {
                                content: {
                                    type: "string"
                                },
                                media: {
                                    type: "string",
                                    format: "binary"
                                }
                            }
                        }
                    },
                    preValidation: multer({
                        limits: {
                            fileSize: 1024 * 1024 * 5
                        },
                        storage: multer.memoryStorage()
                    }).single("media")
                },
                (request: FastifyRequest, reply: FastifyReply) => {
                    const content = (request.body as any).content as string;
                    const file = (request as any).file as File;
                    if (file) {
                        delete file.buffer;
                    }
                    reply.send({
                        content,
                        file: JSON.stringify(file) || "No file selected"
                    });
                }
            );
            next();
        },
        { prefix: "/posts" }
    );
});

EDIT: After I posted the answer below, I was able to find a solution for this. Updating my answer for anyone else who might encounter the same issue.

First, I switched to @fastify/multipart from fastify-multer. Then I removed the type property from the media field.

media: {
    format: "binary"
}

After this, I added the option{ addToBody: true } when registering @fastify/multipart.

import fastifyMultipart from "@fastify/multipart";

server.register(fastifyMultipart, { addToBody: true }).after(() => { ... });

After these changes, the field media became available in request.body.


OLD ANSWER:

Seems like these days I have to answer my own questions here. Anyway, I figured out what's happening. Fastify's built-in schema validation doesn't play well with multipart/form-data. I played around with the schema specification to make sure that this is the case. So I removed schema validation from all routes. My use case here was porting an API from ExpressJS to Fastify, so I had a nice Swagger JSON spec generated using express-oas-generator lying around. I used that to generate Swagger UI and everything worked fine. I hope Fastify gets its act together and sorts out this issue.

Upvotes: 8

Related Questions