diff --git a/chaotic-openapi/chaotic_openapi/back/cpp/client/templates/requests.application.multipart.formdata.jinja b/chaotic-openapi/chaotic_openapi/back/cpp/client/templates/requests.application.multipart.formdata.jinja index 5f80f95f853c..ca5e0865ba98 100644 --- a/chaotic-openapi/chaotic_openapi/back/cpp/client/templates/requests.application.multipart.formdata.jinja +++ b/chaotic-openapi/chaotic_openapi/back/cpp/client/templates/requests.application.multipart.formdata.jinja @@ -2,7 +2,7 @@ const auto &data = {{ request }}; USERVER_NAMESPACE::clients::http::Form form; - {% for field_name, field in body.schema.fields.items() %} + {% for field_name, field in body.fields().items() %} {% if field.required %} form.AddContent("{{ field_name }}", USERVER_NAMESPACE::chaotic::openapi::PrimitiveToString(data.{{ field_name }})); {% else %} diff --git a/chaotic-openapi/chaotic_openapi/back/cpp/client/templates/requests.application.octet.stream.jinja b/chaotic-openapi/chaotic_openapi/back/cpp/client/templates/requests.application.octet.stream.jinja index fb386c6a766e..91b78404d7eb 100644 --- a/chaotic-openapi/chaotic_openapi/back/cpp/client/templates/requests.application.octet.stream.jinja +++ b/chaotic-openapi/chaotic_openapi/back/cpp/client/templates/requests.application.octet.stream.jinja @@ -1,5 +1,8 @@ -{% macro serialize(request, http_request) %} - {{ http_request }}.data({{ request }}.data); +{# An operation with a single content type stores the body as a bare std::string, + while multiple content types are wrapped into distinct structs (see define_body_cpp_name) + to make the std::variant alternatives unambiguous. #} +{% macro serialize(request, http_request, wrapped) %} + {{ http_request }}.data({{ request }}{% if wrapped %}.data{% endif %}); {% endmacro %} diff --git a/chaotic-openapi/chaotic_openapi/back/cpp/client/templates/requests.application.xwwwformurlencoded.jinja b/chaotic-openapi/chaotic_openapi/back/cpp/client/templates/requests.application.xwwwformurlencoded.jinja index 4847b535543b..b066c24273e9 100644 --- a/chaotic-openapi/chaotic_openapi/back/cpp/client/templates/requests.application.xwwwformurlencoded.jinja +++ b/chaotic-openapi/chaotic_openapi/back/cpp/client/templates/requests.application.xwwwformurlencoded.jinja @@ -2,7 +2,7 @@ const auto &data = {{ request }}; std::unordered_map map; - {% for field_name, field in body.schema.fields.items() %} + {% for field_name, field in body.fields().items() %} {% if field.required %} map["{{ field_name }}"] = USERVER_NAMESPACE::chaotic::openapi::PrimitiveToString(data.{{ field_name }}); {% else %} diff --git a/chaotic-openapi/chaotic_openapi/back/cpp/client/templates/requests.cpp.jinja b/chaotic-openapi/chaotic_openapi/back/cpp/client/templates/requests.cpp.jinja index b997ac691998..2fb7cc2c22f3 100644 --- a/chaotic-openapi/chaotic_openapi/back/cpp/client/templates/requests.cpp.jinja +++ b/chaotic-openapi/chaotic_openapi/back/cpp/client/templates/requests.cpp.jinja @@ -17,6 +17,21 @@ namespace {{ namespace }} { namespace openapi = USERVER_NAMESPACE::chaotic::openapi; +{% macro serialize_request_single_body(body, body_obj, http_request, wrapped) %} + {% if body.content_type != 'multipart/form-data' %} + sink.SetHeader(USERVER_NAMESPACE::http::headers::kContentType, "{{ body.content_type }}"); + {% endif %} + {% if body.content_type == 'application/json' %} + {{ application_json.serialize(body_obj, http_request) }} + {% elif body.content_type == 'multipart/form-data' %} + {{ application_multipartformdata.serialize(body, body_obj, http_request) }} + {% elif body.content_type == 'application/x-www-form-urlencoded' %} + {{ application_xwwwformurlencoded.serialize(body, body_obj, http_request) }} + {% else %} + {{ application_octetstream.serialize(body_obj, http_request, wrapped=wrapped) }} + {% endif %} +{% endmacro %} + {% for op in operations %} {% if op.client_generate %} namespace {{ op.cpp_namespace() }} { @@ -66,26 +81,13 @@ void SerializeRequest(const Request& request, const std::string& base_url, USERV {# body #} {% if len(op.request_bodies) == 1 %} - http_request.data(ToString(USERVER_NAMESPACE::formats::json::ValueBuilder(request.body).ExtractValue())); + {{ serialize_request_single_body(op.request_bodies[0], "request.body", "http_request", wrapped=False) }} {% elif len(op.request_bodies) > 1 %} switch (request.body.index()) { {%- for num, body in enumerate(op.request_bodies) -%} case {{ num }}: { - {% if body.content_type != 'multipart/form-data' %} - http_request.headers(USERVER_NAMESPACE::clients::http::Headers{ {USERVER_NAMESPACE::http::headers::kContentType, "{{ body.content_type }}"} }); - {% endif %} - {% set body_obj = "std::get<" + str(num) + ">(request.body)" %} - {% if body.content_type == 'application/json' %} - {{ application_json.serialize(body_obj, "http_request") }} - {% elif body.content_type == 'multipart/form-data' %} - {{ application_multipartformdata.serialize(body, body_obj, "http_request") }} - {% elif body.content_type == 'application/x-www-form-urlencoded' %} - {{ application_xwwwformurlencoded.serialize(body, body_obj, "http_request") }} - {% else %} - {{ application_octetstream.serialize(body_obj, "http_request") }} - {% endif %} - + {{ serialize_request_single_body(body, body_obj, "http_request", wrapped=True) }} break; } {% endfor %} diff --git a/chaotic-openapi/chaotic_openapi/back/cpp/common/translator.py b/chaotic-openapi/chaotic_openapi/back/cpp/common/translator.py index b82c9ef4e8df..67779e3636da 100644 --- a/chaotic-openapi/chaotic_openapi/back/cpp/common/translator.py +++ b/chaotic-openapi/chaotic_openapi/back/cpp/common/translator.py @@ -389,6 +389,7 @@ def _validate_primitive_object(self, schema: cpp_types.CppType) -> None: assert schema.json_schema source_location = schema.json_schema.source_location() + schema = common_types.resolve_ref(schema) if not isinstance(schema, cpp_types.CppStruct): raise chaotic_error.BaseError( full_filepath=source_location.filepath, @@ -397,7 +398,7 @@ def _validate_primitive_object(self, schema: cpp_types.CppType) -> None: msg='"application/x-www-form-urlencoded" body allows only "type: object"', ) for field in schema.fields.values(): - if not isinstance(field.schema, cpp_types.CppPrimitiveType): + if not isinstance(common_types.resolve_ref(field.schema), cpp_types.CppPrimitiveType): raise chaotic_error.BaseError( full_filepath=source_location.filepath, infile_path=source_location.location, diff --git a/chaotic-openapi/chaotic_openapi/back/cpp/common/types.py b/chaotic-openapi/chaotic_openapi/back/cpp/common/types.py index 04348bd01c16..a87baaa0fb8c 100644 --- a/chaotic-openapi/chaotic_openapi/back/cpp/common/types.py +++ b/chaotic-openapi/chaotic_openapi/back/cpp/common/types.py @@ -8,6 +8,13 @@ from chaotic_openapi.back.cpp.client import middleware +def resolve_ref(schema: cpp_types.CppType) -> cpp_types.CppType: + """Follow the `$ref` chain down to the referenced type.""" + while isinstance(schema, cpp_types.CppRef): + schema = schema.orig_cpp_type + return schema + + @dataclasses.dataclass class Security: auth_type: str @@ -134,6 +141,13 @@ def cpp_type(self) -> str: assert self.schema is not None return self.schema.cpp_user_name() + def fields(self) -> dict[str, cpp_types.CppStructField]: + """Fields of a form body, with `$ref` resolved.""" + assert self.schema is not None + schema = resolve_ref(self.schema) + assert isinstance(schema, cpp_types.CppStruct), schema + return schema.fields + @dataclasses.dataclass class Response: diff --git a/chaotic-openapi/chaotic_openapi/back/cpp/handler/templates/requests.cpp.jinja b/chaotic-openapi/chaotic_openapi/back/cpp/handler/templates/requests.cpp.jinja index d49b9c09d72d..2aecda545059 100644 --- a/chaotic-openapi/chaotic_openapi/back/cpp/handler/templates/requests.cpp.jinja +++ b/chaotic-openapi/chaotic_openapi/back/cpp/handler/templates/requests.cpp.jinja @@ -24,7 +24,7 @@ namespace {{ spec.cpp_namespace }}::{{ op.cpp_namespace() }} { {% endmacro %} {% macro _parse_urlencoded(body, prefix) %} - {% for field_name, field in body.schema.fields.items() %} + {% for field_name, field in body.fields().items() %} {% if field.required %} if (!http_request.HasArg("{{ field_name }}")) { throw {{ userver }}::server::handlers::ClientError( @@ -49,7 +49,7 @@ namespace {{ spec.cpp_namespace }}::{{ op.cpp_namespace() }} { {% endmacro %} {% macro _parse_multipart(body, prefix) %} - {% for field_name, field in body.schema.fields.items() %} + {% for field_name, field in body.fields().items() %} {% if field.required %} if (!http_request.HasFormDataArg("{{ field_name }}")) { throw {{ userver }}::server::handlers::ClientError( diff --git a/chaotic-openapi/chaotic_openapi/front/parser.py b/chaotic-openapi/chaotic_openapi/front/parser.py index 894d7d88a7e9..645170affbb5 100644 --- a/chaotic-openapi/chaotic_openapi/front/parser.py +++ b/chaotic-openapi/chaotic_openapi/front/parser.py @@ -182,15 +182,20 @@ def _convert_swagger_request_body( msg='"consumes" must be either "multipart/form-data" or "application/x-www-form-urlencoded" for "type: file"', ) - schema = self._parse_schema( - request_body.model_dump( - by_alias=True, - exclude={'name', 'in_', 'description', 'required', 'allowEmptyValue', 'collectionFormat'}, - exclude_unset=True, - ), - infile_path, - allow_file=True, + field_schema = request_body.model_dump( + by_alias=True, + exclude={'name', 'in_', 'description', 'required', 'allowEmptyValue', 'collectionFormat'}, + exclude_unset=True, ) + object_schema: dict[str, Any] = { + 'type': 'object', + 'properties': {request_body.name: field_schema}, + 'additionalProperties': False, + } + if request_body.required: + object_schema['required'] = [request_body.name] + + schema = self._parse_schema(object_schema, infile_path, allow_file=True) return [ model.RequestBody( content_type=mime, diff --git a/chaotic-openapi/golden_tests/output/client/src/clients/test/requests.cpp b/chaotic-openapi/golden_tests/output/client/src/clients/test/requests.cpp index 7680e5a2079e..549ae392f066 100644 --- a/chaotic-openapi/golden_tests/output/client/src/clients/test/requests.cpp +++ b/chaotic-openapi/golden_tests/output/client/src/clients/test/requests.cpp @@ -27,7 +27,9 @@ base_url + "/testme" openapi::WriteParameter>(request.number, sink); openapi::WriteParameter>(request.array, sink); -http_request.data(ToString(USERVER_NAMESPACE::formats::json::ValueBuilder(request.body).ExtractValue())); +sink.SetHeader(USERVER_NAMESPACE::http::headers::kContentType, "application/json"); + + http_request.data(ToString(USERVER_NAMESPACE::formats::json::ValueBuilder(request.body).ExtractValue())); sink.Flush(); diff --git a/chaotic-openapi/integration_tests/clients/multiple-content-types/openapi.yaml b/chaotic-openapi/integration_tests/clients/multiple-content-types/openapi.yaml index eb4fcb416007..df34d9a57bcf 100644 --- a/chaotic-openapi/integration_tests/clients/multiple-content-types/openapi.yaml +++ b/chaotic-openapi/integration_tests/clients/multiple-content-types/openapi.yaml @@ -10,54 +10,125 @@ paths: content: application/json: schema: - type: object - properties: - foo: - type: string - additionalProperties: false + $ref: '#/components/schemas/single-json' multipart/form-data: schema: - type: object - required: - - filename - properties: - filename: - type: string - content: - type: string - additionalProperties: false + $ref: '#/components/schemas/single-form-data' application/x-www-form-urlencoded: schema: - type: object - required: - - name - - age - properties: - name: - type: string - password: - type: string - age: - type: integer - salary: - type: number - is_smoking: - type: boolean - additionalProperties: false + $ref: '#/components/schemas/single-form-urlen' application/octet-stream: schema: - type: string + $ref: '#/components/schemas/single-octet' responses: '200': description: OK content: application/json: schema: - type: object - properties: - bar: - type: string - additionalProperties: false + $ref: '#/components/schemas/common-response' application/octet-stream: schema: type: string + /test-single-json: + post: + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/single-json' + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/common-response' + + /test-single-form-data: + post: + requestBody: + content: + multipart/form-data: + schema: + $ref: '#/components/schemas/single-form-data' + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/common-response' + + /test-single-form-urlen: + post: + requestBody: + content: + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/single-form-urlen' + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/common-response' + + /test-single-octet: + post: + requestBody: + content: + application/octet-stream: + schema: + $ref: '#/components/schemas/single-octet' + responses: + '200': + description: OK + content: + application/octet-stream: + schema: + type: string +components: + schemas: + single-json: + type: object + properties: + foo: + type: string + additionalProperties: false + single-form-data: + type: object + required: + - filename + properties: + filename: + type: string + content: + type: string + additionalProperties: false + single-form-urlen: + type: object + required: + - name + - age + properties: + name: + type: string + password: + type: string + age: + type: integer + salary: + type: number + is_smoking: + type: boolean + additionalProperties: false + single-octet: + type: string + common-response: + type: object + properties: + bar: + type: string + additionalProperties: false diff --git a/chaotic-openapi/integration_tests/clients/swagger-form-data/swagger.yaml b/chaotic-openapi/integration_tests/clients/swagger-form-data/swagger.yaml new file mode 100644 index 000000000000..43c66624e3e2 --- /dev/null +++ b/chaotic-openapi/integration_tests/clients/swagger-form-data/swagger.yaml @@ -0,0 +1,22 @@ +swagger: '2.0' +description: | + Swagger "in: formData" request body. + + Such a parameter describes a single form field, not the whole body, so it is + wrapped into a one-property object schema (see + Parser._convert_swagger_request_body). Only one "in: formData" parameter per + operation is supported for now, the rest are silently dropped by the parser. + +paths: + /form-urlencoded: + post: + consumes: + - application/x-www-form-urlencoded + parameters: + - in: formData + name: name + required: true + type: string + responses: + '200': + description: OK diff --git a/chaotic-openapi/integration_tests/src/requests_test.cpp b/chaotic-openapi/integration_tests/src/requests_test.cpp index c82030e7303f..b3e2ae050e27 100644 --- a/chaotic-openapi/integration_tests/src/requests_test.cpp +++ b/chaotic-openapi/integration_tests/src/requests_test.cpp @@ -28,9 +28,38 @@ USERVER_NAMESPACE_BEGIN namespace { +using namespace ::clients::multiple_content_types; + namespace client = ::clients::multiple_content_types::test1::post; namespace test_object = ::clients::test_object; +class RequestsMultipleContentTypes : public ::testing::Test { +protected: + template + void SetupCallback(Callback&& callback){ + mock_server_ = std::make_unique( + [hook = std::move(callback)](const utest::HttpServerMock::HttpRequest& request) { + hook(request); + utest::HttpServerMock::HttpResponse response{}; + response.response_status = 200; + return response; + }); + } + + template + void PerformRequest(Request&& request_obj) { + EXPECT_NE(mock_server_.get(), nullptr); + auto http_client_ptr = utest::CreateHttpClient(); + auto request = http_client_ptr->CreateRequest(); + SerializeRequest(std::move(request_obj), mock_server_->GetBaseUrl(), request); + auto response = request.perform(); + EXPECT_EQ(response->status_code(), 200); + } + +private: + std::unique_ptr mock_server_; +}; + template std::string SerializeViaDom(const T& value) { return formats::json::ToString(formats::json::ValueBuilder(value).ExtractValue()); @@ -86,24 +115,6 @@ UTEST(Requests, RegexDestinationName) { ); } -UTEST(RequestsMultipleContentTypes, Json) { - const utest::HttpServerMock http_server([&](const utest::HttpServerMock::HttpRequest& request) { - EXPECT_EQ(request.body, R"({"foo":"a"})"); - EXPECT_EQ(request.headers.at(std::string{"Content-Type"}), "application/json"); - utest::HttpServerMock::HttpResponse response{}; - response.response_status = 200; - return response; - }); - - auto http_client_ptr = utest::CreateHttpClient(); - auto request = http_client_ptr->CreateRequest(); - - client::SerializeRequest({client::RequestBodyApplicationJson{"a"}}, http_server.GetBaseUrl(), request); - - auto response = request.perform(); - EXPECT_EQ(response->status_code(), 200); -} - UTEST(RequestSerializationDom, GeneratedObjectExactBody) { const auto value = MakeSerializationObject(); @@ -212,94 +223,73 @@ UTEST(RequestSerializationDom, SerializationDoesNotRunValidators) { ); } -UTEST(RequestsMultipleContentTypes, XWwwFormUrlencoded) { - const utest::HttpServerMock http_server([&](const utest::HttpServerMock::HttpRequest& request) { - // x-www-form-urlencoded field order is unspecified (serialized from a - // std::unordered_map), so compare the '&'-separated parts order-independently. - const auto parts = utils::text::Split(request.body, "&"); - EXPECT_THAT( - parts, - ::testing::UnorderedElementsAre( - "name=abc", - "password=123%20456", - "age=30", - "salary=1000.500000", - "is_smoking=true" - ) - ); - EXPECT_EQ(request.headers.at(std::string{"Content-Type"}), "application/x-www-form-urlencoded"); - utest::HttpServerMock::HttpResponse response{}; - response.response_status = 200; - return response; - }); - - auto http_client_ptr = utest::CreateHttpClient(); - auto request = http_client_ptr->CreateRequest(); - - client::SerializeRequest( - {client::RequestBodyApplicationXWwwFormUrlencoded{"abc", "123 456", 30, 1000.5, true}}, - http_server.GetBaseUrl(), - request - ); - - auto response = request.perform(); - EXPECT_EQ(response->status_code(), 200); +UTEST_F(RequestsMultipleContentTypes, Json) { + SetupCallback([](const utest::HttpServerMock::HttpRequest& request) { + EXPECT_EQ(request.body, R"({"foo":"a"})"); + EXPECT_EQ(request.headers.at(std::string{"Content-Type"}), "application/json"); + }); + const auto& json_obj = single_json{"a"}; + PerformRequest(test1::post::Request{json_obj}); + PerformRequest(test_single_json::post::Request{json_obj}); } -UTEST(RequestsMultipleContentTypes, MultipartFormData) { - const utest::HttpServerMock http_server([&](const utest::HttpServerMock::HttpRequest& request) { - const auto& raw_content_type = request.headers.at(std::string{"Content-Type"}); - const http::ContentType content_type(raw_content_type); - EXPECT_EQ(content_type.MediaType(), "multipart/form-data"); - const auto& boundary = content_type.Boundary(); - EXPECT_THAT(raw_content_type, ::testing::HasSubstr("boundary=")); - EXPECT_FALSE(boundary.empty()); - EXPECT_EQ( - request.body, - "--" + boundary + - "\r\n" - "Content-Disposition: form-data; name=\"filename\"\r\n" - "\r\nfilename\r\n" + - "--" + boundary + - "\r\n" - "Content-Disposition: form-data; name=\"content\"\r\n" - "\r\nfile\ncontent\r\n" + - "--" + boundary + "--\r\n" - ); - utest::HttpServerMock::HttpResponse response{}; - response.response_status = 200; - return response; - }); - - auto http_client_ptr = utest::CreateHttpClient(); - auto request = http_client_ptr->CreateRequest(); +UTEST_F(RequestsMultipleContentTypes, XWwwFormUrlencoded) { + SetupCallback([](const utest::HttpServerMock::HttpRequest& request) { + // x-www-form-urlencoded field order is unspecified (serialized from a + // std::unordered_map), so compare the '&'-separated parts order-independently. + const auto parts = utils::text::Split(request.body, "&"); + EXPECT_THAT( + parts, + ::testing::UnorderedElementsAre( + "name=abc", + "password=123%20456", + "age=30", + "salary=1000.500000", + "is_smoking=true" + ) + ); + EXPECT_EQ(request.headers.at(std::string{"Content-Type"}), "application/x-www-form-urlencoded"); + }); + const auto& form_urlen_obj = single_form_urlen{"abc", "123 456", 30, 1000.5, true}; + PerformRequest(test1::post::Request{form_urlen_obj}); + PerformRequest(test_single_form_urlen::post::Request{form_urlen_obj}); +} - client::SerializeRequest( - {client::RequestBodyMultipartFormData{"filename", "file\ncontent"}}, - http_server.GetBaseUrl(), - request - ); - auto response = request.perform(); - EXPECT_EQ(response->status_code(), 200); +UTEST_F(RequestsMultipleContentTypes, MultipartFormData) { + SetupCallback([](const utest::HttpServerMock::HttpRequest& request) { + const auto& raw_content_type = request.headers.at(std::string{"Content-Type"}); + const http::ContentType content_type(raw_content_type); + EXPECT_EQ(content_type.MediaType(), "multipart/form-data"); + const auto& boundary = content_type.Boundary(); + EXPECT_THAT(raw_content_type, ::testing::HasSubstr("boundary=")); + EXPECT_FALSE(boundary.empty()); + EXPECT_EQ( + request.body, + "--" + boundary + + "\r\n" + "Content-Disposition: form-data; name=\"filename\"\r\n" + "\r\nfilename\r\n" + + "--" + boundary + + "\r\n" + "Content-Disposition: form-data; name=\"content\"\r\n" + "\r\nfile\ncontent\r\n" + + "--" + boundary + "--\r\n" + ); + }); + const auto& form_data_obj = single_form_data{"filename", "file\ncontent"}; + PerformRequest(test1::post::Request{form_data_obj}); + PerformRequest(test_single_form_data::post::Request{form_data_obj}); } -UTEST(RequestsMultipleContentTypes, OctetStream) { - const utest::HttpServerMock http_server([&](const utest::HttpServerMock::HttpRequest& request) { - EXPECT_EQ(request.body, "blabla"); - EXPECT_EQ(request.headers.at(std::string{"Content-Type"}), "application/octet-stream"); - utest::HttpServerMock::HttpResponse response{}; - response.response_status = 200; - return response; +UTEST_F(RequestsMultipleContentTypes, OctetStream) { + SetupCallback([](const utest::HttpServerMock::HttpRequest& request) { + EXPECT_EQ(request.body, "blabla"); + EXPECT_EQ(request.headers.at(std::string{"Content-Type"}), "application/octet-stream"); }); - - auto http_client_ptr = utest::CreateHttpClient(); - auto request = http_client_ptr->CreateRequest(); - - client::SerializeRequest({client::RequestBodyApplicationOctetStream{"blabla"}}, http_server.GetBaseUrl(), request); - - auto response = request.perform(); - EXPECT_EQ(response->status_code(), 200); + const auto& single_octet_obj = single_octet("blabla"); + PerformRequest(test1::post::Request{test1::post::RequestBodyApplicationOctetStream{single_octet_obj}}); + PerformRequest(test_single_octet::post::Request{single_octet_obj}); } class RequestsQueryLogMode : public utest::LogCaptureFixture<> {}; diff --git a/chaotic-openapi/integration_tests/src/swagger_form_data_test.cpp b/chaotic-openapi/integration_tests/src/swagger_form_data_test.cpp new file mode 100644 index 000000000000..410510d708f6 --- /dev/null +++ b/chaotic-openapi/integration_tests/src/swagger_form_data_test.cpp @@ -0,0 +1,34 @@ +#include + +#include +#include +#include + +#include + +USERVER_NAMESPACE_BEGIN + +namespace { + +namespace client = ::clients::swagger_form_data::form_urlencoded::post; + +// A swagger "in: formData" parameter describes a single form field rather than +// the whole request body, so it must be serialized as a field of the form. +UTEST(SwaggerFormData, Urlencoded) { + std::string body; + const utest::HttpServerMock http_server([&body](const utest::HttpServerMock::HttpRequest& request) { + body = request.body; + return utest::HttpServerMock::HttpResponse{}; + }); + + auto http_client_ptr = utest::CreateHttpClient(); + auto request = http_client_ptr->CreateRequest(); + client::SerializeRequest(client::Request{{"abc"}}, http_server.GetBaseUrl(), request); + EXPECT_EQ(request.perform()->status_code(), 200); + + EXPECT_EQ(body, "name=abc"); +} + +} // namespace + +USERVER_NAMESPACE_END