From 3cf6a5b36fc8619bcd6c463d7838fe4a3a4b063a Mon Sep 17 00:00:00 2001 From: Aleksander Lysenko Date: Fri, 4 Sep 2026 00:16:58 +0300 Subject: [PATCH 1/5] Absent content type serialization was fixed for single body requests --- .../requests.application.octet.stream.jinja | 7 +- .../cpp/client/templates/requests.cpp.jinja | 15 +- .../client/src/clients/test/requests.cpp | 4 +- .../multiple-content-types/openapi.yaml | 99 +++++++++ .../integration_tests/src/requests_test.cpp | 206 ++++++++++-------- 5 files changed, 232 insertions(+), 99 deletions(-) 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..aa94f4a3ed44 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=True) %} + {{ http_request }}.data({{ request }}{% if wrapped %}.data{% endif %}); {% endmacro %} 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..7a2c73207a91 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 @@ -66,7 +66,20 @@ 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())); + {% set body = op.request_bodies[0] %} + {% 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("request.body", "http_request") }} + {% elif body.content_type == 'multipart/form-data' %} + {{ application_multipartformdata.serialize(body, "request.body", "http_request") }} + {% elif body.content_type == 'application/x-www-form-urlencoded' %} + {{ application_xwwwformurlencoded.serialize(body, "request.body", "http_request") }} + {% else %} + {{ application_octetstream.serialize("request.body", "http_request", wrapped=False) }} + {% endif %} {% elif len(op.request_bodies) > 1 %} switch (request.body.index()) { {%- for num, body in enumerate(op.request_bodies) -%} 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..3d39c4b2e88c 100644 --- a/chaotic-openapi/integration_tests/clients/multiple-content-types/openapi.yaml +++ b/chaotic-openapi/integration_tests/clients/multiple-content-types/openapi.yaml @@ -61,3 +61,102 @@ paths: application/octet-stream: schema: type: string +# TODO: declare common components per content type in order to reuse in /test1 + /test-single-json: + post: + requestBody: + content: + application/json: + schema: + type: object + properties: + foo: + type: string + additionalProperties: false + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + properties: + bar: + type: string + additionalProperties: false + + /test-single-form-data: + post: + requestBody: + content: + multipart/form-data: + schema: + type: object + required: + - filename + properties: + filename: + type: string + content: + type: string + additionalProperties: false + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + properties: + bar: + type: string + additionalProperties: false + + /test-single-form-urlen: + post: + requestBody: + content: + 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 + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + properties: + bar: + type: string + additionalProperties: false + + /test-single-octet: + post: + requestBody: + content: + application/octet-stream: + schema: + type: string + responses: + '200': + description: OK + content: + application/octet-stream: + schema: + type: string diff --git a/chaotic-openapi/integration_tests/src/requests_test.cpp b/chaotic-openapi/integration_tests/src/requests_test.cpp index 2221786fb258..97d87a91bddb 100644 --- a/chaotic-openapi/integration_tests/src/requests_test.cpp +++ b/chaotic-openapi/integration_tests/src/requests_test.cpp @@ -61,111 +61,127 @@ 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); + auto test = []() { + 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(); + + SerializeRequest(Request{Body{"a"}}, http_server.GetBaseUrl(), request); + + auto response = request.perform(); + EXPECT_EQ(response->status_code(), 200); + }; + + namespace single = ::clients::multiple_content_types::test_single_json::post; + test.operator()(); + test.operator()(); } 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); + auto test = []() { + 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(); + + SerializeRequest(Request{Body{"abc", "123 456", 30, 1000.5, true}}, http_server.GetBaseUrl(), request); + + auto response = request.perform(); + EXPECT_EQ(response->status_code(), 200); + }; + + namespace single = ::clients::multiple_content_types::test_single_form_urlen::post; + test.operator()(); + test.operator()(); } 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" + + auto test = []() { + 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=\"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(); - - client::SerializeRequest( - {client::RequestBodyMultipartFormData{"filename", "file\ncontent"}}, - http_server.GetBaseUrl(), - request - ); - - auto response = request.perform(); - EXPECT_EQ(response->status_code(), 200); + "\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(); + + SerializeRequest(Request{Body{"filename", "file\ncontent"}}, http_server.GetBaseUrl(), request); + + auto response = request.perform(); + EXPECT_EQ(response->status_code(), 200); + }; + + namespace single = ::clients::multiple_content_types::test_single_form_data::post; + test.operator()(); + test.operator()(); } 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; - }); - - 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); + auto test = []() { + 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; + }); + + auto http_client_ptr = utest::CreateHttpClient(); + auto request = http_client_ptr->CreateRequest(); + + SerializeRequest(Request{Body{"blabla"}}, http_server.GetBaseUrl(), request); + + auto response = request.perform(); + EXPECT_EQ(response->status_code(), 200); + }; + + namespace single = ::clients::multiple_content_types::test_single_octet::post; + test.operator()(); + test.operator()(); } class RequestsQueryLogMode : public utest::LogCaptureFixture<> {}; From e101622c09e8cacc010255ac944e740258e8854d Mon Sep 17 00:00:00 2001 From: Aleksander Lysenko Date: Fri, 4 Sep 2026 11:49:08 +0300 Subject: [PATCH 2/5] Compilation of chaotic-openapi/integration_tests was fixed --- .../requests.application.octet.stream.jinja | 2 +- .../cpp/client/templates/requests.cpp.jinja | 45 +++++++------------ .../chaotic_openapi/front/parser.py | 21 +++++---- 3 files changed, 31 insertions(+), 37 deletions(-) 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 aa94f4a3ed44..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,7 +1,7 @@ {# 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=True) %} +{% 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.cpp.jinja b/chaotic-openapi/chaotic_openapi/back/cpp/client/templates/requests.cpp.jinja index 7a2c73207a91..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,39 +81,13 @@ void SerializeRequest(const Request& request, const std::string& base_url, USERV {# body #} {% if len(op.request_bodies) == 1 %} - {% set body = op.request_bodies[0] %} - {% 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("request.body", "http_request") }} - {% elif body.content_type == 'multipart/form-data' %} - {{ application_multipartformdata.serialize(body, "request.body", "http_request") }} - {% elif body.content_type == 'application/x-www-form-urlencoded' %} - {{ application_xwwwformurlencoded.serialize(body, "request.body", "http_request") }} - {% else %} - {{ application_octetstream.serialize("request.body", "http_request", wrapped=False) }} - {% endif %} + {{ 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/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, From 2954cfeacfa80ebf8be67350696bb2e8d2a1c3e1 Mon Sep 17 00:00:00 2001 From: Aleksander Lysenko Date: Fri, 4 Sep 2026 18:02:42 +0300 Subject: [PATCH 3/5] multiple-content-types/openapi.yaml was refactord + fixed references resolving at x-www-form-urlencoded fields --- ...uests.application.multipart.formdata.jinja | 2 +- ...uests.application.xwwwformurlencoded.jinja | 2 +- .../back/cpp/common/translator.py | 3 +- .../chaotic_openapi/back/cpp/common/types.py | 14 ++ .../cpp/handler/templates/requests.cpp.jinja | 4 +- .../multiple-content-types/openapi.yaml | 138 +++++++----------- 6 files changed, 75 insertions(+), 88 deletions(-) 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.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/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/integration_tests/clients/multiple-content-types/openapi.yaml b/chaotic-openapi/integration_tests/clients/multiple-content-types/openapi.yaml index 3d39c4b2e88c..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,80 +10,40 @@ 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 -# TODO: declare common components per content type in order to reuse in /test1 /test-single-json: post: requestBody: content: application/json: schema: - type: object - properties: - foo: - type: string - additionalProperties: false + $ref: '#/components/schemas/single-json' responses: '200': description: OK content: application/json: schema: - type: object - properties: - bar: - type: string - additionalProperties: false + $ref: '#/components/schemas/common-response' /test-single-form-data: post: @@ -91,26 +51,14 @@ paths: content: multipart/form-data: schema: - type: object - required: - - filename - properties: - filename: - type: string - content: - type: string - additionalProperties: false + $ref: '#/components/schemas/single-form-data' responses: '200': description: OK content: application/json: schema: - type: object - properties: - bar: - type: string - additionalProperties: false + $ref: '#/components/schemas/common-response' /test-single-form-urlen: post: @@ -118,33 +66,14 @@ paths: content: 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' responses: '200': description: OK content: application/json: schema: - type: object - properties: - bar: - type: string - additionalProperties: false + $ref: '#/components/schemas/common-response' /test-single-octet: post: @@ -152,7 +81,7 @@ paths: content: application/octet-stream: schema: - type: string + $ref: '#/components/schemas/single-octet' responses: '200': description: OK @@ -160,3 +89,46 @@ paths: 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 From c15e9100e3c50ae49571b09e06a3f2c2450f1527 Mon Sep 17 00:00:00 2001 From: Aleksander Lysenko Date: Fri, 4 Sep 2026 21:58:26 +0300 Subject: [PATCH 4/5] RequestsMultipleContentTypes tests were encapsulated into fixture in order to reuse code and assertions --- .../integration_tests/src/requests_test.cpp | 128 +++++++----------- 1 file changed, 50 insertions(+), 78 deletions(-) diff --git a/chaotic-openapi/integration_tests/src/requests_test.cpp b/chaotic-openapi/integration_tests/src/requests_test.cpp index 97d87a91bddb..8289aba995da 100644 --- a/chaotic-openapi/integration_tests/src/requests_test.cpp +++ b/chaotic-openapi/integration_tests/src/requests_test.cpp @@ -22,7 +22,34 @@ USERVER_NAMESPACE_BEGIN namespace { -namespace client = ::clients::multiple_content_types::test1::post; +using namespace ::clients::multiple_content_types; + +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_; +}; UTEST(Requests, RegexDestinationName) { const utest::HttpServerMock http_server([&](const utest::HttpServerMock::HttpRequest&) { @@ -60,33 +87,18 @@ UTEST(Requests, RegexDestinationName) { ); } -UTEST(RequestsMultipleContentTypes, Json) { - auto test = []() { - const utest::HttpServerMock http_server([&](const utest::HttpServerMock::HttpRequest& request) { +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"); - utest::HttpServerMock::HttpResponse response{}; - response.response_status = 200; - return response; }); - - auto http_client_ptr = utest::CreateHttpClient(); - auto request = http_client_ptr->CreateRequest(); - - SerializeRequest(Request{Body{"a"}}, http_server.GetBaseUrl(), request); - - auto response = request.perform(); - EXPECT_EQ(response->status_code(), 200); - }; - - namespace single = ::clients::multiple_content_types::test_single_json::post; - test.operator()(); - test.operator()(); + const auto& json_obj = single_json{"a"}; + PerformRequest(test1::post::Request{json_obj}); + PerformRequest(test_single_json::post::Request{json_obj}); } -UTEST(RequestsMultipleContentTypes, XWwwFormUrlencoded) { - auto test = []() { - const utest::HttpServerMock http_server([&](const utest::HttpServerMock::HttpRequest& request) { +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, "&"); @@ -101,28 +113,15 @@ UTEST(RequestsMultipleContentTypes, XWwwFormUrlencoded) { ) ); 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(); - - SerializeRequest(Request{Body{"abc", "123 456", 30, 1000.5, true}}, http_server.GetBaseUrl(), request); - - auto response = request.perform(); - EXPECT_EQ(response->status_code(), 200); - }; - - namespace single = ::clients::multiple_content_types::test_single_form_urlen::post; - test.operator()(); - test.operator()(); + 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}); } -UTEST(RequestsMultipleContentTypes, MultipartFormData) { - auto test = []() { - const utest::HttpServerMock http_server([&](const utest::HttpServerMock::HttpRequest& request) { + +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"); @@ -141,47 +140,20 @@ UTEST(RequestsMultipleContentTypes, MultipartFormData) { "\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(); - - SerializeRequest(Request{Body{"filename", "file\ncontent"}}, http_server.GetBaseUrl(), request); - - auto response = request.perform(); - EXPECT_EQ(response->status_code(), 200); - }; - - namespace single = ::clients::multiple_content_types::test_single_form_data::post; - test.operator()(); - test.operator()(); + 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) { - auto test = []() { - const utest::HttpServerMock http_server([&](const utest::HttpServerMock::HttpRequest& request) { +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"); - utest::HttpServerMock::HttpResponse response{}; - response.response_status = 200; - return response; - }); - - auto http_client_ptr = utest::CreateHttpClient(); - auto request = http_client_ptr->CreateRequest(); - - SerializeRequest(Request{Body{"blabla"}}, http_server.GetBaseUrl(), request); - - auto response = request.perform(); - EXPECT_EQ(response->status_code(), 200); - }; - - namespace single = ::clients::multiple_content_types::test_single_octet::post; - test.operator()(); - test.operator()(); + }); + 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<> {}; From 63491c6eff6cefdb209b6f6a48546f35e9d489ac Mon Sep 17 00:00:00 2001 From: Aleksander Lysenko Date: Sat, 5 Sep 2026 14:20:39 +0300 Subject: [PATCH 5/5] Separate swagger schema was added to demonstrate failed serialization for form's fields --- .../clients/swagger-form-data/swagger.yaml | 22 ++++++++++++ .../src/swagger_form_data_test.cpp | 34 +++++++++++++++++++ 2 files changed, 56 insertions(+) create mode 100644 chaotic-openapi/integration_tests/clients/swagger-form-data/swagger.yaml create mode 100644 chaotic-openapi/integration_tests/src/swagger_form_data_test.cpp 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/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