typescriptmultermultipartform-datafastify

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 'media=@flame-wolf.png;type=image/png' \
  -F 'media-description=' \

Why is this not working?


Solution

  • 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.