feat(cpp-boost-beast-client): variant-first oneOf/anyOf/allOf composition#24387
feat(cpp-boost-beast-client): variant-first oneOf/anyOf/allOf composition#24387bold84 wants to merge 74 commits into
Conversation
Phase 2 of oneOf/anyOf support. Key changes: Java codegen: - Add x-cpp-is-alias and x-cpp-is-variant vendor extensions on composed models, so templates can dispatch between alias (using typedef) and object class generation paths. - Add fallback detection for models whose composedSchemas were consumed by the default fromModel pipeline (e.g., single-branch anyOf collapsed to std::string). - Propagate x-stainless-const values from schema const field to property vendor extensions for inline getter generation. Template (model-header.mustache): - Alias models: emit instead of class. - Variant models: additionally declare / overloads. - Object models: unchanged (existing class template). - x-stainless-const properties: inline getter returning const value. Template (model-source.mustache): - Variant models: implement via std::visit + boost::json::value_from, and with discriminator dispatch or TODO stub. - Pure alias models: no source output (empty file). - Object models: unchanged (existing implementation). - x-stainless-const properties: skip getter/setter definitions (defined inline in header); always emit const value in toJsonObject_internal. Tests: - Verify alias models emit typedef (no class declaration). - Verify variant models emit + / declarations. - Verify InputParam source implements to_json via std::visit. - Verify PetByType source references discriminator property.
Three fixes for Phase 2:
1. x-stainless-const string quoting
- Extract const values from schema enum (since the normalizer converts
const to enum before model processing).
- Store x-stainless-const-inline-value with proper C++ quoting
(e.g., "text" for std::string, 42 for int32_t).
- Both templates now use x-stainless-const-inline-value.
2. x-stainless-const test
- Add StainlessObject model to composed-schema-lowering.yaml with
both string and integer x-stainless-const properties.
- Verify inline getter emits properly quoted C++ literals.
3. Empty .cpp for non-variant aliases
- Add minimal include + namespace block in model-source.mustache
for alias models that are not variants (e.g., ModelIdsResponses,
SingleBranchTest).
… property conflicts, test fixes
- Fix cycle detection: strip std::shared_ptr<X> wrappers from property dataTypes BEFORE super.updateAllModels runs (which calls setCircularReferences), then restore them afterward. This ensures setCircularReferences compares bare model names (e.g., 'Node') to model keys instead of 'std::shared_ptr<Node>', correctly identifying cycles. - Add cycle detection test fixture with self-cycle (TreeNode), mutual cycle (RoundA↔RoundB), and non-cycle holder (CycleHolder→Leaf). - Fix nullability: change TemperatureContainer fixture from OAS 3.0 'nullable: true' to OAS 3.1 'anyOf: [number, null]' which triggers the composed lowering pipeline to emit std::optional<double>. - Fix template: add x-cpp-no-is-set vendor extension on properties whose dataType starts with std::optional<>, so the template skips the redundant IsSet flag. - Update tests: assert std::optional<double> for nullable properties instead of accepting double+IsSet as success.
- toJsonObject_internal: use m_{{name}}.has_value() for optional
- fromJsonObject_internal: use m_{{name}}.reset() for optional
- setter: skip m_{{name}}IsSet = true for optional
- Add .cpp assertions: verify has_value(), reset(), zero IsSet refs
Use vendorExtensions.x-cpp-composed-keyword in model-source.mustache
to differentiate oneOf (exactly-one match, throw on >1) from anyOf
(first-match sufficient) in fromJsonValue_{{classname}}.
Add countVariantBranches helper that counts all matching branches
using comma fold (no short-circuit). Use constexpr char comparison
on the composed keyword to emit compile-time-branched logic.
Add test assertions verifying:
- oneOf variant sources contain isOneOf, matchCount, and the
multi-match rejection error message
- Discriminated variant sources (anyOf+discriminator) do NOT
contain the non-discriminated oneOf/anyOf logic
All 23 existing and new tests pass.
- Fix optional defaults: std::optional<T> members no longer init with
scalar default (0.0) in model-header.mustache for x-cpp-no-is-set
properties. TemperatureContainer now emits {} not {"temperature":0}.
- Fix property-level oneOf: JsonValueConverter<std::variant<Ts...>>::
fromJsonValue now enforces exactly-one matching using
countVariantBranches, rejecting ambiguous multi-match payloads.
- Nullability: documented current Phase 2 scope (unset->omit with
code comment; tri-state deferred) in model-source.mustache.
- anyOf non-discriminated fixture: added AnyOfStringInteger
(anyOf string|integer -> std::variant<string, int32_t>) and
AnyOfPropertyHolder to test spec with assertions for first-match.
- Always set x-cpp-composed-keyword: added keyword resolution in both
fallback paths (postProcessModels) so every alias model has the
vendor extension. Degenerate fallback defaults to 'oneOf'.
There was a problem hiding this comment.
1 issue found across 21 files
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="modules/openapi-generator/src/main/resources/cpp-boost-beast-client/model-header.mustache">
<violation number="1" location="modules/openapi-generator/src/main/resources/cpp-boost-beast-client/model-header.mustache:22">
P1: Nullable composed aliases fail JSON conversion when their inner type is a generated object (for example, `anyOf: [SomeObject, null]`). The alias implementation should delegate optional/inner-model conversion through the generated model conversion helpers instead of calling `boost::json::value_to`/`value_from` directly.</violation>
</file>
Tip: cubic can generate docs of your entire codebase and keep them up to date. Try it here.
Re-trigger cubic
forbiddenapis rejects Field#setAccessible; use the public Lombok setter on CodegenDiscriminator instead.
executeStream was buffering the full response body before calling onEvent once, defeating the purpose of streaming/SSE. Replace with response_parser<buffer_body> and async_read_some loop that delivers body chunks incrementally via the callback. - Add streamBody template using async_read_header + async_read_some loop - Add executeHttpStream/executeHttpsStream private methods - Handle end_of_stream gracefully (connection-close delimited bodies) - Add 4 streaming tests (multi-chunk, empty, single, body-limit) - Fix test helper classes (ThrowingClient, RecordingClient) for new pure virtual - Adapt pet/order model tests to regenerated model code
Deliver complete WHATWG-framed event data payloads via onEvent instead of raw body chunks, and wire streaming API ops to executeStream + appendParsedEvent.
…d SSE type "x-codegen-dual-content" was set unconditionally when produces contained both text/event-stream and JSON, but the SSE element type could remain unresolved when the response schema was absent. The mustache templates would then emit "std::vector<>" with an empty template parameter — invalid C++. Moved "x-codegen-dual-content = true" inside the existing "sseReturnType != null" guard so the stream method is only generated when a concrete SSE element type is actually available.
…ted properties
A non-required nullable property on a derived model (allOf + discriminator)
produced uncompilable C++: serialization and reset code called .has_value()
and .reset() directly on ModelPropertyStorage instead of its .value member.
With inheritance, ModelPropertyStorage<ValueType, true> is an empty struct
(no members at all). Three changes fix this:
1. Guard inheritance checks with \`if constexpr\` so C++17 discards false
branches entirely — inherited properties' dead code is never compiled,
avoiding errors from accessing non-existent .value on empty storage.
2. For parent model serialization, unwrap via .value before .has_value():
\`m_{{name}}.value.has_value()\` instead of \`m_{{name}}.has_value()\`.
3. For parent model deserialization reset, add an \`if constexpr\` inheritance
guard and unwrap via .value before .reset():
\`m_{{name}}.value.reset()\` instead of \`m_{{name}}.reset()\`.
Non-parent models are unaffected — they use plain std::optional<T> members
which correctly support .has_value() and .reset() directly.
… re-lowering Transitive alias resolution (Phase 1b) was calling lowerComposedTypesFromCppTypes which constructs ComposedBranch objects with isEnum=false for all branches. This caused a oneOf [open-string, string-enum] whose branches resolve to ["std::string", "std::string"] through the alias chain to collapse to plain std::string (Rule 7), losing the oneOf overlap detection (Rule 6) that correctly type-erases to boost::json::value. Fix: - Store per-branch isEnum metadata as x-cpp-branch-is-enum in processComposedModel - In Phase 1b, reconstruct ComposedBranch objects with isEnum sourced from: 1. Model lookup (CodegenModel.isEnum) for branches 2. Stored x-cpp-branch-is-enum as fallback for inline enum schemas Test: - Add OpenStringModel + StringEnumModel + OneOfWithStringOverlap to composed-schema-lowering.yaml spec - Verify OneOfWithStringOverlap emits boost::json::value (not std::string) - Verify StringOverlapHolder property uses the OneOfWithStringOverlap typedef
…element type is absent
…optional<T>
The isNullableSchema check in fromModel incorrectly excluded $ref
schemas (model.getType() == null) and array types from nullable
precomputation. This meant:
1. OAS 3.1 anyOf/oneOf [$ref: X, null] — the normalizer consumes the
branches and produces {$ref: X, nullable: true}. Since getType()
returns null for $ref schemas, the nullable path was never entered,
so these models never got 'using T = std::optional<X>' aliases.
2. type: array, nullable: true — explicitly excluded by
!"array".equals(model.getType()), so nullable arrays missed the
std::optional wrapper.
Fix: use model.get$ref() != null to catch normalized $ref schemas,
and drop the array exclusion. Inline object schemas (no $ref) remain
excluded because getTypeDeclaration would return the raw OAS type name
"object" instead of the model name.
…onse converter The `tryAllVariantAlternatives` function in the API response layer used a short-circuit || fold expression, silently accepting ambiguous oneOf payloads that matched multiple alternatives. This violates the OpenAPI spec requirement that oneOf validates against exactly one subschema. Changed to a comma-fold counting expression (same pattern as countVariantBranches in the model layer) that evaluates all alternatives and rejects both zero and >1 matches. Updated the error message to describe the oneOf semantics constraint.
…tainer templates Request bodies containing arrays or maps of composed types (e.g. vector<optional<variant<...>>>) could silently fall back to boost::json::value_from instead of the custom toRequestJsonValue overloads because: 1. The std::optional<T> overload had no forward declaration, so non-ADL two-phase lookup at the vector/map/variant template definition points could not find it — only the base template matched, routing to boost::json::value_from. 2. Both variant and optional definitions appeared after the vector/map definitions, making the recursive dispatch fragile when composed types were nested inside containers. Fix: add the missing optional<T> forward declaration alongside the existing forward declarations, and move both the variant and optional definitions before the vector and map definitions. This ensures all overloads participate in overload resolution regardless of nesting.
…sed schema map variants When a oneOf/anyOf variant alternative is std::map<std::string, T>, tryParseBranch fell through to the model-type fallback, which tried calling fromJsonValue on std::map — a type with no such member — causing valid map-valued variants to be rejected as non-matching. Add a std::map decoder branch in all three tryParseBranch functions (variant and non-variant in model-source.mustache, plus api-source.mustache), mirroring the existing std::vector pattern: iterate object entries, recursively parse each mapped value, and build error paths using [key] notation. This keeps map-valued branches in composed schemas usable.
…event silent wrapping Out-of-range int64 JSON values were silently wrapped into int32_t via unchecked static_cast during oneOf/anyOf variant decoding. A value like 2147483648 (INT32_MAX + 1) would cast to -2147483648, causing the int32_t branch to incorrectly match. Fix: extract to int64_t first, then validate against numeric_limits<int32_t> before the cast. The extra-paren idiom avoids Windows min/max macro conflicts. Applied in both tryParseBranch overloads (model-source.mustache) and the non-error-path overload (api-source.mustache).
…to cover all collapse scenarios When a oneOf model has multiple string-like branches (open-string + string-enum, or string-enum-A + string-enum-B) that all collapse to std::string after type lowering, countVariantBranches undercounts matches (artificially 1 instead of 2+), causing false acceptance of invalid oneOf inputs. The existing Rule 6 only handled the case where ALL branches collapse to a single std::string AND both an open-string and string-enum were present. Extended it to also catch: - Multiple string-enum branches collapsing alone - String branches collapsing alongside non-string types (e.g., int) - Any combination where pre-dedup string count > post-dedup string count and at least one branch has enum constraints Updated DedupTest and deduplicatesIdenticalBranchTypes test expectations from std::variant<std::string, int32_t> to boost::json::value.
- Reject missing required const keys during JSON decode (missing 'else' branch for required const fields in fromJsonObject_internal). - Reject wrong-kind JSON values for const fields: string const now rejects non-string JSON, and numeric/bool const rejects values that are not int64/double/bool. - Both bugs allowed the const path to silently accept invalid JSON that the non-const path would correctly reject.
…rseBranch for binary variant support
…se 1b alias collapse
… to std::optional<T>
…th OpenAI corpus In commit fe87d1c, the openai-cpp-sdk subtree was stripped from the branch, which removed gate-a.sh. The comment in expected-types.yaml referenced gate-a.sh but the script no longer existed, making the manifest comment stale. Restore gate-a.sh as a standalone compliance harness adapted for the current location under generator test resources. Add gitignore entries for its output directories (generated-oas-client/). gate-a.sh auto-discovers the project root, builds the generator jar, generates from both positive and negative fixtures, inventories all composed schemas, and compares types against expected-types.yaml.
CMake 3.15+ -> 3.14+ (matches cmake_minimum_required(VERSION 3.14)) OpenSSL 1.1.1+ -> 1.1.0+ (matches find_package(OpenSSL 1.1.0 ...)) SSL_CTX_set_min_proto_version setter is available since 1.1.0, so no 1.1.1-specific feature is required.
There was a problem hiding this comment.
3 issues found across 47 files
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="modules/openapi-generator/src/main/resources/cpp-boost-beast-client/api-operation-source.mustache">
<violation number="1" location="modules/openapi-generator/src/main/resources/cpp-boost-beast-client/api-operation-source.mustache:203">
P1: Successful no-content operations can throw `Unexpected HTTP status code` instead of returning normally. The response handling is now gated by `x-codegen-return-compatible`, but `addApiResponseMetadata` does not set that extension when `operation.produces` is empty; response compatibility needs to be initialized for every operation or the template must retain the existing void path.</violation>
</file>
<file name="modules/openapi-generator/src/main/resources/cpp-boost-beast-client/README.mustache">
<violation number="1" location="modules/openapi-generator/src/main/resources/cpp-boost-beast-client/README.mustache:220">
P2: The documented Boost 1.75 minimum is too low for generated nullable alias models: their alias source relies on Boost.JSON's `std::optional` value conversion, which was added in Boost 1.81. Either raise the generated client's minimum to Boost 1.81 or route alias serialization through a generator-provided optional converter.</violation>
</file>
<file name="modules/openapi-generator/src/main/resources/cpp-boost-beast-client/model-source.mustache">
<violation number="1" location="modules/openapi-generator/src/main/resources/cpp-boost-beast-client/model-source.mustache:315">
P2: Discriminator mapping values are rendered as raw C++ string literals, so valid mapping keys containing C++-special characters can break generation or inject source. The mapping key should be C++-escaped before it is rendered in the comparison.</violation>
</file>
Tip: cubic can generate docs of your entire codebase and keep them up to date. Try it here.
Re-trigger cubic
| {{^isDefault}} | ||
| if ({{#isRange}}static_cast<unsigned int>(statusCode) / 100U == {{vendorExtensions.x-codegen-response-range}}U{{/isRange}}{{^isRange}}statusCode == boost::beast::http::status({{code}}){{/isRange}}) { | ||
| {{#is2xx}} | ||
| {{#vendorExtensions.x-codegen-return-compatible}} |
There was a problem hiding this comment.
P1: Successful no-content operations can throw Unexpected HTTP status code instead of returning normally. The response handling is now gated by x-codegen-return-compatible, but addApiResponseMetadata does not set that extension when operation.produces is empty; response compatibility needs to be initialized for every operation or the template must retain the existing void path.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At modules/openapi-generator/src/main/resources/cpp-boost-beast-client/api-operation-source.mustache, line 203:
<comment>Successful no-content operations can throw `Unexpected HTTP status code` instead of returning normally. The response handling is now gated by `x-codegen-return-compatible`, but `addApiResponseMetadata` does not set that extension when `operation.produces` is empty; response compatibility needs to be initialized for every operation or the template must retain the existing void path.</comment>
<file context>
@@ -158,10 +195,21 @@
{{^isDefault}}
if ({{#isRange}}static_cast<unsigned int>(statusCode) / 100U == {{vendorExtensions.x-codegen-response-range}}U{{/isRange}}{{^isRange}}statusCode == boost::beast::http::status({{code}}){{/isRange}}) {
{{#is2xx}}
+{{#vendorExtensions.x-codegen-return-compatible}}
+{{#vendorExtensions.x-codegen-streaming-response}}
+{{#returnType}}
</file context>
Description
Breaking redesign of composed-schema support in
cpp-boost-beast-client.Previously,
oneOf/anyOf/allOfmodels were emitted as empty object shells, so request/response JSON lost real values (e.g.{"model":{},"input":{}}). This PR lowers composed OpenAPI schemas to standard C++ types and generates working encode/decode.Composition lowering (OAS-first):
oneOf/ non-nullanyOf→std::variant<...>anyOf: [T, null]/ nullable →std::optional<T>anyOfstring + string-enum →std::string(anyOf-only collapse)oneOfstring + string-enum →boost::json::value(no false exclusive collapse)allOfobject merge; hard error on scalar conflictsconst/ single-valueenumfixed-on-encode (x-stainless-constoptional only)API / transport:
contentmap + Accept (not vendorstreamflags)Motivation and Context
Composed schemas are common in modern OpenAPI documents. Empty-shell output made the Beast client unusable for those APIs. This is intentionally breaking for
cpp-boost-beast-clientonly; no other generators are affected.Testing
CppBoostBeastClientCodegenTest,CppBoostBeastClientApiCodegenTestsrc/test/resources/3_1/cpp-boost-beast-client/(+oas-compliance/)./mvnw -pl modules/openapi-generator -am \ -Dtest=CppBoostBeastClientCodegenTest,CppBoostBeastClientApiCodegenTest \ -DfailIfNoTests=false -Dsurefire.failIfNoSpecifiedTests=false testBreaking change
Yes — generated model/API surface for composed schemas changes from empty structs to
std::variant/std::optional/ collapsed types. Prior composed output was non-functional.Related issues
N/A
PR checklist
Summary by cubic
Implements variant-first oneOf/anyOf/allOf in
cpp-boost-beast-clientwith real C++ types, working JSON encode/decode, multi-content + SSE streaming, and a C++17 upgrade. Adds a Gate A composition compliance harness and updates docs (feature support table, include mappings, and CMake/OpenSSL minimums).Bug Fixes
std::map/std::uint8_tbranches for composed variants.$ref, arrays, maps, and inline object schemas marked nullable now lower tostd::optional<T>; fix regressions for inherited nullable properties (allOf + discriminator) by using correct optional storage andhas_value/resetpaths.executeStreamdelivers framed SSE events incrementally, rejects schema-less SSE, and only generates dual-content stream methods when an SSE element type is resolved.gate-a.shcomposition harness; fix README converter function names; align README minimums withCMake3.14 and OpenSSL 1.1.0; update generator docs to mark composed-schema features as supported and documentstd::variant/std::optional/std::monostateincludes.Migration
std::variant/std::optional/collapsed aliases. Update call sites to construct/visit variants and check optionals.CXX_STANDARD 17).HttpClient::executeStream. For multi-content/SSE, set an Accept header; stream methods returnstd::vector<element>and use generatedfromJsonValue_{Type}. SSE endpoints must define a response schema.Written for commit aeef0bd. Summary will update on new commits.