Skip to content

feat(cpp-boost-beast-client): variant-first oneOf/anyOf/allOf composition#24387

Draft
bold84 wants to merge 74 commits into
OpenAPITools:masterfrom
bold84:plan/cpp-boost-beast-oneof-anyof-plan
Draft

feat(cpp-boost-beast-client): variant-first oneOf/anyOf/allOf composition#24387
bold84 wants to merge 74 commits into
OpenAPITools:masterfrom
bold84:plan/cpp-boost-beast-oneof-anyof-plan

Conversation

@bold84

@bold84 bold84 commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Description

Breaking redesign of composed-schema support in cpp-boost-beast-client.

Previously, oneOf / anyOf / allOf models 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-null anyOfstd::variant<...>
  • anyOf: [T, null] / nullable → std::optional<T>
  • anyOf string + string-enum → std::string (anyOf-only collapse)
  • oneOf string + string-enum → boost::json::value (no false exclusive collapse)
  • Compatible allOf object merge; hard error on scalar conflicts
  • Discriminator-aware decode for object unions
  • OAS const / single-value enum fixed-on-encode (x-stainless-const optional only)

API / transport:

  • JSON bodies/responses use generated converters
  • Multi-content / SSE driven by response content map + Accept (not vendor stream flags)
  • Multipart binary|object variant parts

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-client only; no other generators are affected.

Testing

  • Unit tests: CppBoostBeastClientCodegenTest, CppBoostBeastClientApiCodegenTest
  • OAS composition fixtures under src/test/resources/3_1/cpp-boost-beast-client/ (+ oas-compliance/)
./mvnw -pl modules/openapi-generator -am \
  -Dtest=CppBoostBeastClientCodegenTest,CppBoostBeastClientApiCodegenTest \
  -DfailIfNoTests=false -Dsurefire.failIfNoSpecifiedTests=false test

Breaking 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

  • Read the contribution guidelines
  • Pull Request title clearly describes the work in the base branch and your test/feature branch
  • If this PR adds or updates a feature, did you update the documentation / samples?
  • If this PR adds or updates a feature, did you update the tests?

Summary by cubic

Implements variant-first oneOf/anyOf/allOf in cpp-boost-beast-client with 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

    • Decoding: enforce oneOf exactly-one for request/response, refresh discriminator resolution after alias collapse, enforce const-property invariants, validate int32 bounds, and add std::map/std::uint8_t branches for composed variants.
    • Nullability: $ref, arrays, maps, and inline object schemas marked nullable now lower to std::optional<T>; fix regressions for inherited nullable properties (allOf + discriminator) by using correct optional storage and has_value/reset paths.
    • Streaming: executeStream delivers framed SSE events incrementally, rejects schema-less SSE, and only generates dual-content stream methods when an SSE element type is resolved.
    • Converters: ensure variant/optional helpers are declared before container templates so nested containers of composed types use the correct overloads.
    • Compliance/docs: restore gate-a.sh composition harness; fix README converter function names; align README minimums with CMake 3.14 and OpenSSL 1.1.0; update generator docs to mark composed-schema features as supported and document std::variant/std::optional/std::monostate includes.
  • Migration

    • Breaking: composed-schema models are now std::variant/std::optional/collapsed aliases. Update call sites to construct/visit variants and check optionals.
    • Requires C++17 (CXX_STANDARD 17).
    • Streaming: use HttpClient::executeStream. For multi-content/SSE, set an Accept header; stream methods return std::vector<element> and use generated fromJsonValue_{Type}. SSE endpoints must define a response schema.

Written for commit aeef0bd. Summary will update on new commits.

Review in cubic

bold84 added 30 commits July 20, 2026 06:49
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).
- 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'.
@bold84
bold84 marked this pull request as draft July 22, 2026 13:35

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

bold84 added 23 commits July 22, 2026 21:05
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
…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.
…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.
@bold84
bold84 marked this pull request as ready for review July 22, 2026 18:53
@bold84
bold84 marked this pull request as draft July 22, 2026 18:54

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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}}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant