diff --git a/httplib.h b/httplib.h index 9a5b8e75..004ecdb8 100644 --- a/httplib.h +++ b/httplib.h @@ -1680,6 +1680,34 @@ make_multipart_content_provider(const UploadFormDataItems &items, } // namespace detail +bool is_valid_multipart_boundary(const std::string &boundary); + +// Serializer for multipart/form-data request bodies. The boundary is owned +// by the writer so that per-part framing and the final terminator always +// agree. Field names and filenames are escaped following the WHATWG HTML +// standard ('"' -> %22, CR -> %0D, LF -> %0A). +class MultipartFormDataWriter { +public: + MultipartFormDataWriter(); + // precondition: is_valid_multipart_boundary(boundary) + explicit MultipartFormDataWriter(std::string boundary); + + const std::string &boundary() const; + std::string content_type() const; + + // In-memory items -> whole body (known length) + std::string serialize(const UploadFormDataItems &items) const; + size_t content_length(const UploadFormDataItems &items) const; + + // Per-part framing for streaming via a content provider + std::string item_begin(const UploadFormData &item) const; + static std::string item_end(); + std::string finish() const; + +private: + std::string boundary_; +}; + class Server { public: using Handler = std::function; @@ -10002,6 +10030,48 @@ inline size_t MultipartFormData::get_file_count(const std::string &key) const { return static_cast(std::distance(r.first, r.second)); } +// Multipart FormData writer implementation +inline bool is_valid_multipart_boundary(const std::string &boundary) { + return detail::is_multipart_boundary_chars_valid(boundary); +} + +inline MultipartFormDataWriter::MultipartFormDataWriter() + : boundary_(detail::make_multipart_data_boundary()) {} + +inline MultipartFormDataWriter::MultipartFormDataWriter(std::string boundary) + : boundary_(std::move(boundary)) {} + +inline const std::string &MultipartFormDataWriter::boundary() const { + return boundary_; +} + +inline std::string MultipartFormDataWriter::content_type() const { + return detail::serialize_multipart_formdata_get_content_type(boundary_); +} + +inline std::string +MultipartFormDataWriter::serialize(const UploadFormDataItems &items) const { + return detail::serialize_multipart_formdata(items, boundary_); +} + +inline size_t MultipartFormDataWriter::content_length( + const UploadFormDataItems &items) const { + return detail::get_multipart_content_length(items, boundary_); +} + +inline std::string +MultipartFormDataWriter::item_begin(const UploadFormData &item) const { + return detail::serialize_multipart_formdata_item_begin(item, boundary_); +} + +inline std::string MultipartFormDataWriter::item_end() { + return detail::serialize_multipart_formdata_item_end(); +} + +inline std::string MultipartFormDataWriter::finish() const { + return detail::serialize_multipart_formdata_finish(boundary_); +} + // Response implementation inline size_t Response::get_header_value_u64(const std::string &key, size_t def, size_t id) const { diff --git a/test/test.cc b/test/test.cc index a39cf7f9..1f484814 100644 --- a/test/test.cc +++ b/test/test.cc @@ -6591,6 +6591,93 @@ TEST(MultipartFormDataTest, FieldEscaping) { EXPECT_EQ(StatusCode::OK_200, res->status); } +TEST(MultipartFormDataTest, PublicWriterAPI) { + const UploadFormDataItems items = { + {"name1", "Content 1", "", ""}, + {"name2", "Content 2", "file2.txt", "text/plain"}, + }; + + Server svr; + + svr.Post("/post", [&](const Request &req, Response & /*res*/, + const ContentReader &content_reader) { + ASSERT_TRUE(req.is_multipart_form_data()); + std::vector received; + content_reader( + [&](const FormData &file) { + received.push_back(file); + return true; + }, + [&](const char *data, size_t data_length) { + received.back().content.append(data, data_length); + return true; + }); + + ASSERT_EQ(2U, received.size()); + EXPECT_EQ("name1", received[0].name); + EXPECT_EQ("Content 1", received[0].content); + EXPECT_EQ("name2", received[1].name); + EXPECT_EQ("Content 2", received[1].content); + EXPECT_EQ("file2.txt", received[1].filename); + EXPECT_EQ("text/plain", received[1].content_type); + }); + + auto port = svr.bind_to_any_port("localhost"); + auto t = std::thread([&]() { svr.listen_after_bind(); }); + auto se = detail::scope_exit([&] { + svr.stop(); + t.join(); + ASSERT_FALSE(svr.is_running()); + }); + + svr.wait_until_ready(); + + Client cli("localhost", port); + + // Whole-body serialization with a generated boundary + { + MultipartFormDataWriter writer; + EXPECT_TRUE(is_valid_multipart_boundary(writer.boundary())); + EXPECT_EQ("multipart/form-data; boundary=" + writer.boundary(), + writer.content_type()); + + auto body = writer.serialize(items); + EXPECT_EQ(body.size(), writer.content_length(items)); + + auto res = cli.Post("/post", body, writer.content_type()); + ASSERT_TRUE(res); + EXPECT_EQ(StatusCode::OK_200, res->status); + } + + // Per-part framing with a custom boundary via a content provider + { + EXPECT_FALSE(is_valid_multipart_boundary("bad boundary")); + ASSERT_TRUE(is_valid_multipart_boundary("custom-boundary_123")); + + MultipartFormDataWriter writer("custom-boundary_123"); + EXPECT_EQ("custom-boundary_123", writer.boundary()); + + std::string body; + for (const auto &item : items) { + body += writer.item_begin(item); + body += item.content; + body += MultipartFormDataWriter::item_end(); + } + body += writer.finish(); + EXPECT_EQ(body.size(), writer.content_length(items)); + + auto res = cli.Post( + "/post", body.size(), + [&](size_t offset, size_t length, DataSink &sink) { + sink.write(body.data() + offset, length); + return true; + }, + writer.content_type()); + ASSERT_TRUE(res); + EXPECT_EQ(StatusCode::OK_200, res->status); + } +} + TEST_F(ServerTest, PostContentReceiverGzip) { cli_.set_compress(true); auto res = cli_.Post("/content_receiver", "content", "text/plain");