diff --git a/.gitignore b/.gitignore index a3d23cf207fb..3fbd2a7a0b18 100644 --- a/.gitignore +++ b/.gitignore @@ -314,4 +314,13 @@ samples/client/jetbrains/adyen/checkout71/http/client/Apis/http-client.private.e \?/ # AI agent caches -.junie \ No newline at end of file +.junie + +# openai-cpp-sdk build artifacts (Gate A / B harnesses) +/generated-oas-client/ +/generated-oas-client-negative/ +openai-cpp-sdk/generated-oas/ +openai-cpp-sdk/generated-oas-negative/ +openai-cpp-sdk/generated-openai/ +openai-cpp-sdk/build-generated/ +openai-cpp-sdk/build-master-verification/ diff --git a/docs/generators/cpp-boost-beast-client.md b/docs/generators/cpp-boost-beast-client.md index 32e0abea44f8..8ecd9ffa97d5 100644 --- a/docs/generators/cpp-boost-beast-client.md +++ b/docs/generators/cpp-boost-beast-client.md @@ -32,8 +32,12 @@ These options may be applied as additional-properties (cli) or configOptions (pl |int32_t|#include <cstdint>| |int64_t|#include <cstdint>| |std::map|#include <map>| +|std::monostate|#include <variant>| |std::nullptr_t|#include <cstddef>| +|std::optional|#include <optional>| +|std::shared_ptr|#include <memory>| |std::string|#include <string>| +|std::variant|#include <variant>| |std::vector|#include <vector>| @@ -239,11 +243,11 @@ These options may be applied as additional-properties (cli) or configOptions (pl | ---- | --------- | ---------- | |Simple|✓|OAS2,OAS3 |Composite|✓|OAS2,OAS3 -|Polymorphism|✗|OAS2,OAS3 -|Union|✗|OAS3 -|allOf|✗|OAS2,OAS3 -|anyOf|✗|OAS3 -|oneOf|✗|OAS3 +|Polymorphism|✓|OAS2,OAS3 +|Union|✓|OAS3 +|allOf|✓|OAS2,OAS3 +|anyOf|✓|OAS3 +|oneOf|✓|OAS3 |not|✗|OAS3 ### Security Feature diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/CppBoostBeastClientCodegen.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/CppBoostBeastClientCodegen.java index 64eb895cdf13..d24dd2cca74a 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/CppBoostBeastClientCodegen.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/CppBoostBeastClientCodegen.java @@ -1,6 +1,7 @@ package org.openapitools.codegen.languages; +import io.swagger.v3.oas.models.OpenAPI; import io.swagger.v3.oas.models.media.Schema; import io.swagger.v3.oas.models.parameters.Parameter; import org.apache.commons.lang3.StringUtils; @@ -8,6 +9,9 @@ import java.io.File; import java.util.*; +import java.util.Map; +import java.util.HashMap; +import java.util.stream.Collectors; import org.openapitools.codegen.meta.features.*; import org.openapitools.codegen.model.ModelMap; @@ -38,7 +42,19 @@ public class CppBoostBeastClientCodegen extends AbstractCppCodegen { private static final String X_CODEGEN_QUERY_MAP_DEEP_OBJECT = "x-codegen-query-map-deep-object"; private static final String X_CODEGEN_RESPONSE_RANGE = "x-codegen-response-range"; + private static final String X_CODEGEN_RESPONSE_IS_ONE_OF = "x-codegen-response-is-oneof"; + private static final String X_CODEGEN_STREAM_IS_ONE_OF = "x-codegen-stream-is-oneof"; + private static final String X_CODEGEN_DUAL_STREAM_IS_ONE_OF = "x-codegen-dual-stream-is-oneof"; private final Logger LOGGER = LoggerFactory.getLogger(CppBoostBeastClientCodegen.class); + /** Tracks model names resolved as oneOf/anyOf variant types for shared_ptr exclusion. */ + private final Set variantModels = new HashSet<>(); + /** Caches resolved C++ types for composed models, keyed by model name. + * Populated during Phase 1 of postProcessModels and used by Phase 1b to + * transitively resolve $ref chains through model aliases (e.g., ModelIds + * referencing ModelIdsShared, both ultimately std::string). */ + private final Map resolvedAliasTypes = new HashMap<>(); + /** Retains composition semantics after named schemas are lowered to C++ aliases. */ + private final Map composedKeywordsByModel = new HashMap<>(); protected String packageName = DEFAULT_PACKAGE_NAME; public CodegenType getTag() { @@ -53,8 +69,29 @@ public String getHelp() { return "Generates a cpp-boost-beast client."; } + @Override + public void preprocessOpenAPI(OpenAPI openAPI) { + super.preprocessOpenAPI(openAPI); + // Populate variantModels before model processing begins so that + // getTypeDeclaration can resolve $ref to composed models as value types + // (without shared_ptr wrapping) regardless of processing order. + Map schemas = openAPI.getComponents() != null + ? openAPI.getComponents().getSchemas() : null; + if (schemas != null) { + for (Map.Entry entry : schemas.entrySet()) { + String name = entry.getKey(); + Schema schema = entry.getValue(); + if ((schema.getOneOf() != null && !schema.getOneOf().isEmpty()) + || (schema.getAnyOf() != null && !schema.getAnyOf().isEmpty())) { + variantModels.add(name); + } + } + } + } + public CppBoostBeastClientCodegen() { super(); + openapiNormalizer.put("NORMALIZER_CLASS", CppBoostBeastOpenAPINormalizer.class.getName()); modifyFeatureSet(features -> features .includeDocumentationFeatures(DocumentationFeature.Readme) .securityFeatures(EnumSet.noneOf(SecurityFeature.class)) @@ -65,8 +102,13 @@ public CppBoostBeastClientCodegen() { GlobalFeature.ParameterStyling, GlobalFeature.MultiServer ) - .excludeSchemaSupportFeatures( - SchemaSupportFeature.Polymorphism + .includeSchemaSupportFeatures( + SchemaSupportFeature.Polymorphism, + SchemaSupportFeature.Composite, + SchemaSupportFeature.oneOf, + SchemaSupportFeature.anyOf, + SchemaSupportFeature.allOf, + SchemaSupportFeature.Union ) .includeDataTypeFeatures( DataTypeFeature.AnyType, @@ -133,9 +175,50 @@ public CppBoostBeastClientCodegen() { importMapping.put("boost::json::value", "#include "); importMapping.put("std::nullptr_t", "#include "); importMapping.put("Null", "#include "); + importMapping.put("std::optional", "#include "); + importMapping.put("std::variant", "#include "); + importMapping.put("std::monostate", "#include "); + importMapping.put("std::shared_ptr", "#include "); importMapping.put("AnyType", "#include \"AnyType.h\""); } + /** Retains [Model, null] unions while preserving default normalization elsewhere. */ + public static final class CppBoostBeastOpenAPINormalizer extends OpenAPINormalizer { + public CppBoostBeastOpenAPINormalizer(OpenAPI openAPI, Map inputRules) { + super(openAPI, inputRules); + } + + @Override + protected Schema processSimplifyAnyOf(Schema schema) { + return nullableModelRef(schema.getAnyOf()) == null + ? super.processSimplifyAnyOf(schema) : schema; + } + + @Override + protected Schema processSimplifyOneOf(Schema schema) { + return nullableModelRef(schema.getOneOf()) == null + ? super.processSimplifyOneOf(schema) : schema; + } + + private String nullableModelRef(List branches) { + if (branches == null || branches.size() != 2) { + return null; + } + String referencedModel = null; + int nullBranches = 0; + for (Schema branch : branches) { + if (ModelUtils.isNullTypeSchema(openAPI, branch)) { + nullBranches++; + } else if (branch.get$ref() != null) { + referencedModel = ModelUtils.getSimpleRef(branch.get$ref()); + } else { + return null; + } + } + return nullBranches == 1 ? referencedModel : null; + } + } + @Override public Map updateAllModels(Map objs) { @@ -158,10 +241,1080 @@ public Map updateAllModels(Map objs) { } } + // --- Critical: Normalize shared_ptr types for cycle detection --- + // DefaultCodegen.setCircularReferences compares property dataType strings + // to model names literally. Since getTypeDeclaration wraps refs in + // "std::shared_ptr", the comparison "std::shared_ptr" != "Node" + // never matches — cycles go undetected. This causes the shared_ptr stripping + // phase below to strip ALL wrappers, including cycle edges, producing invalid + // C++ with value self-refs. + // + // Fix: Temporarily strip std::shared_ptr<> wrappers from all property + // dataTypes BEFORE super.updateAllModels runs (which calls setCircularReferences), + // then restore them after. This ensures setCircularReferences sees bare model + // names and correctly identifies cycles. + Map> savedSharedPtr = new HashMap<>(); + for (CodegenModel cm : allModels.values()) { + Map modelSaves = new HashMap<>(); + for (CodegenProperty var : allVarsOf(cm)) { + if (var == null) continue; + checkAndSaveSharedPtr(var, cm.classname, modelSaves); + if (var.isContainer && var.items != null) { + checkAndSaveSharedPtr(var.items, cm.classname, modelSaves); + } + } + if (!modelSaves.isEmpty()) { + savedSharedPtr.put(cm.classname, modelSaves); + } + } + objs = super.updateAllModels(objs); + + // Restore shared_ptr wrappers stripped above. + // isCircularReference flags are now correctly set by setCircularReferences + // because it compared bare model names. + for (CodegenModel cm : allModels.values()) { + Map modelSaves = savedSharedPtr.get(cm.classname); + if (modelSaves == null) continue; + for (CodegenProperty var : allVarsOf(cm)) { + if (var == null) continue; + restoreSavedSharedPtr(var, cm.classname, modelSaves); + if (var.isContainer && var.items != null) { + restoreSavedSharedPtr(var.items, cm.classname + ".items", modelSaves); + } + } + } + + // Phase: Strip std::shared_ptr from non-cyclic object refs. + // super.updateAllModels → DefaultCodegen.updateAllModels → setCircularReferences + // has now run, setting isCircularReference flags on properties correctly. + // Non-cyclic edges should use value semantics (plain X) rather than + // std::shared_ptr to avoid unnecessary heap allocation. + for (CodegenModel cm : allModels.values()) { + for (CodegenProperty var : allVarsOf(cm)) { + if (var == null) continue; + stripNonCyclicSharedPtr(var); + if (var.isContainer && var.items != null) { + stripNonCyclicSharedPtr(var.items); + } + } + } + return objs; } + /** + * Returns all property lists of a model for iteration. + */ + private static List allVarsOf(CodegenModel cm) { + List combined = new ArrayList<>(); + if (cm.vars != null) combined.addAll(cm.vars); + if (cm.allVars != null) combined.addAll(cm.allVars); + if (cm.requiredVars != null) combined.addAll(cm.requiredVars); + if (cm.optionalVars != null) combined.addAll(cm.optionalVars); + if (cm.readOnlyVars != null) combined.addAll(cm.readOnlyVars); + if (cm.readWriteVars != null) combined.addAll(cm.readWriteVars); + if (cm.parentVars != null) combined.addAll(cm.parentVars); + return combined; + } + + /** + * If a property has a dataType wrapped in std::shared_ptr<>, strips the + * wrapper and saves the original under a compound key (modelName.baseName) + * so it can be restored after setCircularReferences runs. + */ + private static void checkAndSaveSharedPtr(CodegenProperty var, String modelName, + Map saves) { + if (var.dataType != null && var.dataType.startsWith("std::shared_ptr<")) { + String key = modelName + "." + var.baseName; + if (!saves.containsKey(key)) { + saves.put(key, var.dataType); + } + var.dataType = var.dataType.substring(16, var.dataType.length() - 1); + } + } + + /** + * Restores a previously saved shared_ptr-wrapped dataType onto a property. + */ + private static void restoreSavedSharedPtr(CodegenProperty var, String modelName, + Map saves) { + String key = modelName + "." + var.baseName; + String saved = saves.get(key); + if (saved != null) { + var.dataType = saved; + } + } + + /** + * Strips std::shared_ptr from a non-cyclic property, replacing it with + * bare value type X. Cyclic properties retain the shared_ptr wrapper. + */ + private static void stripNonCyclicSharedPtr(CodegenProperty var) { + if (var.dataType != null && var.dataType.startsWith("std::shared_ptr<") + && !var.isCircularReference) { + String innerType = var.dataType.substring(16, var.dataType.length() - 1); + var.dataType = innerType; + var.defaultValue = null; + } + } + + @Override + public ModelsMap postProcessModels(ModelsMap objs) { + // Clear parent for non-inheriting array/map models (inherited from AbstractCppCodegen) + for (ModelMap mo : objs.getModels()) { + CodegenModel cm = mo.getModel(); + if ((cm.isArray || cm.isMap) && (cm.parentModel == null)) { + cm.parent = null; + } + } + + ModelsMap result = postProcessModelsEnum(objs); + + // Phase 1: Apply type lowering to oneOf/anyOf models + for (ModelMap mo : result.getModels()) { + processComposedModel(mo.getModel()); + } + + // Phase 2: Tag models with alias/variant flags for template dispatch. + // Mustache templates use these flags to choose between emitting a using + // alias (with to_json/from_json overloads for variants) vs. the existing + // object model class template (with properties). + for (ModelMap mo : result.getModels()) { + CodegenModel cm = mo.getModel(); + if (cm.vendorExtensions.containsKey("x-cpp-type")) { + cm.vendorExtensions.put("x-cpp-is-alias", true); + String resolvedType = (String) cm.vendorExtensions.get("x-cpp-type"); + // Resolve non-std:: types through the alias chain to detect + // models that alias to a variant (e.g., ParentServerEvent → + // StreamEventUnion → std::variant<...>). + String ultimateType = resolveThroughAliases(resolvedType); + if (ultimateType != null && ultimateType.startsWith("std::variant<")) { + cm.vendorExtensions.put("x-cpp-is-variant", true); + cm.vendorExtensions.putIfAbsent("x-cpp-composed-keyword", "oneOf"); + } + } else if (cm.parent != null && !cm.parent.isEmpty() + && resolvedAliasTypes.containsKey(cm.parent)) { + // (e.g., ParentServerEvent : public StreamEventUnion) but where + // the parent is a resolved variant/alias. Since inheritance from a + // variant alias is invalid C++, treat this model as an alias too. + // Example: ParentServerEvent has anyOf: [StreamEventUnion] where + // StreamEventUnion = std::variant<...>. + String parentAlias = cm.parent; + cm.vendorExtensions.put("x-cpp-type", parentAlias); + cm.vendorExtensions.put("x-cpp-is-alias", true); + cm.dataType = parentAlias; + resolvedAliasTypes.put(cm.classname, parentAlias); + String parentResolvedType = resolvedAliasTypes.get(parentAlias); + if (parentResolvedType != null && parentResolvedType.startsWith("std::variant<")) { + cm.vendorExtensions.put("x-cpp-is-variant", true); + // Non-variant alias source template (Path B) only generates + // stubs. For variant aliases (Path A), we need the composed + // keyword to generate fromJsonValue_/toJsonValue_ functions. + // Default to oneOf (conservative: exactly-one enforcement). + cm.vendorExtensions.putIfAbsent("x-cpp-composed-keyword", "oneOf"); + } + } + } + + // Fallback: Detect models whose composedSchemas were consumed by fromModel + // before processComposedModel had a chance to run. This happens when the + // default codegen pipeline collapses a bare oneOf/anyOf (without type:object) + // into a flat dataType. These models have no vars and a dataType that differs + // from their classname (e.g., SingleBranchTest → std::string). + for (ModelMap mo : result.getModels()) { + CodegenModel cm = mo.getModel(); + if (cm.vendorExtensions.containsKey("x-cpp-is-alias")) { + continue; + } + if (cm.vars != null && !cm.vars.isEmpty()) { + continue; + } + if (cm.isArray || cm.isMap) { + continue; + } + if (cm.dataType != null + && !cm.dataType.equals(cm.classname) + && (cm.dataType.startsWith("std::") || "boost::json::value".equals(cm.dataType) + || resolvedAliasTypes.containsKey(cm.dataType))) { + cm.vendorExtensions.put("x-cpp-type", cm.dataType); + cm.vendorExtensions.put("x-cpp-is-alias", true); + resolvedAliasTypes.put(cm.classname, cm.dataType); + if (cm.dataType.startsWith("std::variant<")) { + cm.vendorExtensions.put("x-cpp-is-variant", true); + } + // Determine composed keyword from the CodegenModel's anyOf/oneOf sets + // for fallback paths that bypassed processComposedModel. For variant + // types, oneOf is the conservative default (enables exactly-one checking + // in fromJsonValue). + String fallbackKeyword = null; + if (cm.oneOf != null && !cm.oneOf.isEmpty()) { + fallbackKeyword = "oneOf"; + } else if (cm.anyOf != null && !cm.anyOf.isEmpty()) { + fallbackKeyword = "anyOf"; + } + if (fallbackKeyword == null) { + fallbackKeyword = "oneOf"; + } + cm.vendorExtensions.put("x-cpp-composed-keyword", fallbackKeyword); + composedKeywordsByModel.put(cm.classname, fallbackKeyword); + } + } + + // Degenerate fallback: Models like AllNullTest whose composed schemas + // (anyOf [null, null]) were entirely consumed by the default codegen + // without leaving usable branches or dataType. These models have no vars, + // are not arrays/maps, and have `isAnyType = true` (no explicit `type` field + // on the OpenAPI schema). Treat as boost::json::value alias. + for (ModelMap mo : result.getModels()) { + CodegenModel cm = mo.getModel(); + if (cm.vendorExtensions.containsKey("x-cpp-is-alias")) { + continue; + } + if (cm.vars != null && !cm.vars.isEmpty()) { + continue; + } + if (cm.isArray || cm.isMap) { + continue; + } + if (cm.getIsAnyType()) { + cm.vendorExtensions.put("x-cpp-type", "boost::json::value"); + resolvedAliasTypes.put(cm.classname, "boost::json::value"); + cm.vendorExtensions.put("x-cpp-is-alias", true); + // Even for boost::json::value fallbacks, set the keyword so + // template code referencing vendorExtensions.x-cpp-composed-keyword + // does not encounter an undefined variable. + cm.vendorExtensions.put("x-cpp-composed-keyword", "oneOf"); + composedKeywordsByModel.put(cm.classname, "oneOf"); + } + } + + // Phase 3b: Tag properties whose types already embed optional semantics + // (e.g., std::optional) so the template skips the redundant IsSet flag. + for (ModelMap mo : result.getModels()) { + CodegenModel cm = mo.getModel(); + for (CodegenProperty var : allVarsOf(cm)) { + if (var.dataType != null && var.dataType.startsWith("std::optional<")) { + var.vendorExtensions.put("x-cpp-no-is-set", true); + } + } + } + + // Phase 3c: Tag properties is deferred to postProcessAllModels (which runs + // once with the full model map) because postProcessModels is called per-model, + // so a cross-model lookup of variant aliases is not possible here. + + // Phase 4: Emit complete includes for resolved alias/variant types. + // Scan x-cpp-type and x-cpp-branches for known standard types and add + // corresponding #include directives to the model's imports. + for (ModelMap mo : result.getModels()) { + CodegenModel cm = mo.getModel(); + if (!cm.vendorExtensions.containsKey("x-cpp-is-alias")) { + continue; + } + String resolvedType = (String) cm.vendorExtensions.get("x-cpp-type"); + List branchTypes = (List) cm.vendorExtensions.get("x-cpp-branches"); + collectImportsForType(resolvedType, cm); + if (branchTypes != null) { + for (String branchType : branchTypes) { + collectImportsForType(branchType, cm); + } + } + // Remove self-includes that were added by the branch/type scan. + // A variant like std::variant referencing + // itself as a branch causes the model to include its own header. + cm.imports.removeIf(imp -> imp.equals("#include \"" + cm.classname + ".h\"")); + } + + return result; + } + + @Override + public Map postProcessAllModels(Map objs) { + Map processed = super.postProcessAllModels(objs); + // Build model index for enum lookup in Phase 1b. + Map allModels = getAllModels(processed); + + // Phase 1b (global): Transitive resolution for model-reference branches. + // Runs once with ALL models available (unlike Phase 1b in postProcessModels + // which is per-batch and cannot see models processed in other batches). + // Resolves $ref chains like ModelIdsResponses → ModelIdsShared → std::string. + // Multiple passes needed for deep chains (A→B→C→string). + boolean typeChanged = true; + int phase1bPass = 0; + while (typeChanged && phase1bPass < 10) { + typeChanged = false; + phase1bPass++; + for (Map.Entry entry : processed.entrySet()) { + for (ModelMap mo : entry.getValue().getModels()) { + CodegenModel cm = mo.getModel(); + if (!cm.vendorExtensions.containsKey("x-cpp-type")) { + continue; + } + String composedKeyword = (String) cm.vendorExtensions.get("x-cpp-composed-keyword"); + if (composedKeyword == null) { + continue; + } + List branchTypes = (List) cm.vendorExtensions.get("x-cpp-branches"); + if (branchTypes == null) { + continue; + } + List resolved = branchTypes.stream() + .map(this::resolveThroughAliases) + .collect(Collectors.toList()); + if (resolved.equals(branchTypes)) { + continue; + } + String currentType = (String) cm.vendorExtensions.get("x-cpp-type"); + String newType; + try { + // Reconstruct ComposedBranch objects using resolved C++ type + // strings and per-branch isEnum metadata. Without isEnum, a + // oneOf [open-string, string-enum] whose branches resolve to + // ["std::string", "std::string"] through the alias chain would + // collapse to plain std::string (Rule 7), losing the oneOf overlap + // detection (Rule 6) that correctly type-erases to boost::json::value. + // + // Branch isEnum comes from two sources: + // 1. For branches whose original type is a model name (not a C++ + // type string), look up the CodegenModel to check isEnum. + // 2. Fall back to stored x-cpp-branch-is-enum metadata from the + // first lowering pass (handles inline enum schemas where the + // CodegenProperty.isEnum flag was set directly). + @SuppressWarnings("unchecked") + List storedIsEnum = (List) cm.vendorExtensions.get("x-cpp-branch-is-enum"); + List branchesWithMeta = new ArrayList<>(); + for (int i = 0; i < resolved.size(); i++) { + boolean isEnum = false; + if ("std::string".equals(resolved.get(i))) { + // Source 1: Look up the original branch model for enum status. + String originalType = branchTypes.get(i); + CodegenModel branchModel = allModels.get(originalType); + isEnum = branchModel != null && branchModel.isEnum; + // Source 2: Fall back to stored metadata from first pass. + if (!isEnum && storedIsEnum != null && i < storedIsEnum.size()) { + isEnum = storedIsEnum.get(i); + } + } + boolean isStringLike = "std::string".equals(resolved.get(i)); + branchesWithMeta.add(new ComposedBranch(resolved.get(i), isEnum, isStringLike)); + } + newType = lowerComposedTypes(branchesWithMeta, composedKeyword); + } catch (RuntimeException e) { + LOGGER.warn("Failed to re-lower composed types for '{}': {} — keeping current type '{}'", + cm.classname, e.getMessage(), currentType); + continue; + } + if (!newType.equals(currentType)) { + cm.vendorExtensions.put("x-cpp-type", newType); + // Keep original x-cpp-branches for import resolution. + cm.dataType = newType; + resolvedAliasTypes.put(cm.classname, newType); + // Refresh discriminator resolved type — Phase 4b uses this + // to filter self-referential mappings and it must reflect + // the final post-collapse type, not the pre-collapse value + // cached during Phase 1a (updateAllModels). + if (cm.discriminator != null) { + cm.vendorExtensions.put("x-discriminator-resolved-type", newType); + } + typeChanged = true; + } + } + } + } + + // Refresh alias/variant flags after Phase 1b resolution. Phase 2 in + // postProcessModels may have set x-cpp-is-variant = true for models whose + // types were later collapsed to plain types (e.g., std::string) by Phase 1b. + // Use transitive alias resolution so models aliased to a variant type + // (e.g., ParentServerEvent → StreamEventUnion → std::variant<...>) + // also get the variant flag. + for (Map.Entry entry : processed.entrySet()) { + for (ModelMap mo : entry.getValue().getModels()) { + CodegenModel cm = mo.getModel(); + if (cm.vendorExtensions.containsKey("x-cpp-is-alias")) { + String resolvedType = (String) cm.vendorExtensions.get("x-cpp-type"); + String ultimateType = resolveThroughAliases(resolvedType); + if (ultimateType != null && ultimateType.startsWith("std::variant<")) { + cm.vendorExtensions.put("x-cpp-is-variant", true); + cm.vendorExtensions.putIfAbsent("x-cpp-composed-keyword", "oneOf"); + } else { + cm.vendorExtensions.remove("x-cpp-is-variant"); + } + } + } + } + + // Type-erased oneOf aliases still need to validate the original branch + // constraints before accepting the JSON value. + for (Map.Entry entry : processed.entrySet()) { + for (ModelMap modelMap : entry.getValue().getModels()) { + CodegenModel codegenModel = modelMap.getModel(); + if ("oneOf".equals(codegenModel.vendorExtensions.get("x-cpp-composed-keyword")) + && "boost::json::value".equals(codegenModel.vendorExtensions.get("x-cpp-type")) + && codegenModel.getComposedSchemas() != null + && codegenModel.getComposedSchemas().getOneOf() != null + && !codegenModel.getComposedSchemas().getOneOf().isEmpty()) { + codegenModel.vendorExtensions.put( + "x-cpp-type-erased-oneof-branches", + buildTypeErasedOneOfBranches(codegenModel, allModels)); + codegenModel.vendorExtensions.put("x-cpp-type-erased-oneof", true); + } + } + } + + // Phase 4b: Filter discriminator mappings to remove self-referential entries. + // After Phase 1b, all resolvedAliasTypes are final. A discriminator mapping + // like "ParentServerEvent" → ParentServerEvent where ParentServerEvent + // resolves to the same type as the current model (e.g., StreamEventUnion = + // std::variant<...>) would cause compile errors (constructing variant from self) + // and infinite recursion in fromJsonValue. + // The template uses discriminator.mappedModels (built-in CodegenModel field), + // NOT x-discriminator-mapping. We modify the actual CodegenModel discriminator. + for (Map.Entry entry : processed.entrySet()) { + for (ModelMap mo : entry.getValue().getModels()) { + CodegenModel cm = mo.getModel(); + if (cm.discriminator == null) continue; + String resolvedType = (String) cm.vendorExtensions.get("x-discriminator-resolved-type"); + if (resolvedType == null) continue; + Set mappedModels = cm.discriminator.getMappedModels(); + if (mappedModels == null || mappedModels.isEmpty()) continue; + Set filtered = new TreeSet<>(); + for (CodegenDiscriminator.MappedModel mm : mappedModels) { + if (mm.getModelName() != null) { + String resolvedTarget = resolveThroughAliases(mm.getModelName()); + if (resolvedTarget.equals(resolvedType)) { + continue; // skip self-referential mapping + } + } + CodegenDiscriminator.MappedModel escapedMapping = + new CodegenDiscriminator.MappedModel( + escapeCppStringContent(mm.getMappingName()), + mm.getModelName(), + mm.getSchemaName(), + mm.isExplicitMapping()); + escapedMapping.setModel(mm.getModel()); + filtered.add(escapedMapping); + } + cm.discriminator.setMappedModels(filtered); + } + } + + // Phase 5: Tag properties referencing a variant alias model so the template + // dispatches via fromJsonValue_/toJsonValue_ free functions (which respect the + // composed keyword — oneOf vs anyOf semantics) instead of the generic + // JsonValueConverter> (which always enforces exactly-one + // oneOf semantics, even for anyOf properties). + // + // This must run in postProcessAllModels (not postProcessModels) because the + // latter is called per-model, not globally. We need access to all models to + // look up whether a property's dataType refers to a variant alias model. + // + // Only true std::variant aliases (x-cpp-is-variant = true) have the + // toJsonValue_/fromJsonValue_ free functions. Non-variant aliases (e.g., + // ModelIdsResponses = std::string) must NOT be tagged. + for (Map.Entry entry : processed.entrySet()) { + for (ModelMap mo : entry.getValue().getModels()) { + CodegenModel cm = mo.getModel(); + for (CodegenProperty var : allVarsOf(cm)) { + if (var.dataType != null) { + // Look up the model that this property's dataType refers to + ModelsMap targetEntry = processed.get(var.dataType); + if (targetEntry != null) { + for (ModelMap targetMo : targetEntry.getModels()) { + CodegenModel targetModel = targetMo.getModel(); + if (Boolean.TRUE.equals(targetModel.vendorExtensions.get("x-cpp-is-variant"))) { + var.vendorExtensions.put("x-cpp-variant-alias", true); + var.vendorExtensions.put("x-cpp-variant-alias-name", var.dataType); + } + } + } + } + } + } + } + + // Phase 6: Add includes for discriminator-mapped models to variant + // alias headers/sources. The discriminator dispatch in the variant alias + // template calls fromJsonValue_{{modelName}}(value) or toJsonValue_{{modelName}}. + // Without the include, the compiler sees an undeclared identifier. + for (Map.Entry entry : processed.entrySet()) { + for (ModelMap mo : entry.getValue().getModels()) { + CodegenModel cm = mo.getModel(); + @SuppressWarnings("unchecked") + Map mapping = (Map) + cm.vendorExtensions.get("x-discriminator-mapping"); + if (mapping == null) continue; + for (String modelName : mapping.values()) { + if (modelName != null) { + collectImportsForType(modelName, cm); + } + } + } + } + + return processed; + } + + /** + * Scans a type string for known standard types and adds corresponding + * #include directives to the model's import set. Types that look like + * model names (start with an uppercase letter and are not otherwise + * mapped) are resolved via toModelImport. + */ + private void collectImportsForType(String type, CodegenModel cm) { + if (type == null) { + return; + } + boolean matchedImportMapping = false; + for (Map.Entry entry : importMapping.entrySet()) { + String mappedKey = entry.getKey(); + String mappedInclude = entry.getValue(); + if (type.contains(mappedKey)) { + cm.imports.add(mappedInclude); + if (type.equals(mappedKey) || type.startsWith(mappedKey + "<")) { + matchedImportMapping = true; + } + } + } + // If the type was not matched by importMapping and looks like a model + // name (starts with uppercase), treat it as a model include. + if (!matchedImportMapping && !type.isEmpty() && Character.isUpperCase(type.charAt(0))) { + String modelInclude = toModelImport(type); + if (modelInclude != null && !modelInclude.isEmpty()) { + cm.imports.add(modelInclude); + } + } + } + + /** + * Maps OpenAPI type names (from composed branch properties) to C++ types. + * Composed properties created by DefaultCodegen.fromProperty use OpenAPI + * type names (e.g., "null", "integer", "string") rather than mapped C++ types. + */ + + private String resolveOpenApiTypeName(String type) { + if (type == null) { + return null; + } + // Check typeMapping first for known OpenAPI type names + if ("null".equals(type)) { + return "std::nullptr_t"; + } + // Check if it's already a C++ type (starts with std:: or boost:: or is a model name) + if (type.startsWith("std::") || type.startsWith("boost::") || type.contains("<")) { + return type; + } + // Map through typeMapping for OpenAPI primitive type names + String mapped = typeMapping.get(type); + if (mapped != null) { + return mapped; + } + // If it has underscores or uppercase letters, assume it's already a model name + return type; + } + + /** + * Applies the ordered type lowering rules to a composed (oneOf/anyOf) model. + * Sets vendor extensions consumed by templates and records the model as a variant type. + * + * NOTE: When a schema uses both allOf and oneOf/anyOf at the same root level, + * the allOf branches are merged into properties while the oneOf/anyOf branches are + * lowered to variant types. This can produce a model with both concrete properties + * AND a variant type, which may generate conflicting C++ declarations. Avoid such + * mixed-schema patterns; prefer separate allOf-only or oneOf-only schemas. + */ + private void processComposedModel(CodegenModel cm) { + if (cm.getComposedSchemas() == null) { + return; + } + + List branches = null; + String composedKeyword = null; + + if (cm.getComposedSchemas().getOneOf() != null && !cm.getComposedSchemas().getOneOf().isEmpty()) { + branches = cm.getComposedSchemas().getOneOf(); + composedKeyword = "oneOf"; + } else if (cm.getComposedSchemas().getAnyOf() != null && !cm.getComposedSchemas().getAnyOf().isEmpty()) { + branches = cm.getComposedSchemas().getAnyOf(); + composedKeyword = "anyOf"; + } + + if (branches == null) { + return; + } + + // Collect C++ branch types (strip shared_ptr wrappers for variant members). + // Map OpenAPI type names (e.g., "null", "integer", "string") to C++ types + // because composed properties from fromProperty use OpenAPI type names as-is. + // Self-referencing branches (a variant containing itself) are excluded + // because they would create an illegal recursive type alias in C++. + // Binary branches (format: binary) are mapped to std::vector + // so the multipart addVariantFormParameter helper can dispatch them as + // file parts via compile-time type checking. + // NOTE: Deduplication is deferred to lowerComposedTypes (step 5) so that + // oneOf semantics can be preserved when duplicate types would otherwise + // cause silent single-branch collapse. + List composedBranches = new ArrayList<>(); + for (CodegenProperty b : branches) { + String cppType; + if (b.isBinary || b.isFile) { + cppType = "std::vector"; + } else { + cppType = resolveOpenApiTypeName(stripSharedPtr(b.dataType)); + } + if (cppType.equals(cm.classname)) { + continue; + } + boolean isStringLike = b.isString || "std::string".equals(cppType) + || "string".equals(b.dataType); + composedBranches.add(new ComposedBranch(cppType, b.isEnum, isStringLike)); + } + List branchTypes = composedBranches.stream() + .map(cb -> cb.cppType) + .collect(Collectors.toList()); + + String resolvedType; + try { + resolvedType = lowerComposedTypes(composedBranches, composedKeyword); + } catch (RuntimeException e) { + // Fallback: if lowering fails (e.g., indistinguishable oneOf branches), + // treat as boost::json::value and log the warning rather than crashing + // the entire generation pipeline. + LOGGER.warn("Failed to lower composed types for '{}': {} — falling back to boost::json::value", + cm.classname, e.getMessage()); + resolvedType = "boost::json::value"; + } + + // Cache the resolved type for transitive resolution in Phase 1b + resolvedAliasTypes.put(cm.classname, resolvedType); + + // Record as variant model for getTypeDeclaration shared_ptr exclusion + variantModels.add(cm.classname); + + // Emit vendor extensions consumed by Mustache templates + cm.vendorExtensions.put("x-cpp-type", resolvedType); + cm.vendorExtensions.put("x-cpp-branches", branchTypes); + cm.vendorExtensions.put("x-cpp-composed-keyword", composedKeyword); + composedKeywordsByModel.put(cm.classname, composedKeyword); + + // Store per-branch isEnum metadata for Phase 1b re-lowering. + // Phase 1b resolves model-name branch types through aliases to + // C++ type strings but the isEnum flag (used by Rule 6 for oneOf + // open-string + string-enum overlap detection) is not derivable + // from C++ type strings alone — both open strings and string enums + // produce "std::string". + List branchIsEnumFlags = composedBranches.stream() + .map(cb -> cb.isEnum) + .collect(Collectors.toList()); + cm.vendorExtensions.put("x-cpp-branch-is-enum", branchIsEnumFlags); + + if (cm.discriminator != null) { + cm.vendorExtensions.put("x-has-discriminator", true); + cm.vendorExtensions.put("x-discriminator-property", cm.discriminator.getPropertyBaseName()); + cm.vendorExtensions.put("x-discriminator-mapping", cm.discriminator.getMapping()); + // Self-referential discriminator entries are filtered in Phase 1b + // (postProcessAllModels) after all resolvedAliasTypes are populated. + // We store the resolved type so Phase 1b can check for self-refs. + cm.vendorExtensions.put("x-discriminator-resolved-type", resolvedType); + } + + // Update data type so templates and references use the resolved type + cm.dataType = resolvedType; + } + + /** Branch metadata used by ordered composition lowering. */ + private static final class ComposedBranch { + final String cppType; + final boolean isEnum; + final boolean isStringLike; + + ComposedBranch(String cppType, boolean isEnum, boolean isStringLike) { + this.cppType = cppType; + this.isEnum = isEnum; + this.isStringLike = isStringLike; + } + } + + private List> buildTypeErasedOneOfBranches( + CodegenModel codegenModel, Map allModels) { + List> validationBranches = new ArrayList<>(); + for (CodegenProperty branch : codegenModel.getComposedSchemas().getOneOf()) { + String originalType = stripSharedPtr(branch.dataType); + CodegenModel referencedModel = allModels.get(originalType); + String resolvedType = resolveThroughAliases(originalType); + if (referencedModel != null && referencedModel.dataType != null) { + resolvedType = resolveThroughAliases(stripSharedPtr(referencedModel.dataType)); + } + resolvedType = resolveOpenApiTypeName(resolvedType); + + Map validationBranch = new LinkedHashMap<>(); + if ("std::string".equals(resolvedType)) { + validationBranch.put("is-string", true); + List enumValues = getEnumValues(branch, referencedModel); + if (!enumValues.isEmpty()) { + validationBranch.put("has-enum-values", true); + List> escapedValues = new ArrayList<>(); + for (Object enumValue : enumValues) { + escapedValues.add(Collections.singletonMap( + "literal", escapeCppStringContent(String.valueOf(enumValue)))); + } + validationBranch.put("enum-values", escapedValues); + } + } else if ("bool".equals(resolvedType)) { + validationBranch.put("is-boolean", true); + } else if ("std::int32_t".equals(resolvedType) || "int32_t".equals(resolvedType)) { + validationBranch.put("is-int32", true); + } else if ("std::int64_t".equals(resolvedType) || "int64_t".equals(resolvedType)) { + validationBranch.put("is-integer", true); + } else if ("double".equals(resolvedType) || "float".equals(resolvedType)) { + validationBranch.put("is-number", true); + } else if ("std::nullptr_t".equals(resolvedType)) { + validationBranch.put("is-null", true); + } else if (resolvedType != null && resolvedType.startsWith("std::vector<")) { + validationBranch.put("is-array", true); + } else if (resolvedType != null + && (resolvedType.startsWith("std::map<") + || (!resolvedType.startsWith("std::") + && !resolvedType.startsWith("boost::")))) { + validationBranch.put("is-object", true); + } else { + validationBranch.put("is-any", true); + } + validationBranches.add(validationBranch); + } + return validationBranches; + } + + @SuppressWarnings("unchecked") + private static List getEnumValues( + CodegenProperty branch, CodegenModel referencedModel) { + Map allowableValues = branch.allowableValues; + if ((allowableValues == null || allowableValues.get("values") == null) + && referencedModel != null) { + allowableValues = referencedModel.allowableValues; + } + if (allowableValues == null || !(allowableValues.get("values") instanceof List)) { + return Collections.emptyList(); + } + return (List) allowableValues.get("values"); + } + + private static String escapeCppStringContent(String value) { + if (value == null) { + return ""; + } + StringBuilder escaped = new StringBuilder(value.length()); + for (int index = 0; index < value.length(); index++) { + char character = value.charAt(index); + switch (character) { + case '\\': + escaped.append("\\\\"); + break; + case '"': + escaped.append("\\\""); + break; + case '\n': + escaped.append("\\n"); + break; + case '\r': + escaped.append("\\r"); + break; + case '\t': + escaped.append("\\t"); + break; + case '\b': + escaped.append("\\b"); + break; + case '\f': + escaped.append("\\f"); + break; + default: + if (character < 0x20 || character == 0x7f) { + escaped.append(String.format(Locale.ROOT, "\\%03o", (int) character)); + } else { + escaped.append(character); + } + break; + } + } + return escaped.toString(); + } + + /** + * Ordered lowering rules for composed types (OAS-first): + * 1. anyOf/oneOf: [T, null] → std::optional<T> + * 2. anyOf only: all strings/string-enums → std::string + * 3. Remove null branches + * 4. Single non-null branch → that branch's type + * 5. Deduplicate identical branch types + * 6. oneOf open-string + string-enum (type-erased) → boost::json::value + * (do not pretend exclusivity after both erase to std::string) + * 7. oneOf multi-branch → single identical C++ type (alias collapse) → that type + * 8. Emit std::variant<Branches...> or boost::json::value + */ + private String lowerComposedTypes(List branches, String composedKeyword) { + if (branches == null || branches.isEmpty()) { + return "boost::json::value"; + } + List branchTypes = branches.stream() + .map(b -> b.cppType) + .collect(Collectors.toList()); + + // Rule 1: anyOf/oneOf: [T, null] → std::optional + int nullCount = (int) branchTypes.stream().filter("std::nullptr_t"::equals).count(); + if (nullCount == 1 && branchTypes.size() == 2) { + String nonNullBranch = branchTypes.stream() + .filter(bt -> !"std::nullptr_t".equals(bt)) + .findFirst().orElse(null); + if (nonNullBranch != null) { + return "std::optional<" + nonNullBranch + ">"; + } + } + + // Rule 2: anyOf-only collapse of all-string (including string-enum) to std::string. + // Do NOT apply this collapse to oneOf — exclusive semantics differ. + if ("anyOf".equals(composedKeyword) && branchTypes.stream().allMatch("std::string"::equals)) { + return "std::string"; + } + + // Rule 3: Remove null branches for further processing. + List nonNullMeta = branches.stream() + .filter(b -> !"std::nullptr_t".equals(b.cppType)) + .collect(Collectors.toList()); + List nonNullBranches = nonNullMeta.stream() + .map(b -> b.cppType) + .collect(Collectors.toList()); + + // Rule 3b: Flatten nested variants + List flattened = new ArrayList<>(); + for (String bt : nonNullBranches) { + if (bt.startsWith("std::variant<") && bt.endsWith(">")) { + String inner = bt.substring(13, bt.length() - 1); + int depth = 0; + int start = 0; + for (int i = 0; i < inner.length(); i++) { + char c = inner.charAt(i); + if (c == '<') depth++; + else if (c == '>') depth--; + else if (c == ',' && depth == 0) { + flattened.add(inner.substring(start, i).trim()); + start = i + 1; + } + } + if (start < inner.length()) { + flattened.add(inner.substring(start).trim()); + } + } else { + flattened.add(bt); + } + } + + // Rule 4: All-null or empty → boost::json::value + if (flattened.isEmpty()) { + return "boost::json::value"; + } + + // Rule 5: Deduplicate identical branch types. + List deduped = flattened.stream() + .distinct() + .collect(Collectors.toList()); + + // Rule 6: oneOf string branches that lose exclusivity after type lowering. + // Branches [open-string, string-enum] or [string-enum-A, string-enum-B] all + // collapse to std::string after type lowering, so every string value matches + // every original string-like branch. Under JSON Schema oneOf, this means + // values matching multiple original branches cannot be detected (count is + // artificially 1 instead of 2+), causing false acceptance of invalid oneOf + // inputs. Type-erase to boost::json::value when multiple string-like branches + // collapse and at least one has enum constraints (the constraint is the only + // thing that distinguishes otherwise-identical branches). anyOf keeps the + // string collapse (rule 2) since first-match is correct behavior. + if ("oneOf".equals(composedKeyword) && nonNullMeta.size() > 1) { + long preDedupStringCount = nonNullMeta.stream() + .filter(b -> b.isStringLike) + .count(); + long postDedupStringCount = deduped.stream() + .filter("std::string"::equals) + .count(); + boolean hasStringEnum = nonNullMeta.stream() + .anyMatch(b -> b.isStringLike && b.isEnum); + if (preDedupStringCount > postDedupStringCount && hasStringEnum) { + LOGGER.warn( + "oneOf string branches erase to std::string; " + + "emitting boost::json::value to avoid false exclusive-union fidelity"); + return "boost::json::value"; + } + } + + // Rule 7: Single branch after dedup (including oneOf alias chains that + // resolve to the same underlying C++ type without enum/open-string mix). + if (deduped.size() == 1) { + return deduped.get(0); + } + + // Rule 8: Emit std::variant + List variantBranches = new ArrayList<>(deduped); + boolean hasNull = branchTypes.stream().anyMatch("std::nullptr_t"::equals); + if (hasNull && deduped.size() > 1) { + variantBranches.add("std::nullptr_t"); + } + return "std::variant<" + String.join(", ", variantBranches) + ">"; + } + + /** Convenience overload for callers that only have C++ type strings. */ + private String lowerComposedTypesFromCppTypes(List branchTypes, String composedKeyword) { + List branches = new ArrayList<>(); + for (String t : branchTypes) { + boolean isString = "std::string".equals(t); + branches.add(new ComposedBranch(t, false, isString)); + } + return lowerComposedTypes(branches, composedKeyword); + } + + /** + * Resolves a type name transitively through the resolvedAliasTypes map. + * For example, if ModelIdsResponses → std::string and ModelIdsShared → std::string, + * then resolveThroughAliases("ModelIdsResponses") returns "std::string". + *

+ * Cycles are prevented via a visited set. Returns the typeName unmodified + * when no alias resolution applies. + */ + private String resolveThroughAliases(String typeName) { + if (typeName == null) { + return null; + } + Set visited = new HashSet<>(); + String current = typeName; + int maxDepth = 20; + for (int depth = 0; depth < maxDepth; depth++) { + String resolved = resolvedAliasTypes.get(current); + if (resolved == null || resolved.equals(current)) { + break; + } + if (!visited.add(current)) { + break; // cycle detected + } + current = resolved; + } + return current; + } + + /** + * Detects whether a schema is a null union (anyOf/oneOf with [T, null] or [null, T]) + * that should lower to std::optional<T>. Returns the lowered type string, + * or null if the schema is not a simple null union. + */ + private String detectNullUnion(Schema schema, String className) { + // Use raw List and cast explicitly because Schema is unparameterized. + List anyOfRaw = schema.getAnyOf(); + List oneOfRaw = schema.getOneOf(); + List branches = null; + if (anyOfRaw != null && !anyOfRaw.isEmpty()) { + branches = anyOfRaw; + } else if (oneOfRaw != null && !oneOfRaw.isEmpty()) { + branches = oneOfRaw; + } + if (branches == null) { + return null; + } + if (branches.size() != 2) { + return null; + } + + // Find the non-null branch using ModelUtils for correct null-type detection + // (handles both OAS 3.0 nullable and OAS 3.1 type: "null") + Schema nonNullBranch = null; + for (Object brObj : branches) { + Schema branch = (Schema) brObj; + if (!ModelUtils.isNullType(branch)) { + nonNullBranch = branch; + } + } + if (nonNullBranch == null) { + return null; // Both branches are null + } + // Verify exactly one null branch exists + long nullBranchCount = 0; + for (Object brObj : branches) { + if (ModelUtils.isNullType((Schema) brObj)) nullBranchCount++; + } + if (nullBranchCount != 1) { + return null; + } + + // Resolve the non-null branch type. For $ref schemas, resolve to model name. + String nonNullType; + if (nonNullBranch.get$ref() != null) { + nonNullType = ModelUtils.getSimpleRef(nonNullBranch.get$ref()); + } else { + nonNullType = getTypeDeclaration(nonNullBranch); + } + + // Avoid self-referencing optional (optional of the model itself) + if (nonNullType.equals(className)) { + return "boost::json::value"; + } + + return "std::optional<" + nonNullType + ">"; + } + + /** + * Recursively strips {@code std::shared_ptr} wrappers from a type string. + *

    + *
  • {@code std::shared_ptr} → {@code Foo}
  • + *
  • {@code std::vector>} → {@code std::vector}
  • + *
  • {@code std::map>} → {@code std::map}
  • + *
  • {@code std::string} → {@code std::string} (unchanged)
  • + *
+ */ + private static String stripSharedPtr(String type) { + if (type == null) { + return null; + } + // Direct std::shared_ptr wrapper — extract inner type and recurse. + if (type.startsWith("std::shared_ptr<") && type.endsWith(">")) { + return stripSharedPtr(type.substring(16, type.length() - 1)); + } + // Check for template arguments (contains '<' and '>'). + int firstLt = type.indexOf('<'); + int lastGt = type.lastIndexOf('>'); + if (firstLt > 0 && lastGt > firstLt) { + // Split arguments at commas at depth 0 (not inside nested angle brackets). + String prefix = type.substring(0, firstLt); + String argsSection = type.substring(firstLt + 1, lastGt); + List args = splitTemplateArgs(argsSection); + for (int i = 0; i < args.size(); i++) { + args.set(i, stripSharedPtr(args.get(i).trim())); + } + return prefix + "<" + String.join(", ", args) + ">"; + } + return type; + } + + /** + * Splits a comma-separated template argument list, respecting nested angle brackets. + * {@code "std::string, std::shared_ptr"} → {@code ["std::string", "std::shared_ptr"]} + */ + private static List splitTemplateArgs(String args) { + List result = new ArrayList<>(); + int depth = 0; + int start = 0; + for (int i = 0; i < args.length(); i++) { + char c = args.charAt(i); + if (c == '<') { + depth++; + } else if (c == '>') { + depth--; + } else if (c == ',' && depth == 0) { + result.add(args.substring(start, i)); + start = i + 1; + } + } + result.add(args.substring(start)); + return result; + } + /** * Camelize the method name of the getter and setter, but keep underscores at the front * @@ -227,11 +1380,158 @@ public String toModelImport(String name) { @Override public CodegenModel fromModel(String name, Schema model) { + // Pre-check: allOf scalar type conflicts — detect before the default + // pipeline consumes the composed schemas. + if (model != null && model.getAllOf() != null && !model.getAllOf().isEmpty()) { + List allOfPrimitiveTypes = new ArrayList<>(); + // Also track property-level conflicts: same property name with + // incompatible types across allOf branches. + Map allOfPropertyTypes = new HashMap<>(); + for (Object allOfItem : model.getAllOf()) { + if (allOfItem instanceof Schema) { + Schema itemSchema = (Schema) allOfItem; + String ref = itemSchema.get$ref(); + String resolvedType = null; + Schema resolvedRef = null; + // For $ref schemas, resolve to the target's type + if (ref != null) { + resolvedRef = (openAPI != null) + ? ModelUtils.getReferencedSchema(openAPI, itemSchema) + : null; + if (resolvedRef != null) { + resolvedType = resolvedRef.getType(); + } + } else { + resolvedType = itemSchema.getType(); + resolvedRef = itemSchema; + } + if (resolvedType != null && !"object".equals(resolvedType)) { + allOfPrimitiveTypes.add(resolvedType); + } + // Check property-level conflicts: scan each allOf branch's + // properties for name collisions with incompatible types. + Schema propsSource = ref != null ? resolvedRef : itemSchema; + if (propsSource != null && propsSource.getProperties() != null) { + for (Object propEntryObj : propsSource.getProperties().entrySet()) { + Map.Entry propEntry = (Map.Entry) propEntryObj; + String propName = propEntry.getKey(); + Schema propSchema = propEntry.getValue(); + String propType = propSchema.getType(); + if (propType == null) { + // For $ref properties, resolve the type + if (propSchema.get$ref() != null && openAPI != null) { + Schema refProp = ModelUtils.getReferencedSchema(openAPI, propSchema); + if (refProp != null) { + propType = refProp.getType(); + } + } + if (propType == null) { + propType = "unknown"; + } + } + if (allOfPropertyTypes.containsKey(propName)) { + String existingType = allOfPropertyTypes.get(propName); + if (!existingType.equals(propType) + && !"object".equals(propType) + && !"object".equals(existingType) + && !"unknown".equals(propType) + && !"unknown".equals(existingType)) { + throw new RuntimeException( + "allOf property type conflict in schema '" + name + + "': property '" + propName + "' has incompatible types [" + + existingType + ", " + propType + + "]. allOf with conflicting property types is not supported."); + } + } else { + allOfPropertyTypes.put(propName, propType); + } + } + } + } + } + if (allOfPrimitiveTypes.size() >= 2) { + String firstType = allOfPrimitiveTypes.get(0); + for (int i = 1; i < allOfPrimitiveTypes.size(); i++) { + if (!allOfPrimitiveTypes.get(i).equals(firstType)) { + throw new RuntimeException( + "allOf type conflict in schema '" + name + + "': branches have incompatible types [" + + String.join(", ", allOfPrimitiveTypes) + + "]. allOf with scalar type conflicts is not supported."); + } + } + } + } + + // Pre-check: The OpenAPI 3.1 parser converts anyOf [T, null] into + // {type: T, nullable: true} or {$ref: X, nullable: true}, consuming + // the anyOf list. Detect these nullable schemas and produce the + // correct std::optional type. + // + // For $ref schemas (normalised anyOf/oneOf [T, null] where T was a + // $ref), getTypeDeclaration resolves the target and returns the + // correct C++ type. For arrays, getTypeDeclaration returns the + // container type (e.g. std::vector<...>) without optional wrapping, + // so we wrap it here. Inline object schemas (type=object, no $ref) + // are full class models — they stay out of the alias precomputation + // because getTypeDeclaration would return the raw OAS type name + // "object" instead of the model name. They are handled separately + // below via variant model registration. + boolean isNullableSchema = model != null + && Boolean.TRUE.equals(model.getNullable()) + && (model.get$ref() != null + || (model.getType() != null && !"object".equals(model.getType()))); + String preComputedNullUnionType = null; + if (isNullableSchema) { + // Resolve the type to its C++ type and wrap in std::optional + String innerType = getTypeDeclaration(model); + // getTypeDeclaration already returns std::optional for nullable. + // Use it directly if it starts with std::optional<. + if (innerType.startsWith("std::optional<")) { + preComputedNullUnionType = innerType; + } else { + preComputedNullUnionType = "std::optional<" + innerType + ">"; + } + } else if (model != null) { + // Also try the anyOf/oneOf path for cases where the parser + // preserved the composed schema structure. + preComputedNullUnionType = detectNullUnion(model, name); + } + CodegenModel codegenModel = super.fromModel(name, model); if (codegenModel == null) { return null; } + // Post-check: Apply the pre-computed null union type if the default + // pipeline consumed the composed schemas. + if (preComputedNullUnionType != null) { + codegenModel.dataType = preComputedNullUnionType; + codegenModel.vendorExtensions.put("x-cpp-type", preComputedNullUnionType); + codegenModel.vendorExtensions.put("x-cpp-composed-keyword", + model.getAnyOf() != null ? "anyOf" : "oneOf"); + codegenModel.vendorExtensions.put("x-cpp-is-alias", true); + codegenModel.vendorExtensions.put("x-cpp-is-optional", true); + // Force a model header/source so Gate A inventory and $ref users get + // `using NullableString = std::optional;`. DefaultCodegen + // marks plain nullable primitives as isAlias and skips file emission. + codegenModel.isAlias = false; + resolvedAliasTypes.put(name, preComputedNullUnionType); + variantModels.add(name); + } + + // Post-check: Inline nullable object schemas (type=object, nullable=true, + // no $ref) are full class models with properties — they cannot use the + // alias path. Register them as variant models so $ref references use value + // semantics (std::shared_ptr → NullableObject) and tag + // the model as optional for correct null-value representation. + if (model != null && model.get$ref() == null + && "object".equals(model.getType()) + && Boolean.TRUE.equals(model.getNullable())) { + variantModels.add(name); + codegenModel.vendorExtensions.put("x-cpp-is-optional", true); + } + Set oldImports = codegenModel.imports; codegenModel.imports = new HashSet<>(); for (String imp : oldImports) { @@ -242,6 +1542,82 @@ public CodegenModel fromModel(String name, Schema model) { } // Every model header declares vector conversion helpers. codegenModel.imports.add("#include "); + + // Fixed-const properties: OAS 3.1 `const`, single-value `enum`, or optional + // vendor extension `x-stainless-const`. Portable path is OAS `const` / single enum — + // vendor extensions are never required for correct encode/decode. + if (codegenModel.vars != null) { + Map allProps = new LinkedHashMap<>(); + if (model.getProperties() != null) { + allProps.putAll(model.getProperties()); + } + if (model.getAllOf() != null && openAPI != null) { + for (Object parentObj : model.getAllOf()) { + if (parentObj instanceof Schema) { + Schema parentSchema = ModelUtils.getReferencedSchema(openAPI, (Schema) parentObj); + if (parentSchema != null && parentSchema.getProperties() != null) { + allProps.putAll(parentSchema.getProperties()); + } + } + } + } + for (CodegenProperty var : codegenModel.vars) { + Object rawProp = allProps.get(var.baseName); + if (!(rawProp instanceof Schema)) { + continue; + } + Schema varSchema = (Schema) rawProp; + boolean hasOasConst = varSchema.getConst() != null; + boolean hasSingleValueEnum = varSchema.getEnum() != null + && varSchema.getEnum().size() == 1; + boolean hasStainlessConst = varSchema.getExtensions() != null + && Boolean.TRUE.equals(varSchema.getExtensions().get("x-stainless-const")); + if (!(hasOasConst || hasSingleValueEnum || hasStainlessConst)) { + continue; + } + String constRawValue = null; + if (varSchema.getConst() != null) { + constRawValue = varSchema.getConst().toString(); + } else if (varSchema.getEnum() != null && !varSchema.getEnum().isEmpty()) { + constRawValue = varSchema.getEnum().get(0).toString(); + } + if (constRawValue == null && var.example != null) { + constRawValue = var.example; + } + if (constRawValue == null) { + constRawValue = "std::string".equals(var.dataType) ? "" : "0"; + } + String inlineValue; + boolean isStringConst = "std::string".equals(var.dataType) + || "std::optional".equals(var.dataType) + || (var.isString && !var.isInteger && !var.isLong && !var.isNumber + && !var.isBoolean); + if ("std::optional".equals(var.dataType)) { + inlineValue = "std::optional{\"" + constRawValue + "\"}"; + } else if (isStringConst || "std::string".equals(var.dataType)) { + inlineValue = "\"" + constRawValue + "\""; + } else { + inlineValue = constRawValue; + } + // Neutral OAS-first flag used by templates. + var.vendorExtensions.put("x-cpp-const", true); + var.vendorExtensions.put("x-cpp-const-value", constRawValue); + var.vendorExtensions.put("x-cpp-const-inline-value", inlineValue); + // Mustache is truthy on key presence — only set when string-typed. + if (isStringConst || "std::string".equals(var.dataType) + || "std::optional".equals(var.dataType)) { + var.vendorExtensions.put("x-cpp-const-is-string", true); + } else if (var.isBoolean || "bool".equals(var.dataType) + || "std::optional".equals(var.dataType)) { + var.vendorExtensions.put("x-cpp-const-is-boolean", true); + } + // Keep stainless keys as aliases so older template forks still work. + var.vendorExtensions.put("x-stainless-const", true); + var.vendorExtensions.put("x-stainless-const-value", constRawValue); + var.vendorExtensions.put("x-stainless-const-inline-value", inlineValue); + } + } + addContainerPropertyNames(codegenModel.vars); return codegenModel; } @@ -394,6 +1770,10 @@ public OperationsMap postProcessOperationsWithModels(OperationsMap objs, List. + // For dual-content ops (JSON + SSE), the operation-level flag is NOT + // set (return type stays JSON). Instead, a dedicated stream method is + // generated ({operationId}Stream) that sets Accept to text/event-stream + // and returns std::vector via incremental event conversion. + // Note: Dual-content detection is driven by produces media types, not + // by the presence of a "stream" query parameter (the parameter is + // a client-side convention for choosing between JSON and SSE). + if (operation.produces != null && !operation.produces.isEmpty()) { + boolean hasEventStream = false; + boolean hasJsonStream = false; + for (Map produce : operation.produces) { + String mediaType = produce.get("mediaType"); + if ("text/event-stream".equalsIgnoreCase(mediaType)) { + hasEventStream = true; + } else if (mediaType != null && mediaType.contains("json")) { + hasJsonStream = true; + } + } + boolean isPureSse = hasEventStream && !hasJsonStream; + boolean isDualContent = hasEventStream && hasJsonStream; + operation.vendorExtensions.put("x-codegen-streaming-response", isPureSse); + // For pure SSE ops, flag all 2xx responses as streaming and + // set the stripped element type (without shared_ptr) for use in + // the event vector element and converter name. + // For dual-content ops, mark SSE responses (different datatype from returnType) + // as streaming so the stream method template can identify them. + // Also mark each response with x-codegen-return-compatible so the normal + // method template can skip responses whose dataType doesn't match the + // operation return type (avoids type mismatch in deserializedResponse). + for (CodegenResponse response : operation.responses) { + if (isPureSse) { + response.vendorExtensions.put("x-codegen-streaming-response", true); + if (isOneOfResponse(response) + || isOneOfMediaType(response, "text/event-stream")) { + operation.vendorExtensions.put(X_CODEGEN_STREAM_IS_ONE_OF, true); + } + if (response.dataType != null) { + String streamElementType = stripSharedPtr(response.dataType); + // Only set element type for model types (uppercase first char). + // Primitives like std::string don't have fromJsonValue_ free + // functions and would produce invalid identifiers like + // fromJsonValue_std::string. + if (!streamElementType.startsWith("std::") && !streamElementType.startsWith("boost::") + && Character.isUpperCase(streamElementType.charAt(0))) { + response.vendorExtensions.put("x-codegen-stream-element-type", + streamElementType); + // Propagate to operation level for use outside {{#responses}} scope + operation.vendorExtensions.put("x-codegen-stream-element-type", + streamElementType); + } + } + } else if (isDualContent && response.is2xx && response.dataType != null + && !response.dataType.equals(operation.returnType)) { + response.vendorExtensions.put("x-codegen-streaming-response", true); + if (response.dataType != null) { + String streamElementType = stripSharedPtr(response.dataType); + response.vendorExtensions.put("x-codegen-stream-element-type", + streamElementType); + } + } + } + // If a pure SSE operation has no response schema (no data type + // on any 2xx response), returnType will be null and the + // mustache template would produce std::vector, which + // is invalid C++. Clear the streaming flag so the normal + // non-streaming void path is used instead. + if (isPureSse && operation.returnType == null) { + operation.vendorExtensions.put("x-codegen-streaming-response", false); + for (CodegenResponse r : operation.responses) { + r.vendorExtensions.put("x-codegen-streaming-response", false); + } + } + // Dual-content: generate stream method + // Only emit the stream method if we can resolve a concrete SSE + // element type from the response content. Without it, the template + // would produce an invalid std::vector<> with an empty parameter. + if (isDualContent) { + // Resolve SSE response type from the response content media-type map. + // Specs may expose a single 200 with both application/json and + // text/event-stream. Look for text/event-stream in any 2xx response. + String sseReturnType = null; + String sseBaseModelName = null; + for (CodegenResponse response : operation.responses) { + if (!response.is2xx || response.getContent() == null) continue; + CodegenMediaType sseMediaType = response.getContent().get("text/event-stream"); + if (sseMediaType != null && sseMediaType.getSchema() != null) { + CodegenProperty sseSchema = sseMediaType.getSchema(); + String rawType = sseSchema.dataType; + if (rawType != null) { + sseReturnType = rawType; + // Derive a valid C++ identifier for the fromJsonValue_ converter. + // Strip std::shared_ptr wrapper down to just X. + sseBaseModelName = stripSharedPtr(rawType); + if (isOneOfSchema(sseSchema)) { + operation.vendorExtensions.put(X_CODEGEN_DUAL_STREAM_IS_ONE_OF, true); + } + break; + } + } + } + // Fallback: use response dataType (works for split-status fixtures) + if (sseReturnType == null) { + for (CodegenResponse response : operation.responses) { + if (response.is2xx && response.dataType != null + && !response.dataType.equals(operation.returnType)) { + sseReturnType = response.dataType; + sseBaseModelName = stripSharedPtr(response.dataType); + break; + } + } + } + if (sseReturnType == null) { + // Final fallback: first 2xx response + for (CodegenResponse response : operation.responses) { + if (response.is2xx && response.dataType != null) { + sseReturnType = response.dataType; + sseBaseModelName = stripSharedPtr(response.dataType); + break; + } + } + } + if (sseReturnType != null && sseBaseModelName != null) { + if (isOneOfType(sseReturnType)) { + operation.vendorExtensions.put(X_CODEGEN_DUAL_STREAM_IS_ONE_OF, true); + } + operation.vendorExtensions.put("x-codegen-dual-content", true); + // Full C++ type for the vector element (may contain std::shared_ptr<...>) + operation.vendorExtensions.put("x-codegen-dual-stream-return-type", sseReturnType); + // Stripped base name (valid C++ identifier) for fromJsonValue_ converter + operation.vendorExtensions.put("x-codegen-dual-stream-base-name", sseBaseModelName); + // Stripped element type for event conversion and the vector element + // (same as base name since both strip shared_ptr, but semantically distinct) + String dualStreamElementType = stripSharedPtr(sseReturnType); + operation.vendorExtensions.put("x-codegen-dual-stream-element-type", dualStreamElementType); + // Also propagate to each response so the template can access it + // from within the {{#responses}} context scope. + for (CodegenResponse response : operation.responses) { + response.vendorExtensions.put("x-codegen-dual-stream-return-type", sseReturnType); + response.vendorExtensions.put("x-codegen-dual-stream-base-name", sseBaseModelName); + response.vendorExtensions.put("x-codegen-dual-stream-element-type", dualStreamElementType); + } + } + } + } + } + + private boolean isOneOfResponse(CodegenResponse response) { + if (response.getContent() != null) { + for (Map.Entry contentEntry : response.getContent().entrySet()) { + String mediaType = contentEntry.getKey(); + CodegenMediaType codegenMediaType = contentEntry.getValue(); + if (mediaType != null && mediaType.toLowerCase(Locale.ROOT).contains("json") + && codegenMediaType != null && isOneOfSchema(codegenMediaType.getSchema())) { + return true; + } + } + } + return isOneOfType(response.dataType); + } + + private boolean isOneOfMediaType(CodegenResponse response, String mediaType) { + if (response.getContent() == null) { + return false; + } + CodegenMediaType codegenMediaType = response.getContent().get(mediaType); + return codegenMediaType != null && isOneOfSchema(codegenMediaType.getSchema()); + } + + private boolean isOneOfSchema(CodegenProperty schema) { + return schema != null + && (Boolean.TRUE.equals(schema.vendorExtensions.get("x-cpp-is-oneof")) + || isOneOfType(schema.dataType)); + } + + private boolean isOneOfType(String dataType) { + String unwrappedType = stripSharedPtr(dataType); + return "oneOf".equals(composedKeywordsByModel.get(unwrappedType)); } /** @@ -420,26 +1982,52 @@ private void addApiResponseMetadata(CodegenOperation operation) { */ @Override public String getTypeDeclaration(Schema p) { + // Handle inline oneOf/anyOf composed schemas (apply lowering rules directly) + if (ModelUtils.isComposedSchema(p) && (p.getOneOf() != null || p.getAnyOf() != null)) { + return lowerInlineComposedSchema(p); + } + String openAPIType = getSchemaType(p); if (ModelUtils.isArraySchema(p)) { // Use getItems() directly to handle both OpenAPI 3.0 and 3.1 Schema inner = p.getItems(); + String arrayType; if (inner != null) { - return getSchemaType(p) + "<" + getTypeDeclaration(inner) + ">"; + arrayType = getSchemaType(p) + "<" + getTypeDeclaration(inner) + ">"; + } else { + arrayType = "std::vector"; + } + // Nullable arrays must be wrapped in std::optional so null JSON + // values are representable. The array branch returns before the + // nullable fallback checks at the end of this method. + if (ModelUtils.isNullable(p)) { + return "std::optional<" + arrayType + ">"; } - return "std::vector"; + return arrayType; } else if (ModelUtils.isMapSchema(p)) { Schema inner = ModelUtils.getAdditionalProperties(p); String innerType = inner == null ? "boost::json::value" : getTypeDeclaration(inner); - return getSchemaType(p) + ""; + String mapType = getSchemaType(p) + ""; + // Nullable maps must be wrapped in std::optional so null JSON + // values are representable. The map branch returns before the + // nullable fallback checks at the end of this method. + if (ModelUtils.isNullable(p)) { + return "std::optional<" + mapType + ">"; + } + return mapType; } else if (ModelUtils.isByteArraySchema(p)) { return "std::string"; } else if (ModelUtils.isStringSchema(p) || ModelUtils.isDateSchema(p) || ModelUtils.isDateTimeSchema(p) || ModelUtils.isFileSchema(p) || languageSpecificPrimitives.contains(openAPIType)) { - return toModelName(openAPIType); + String resolved = toModelName(openAPIType); + // OAS 3.0 nullable: true → std::optional + if (ModelUtils.isNullable(p)) { + return "std::optional<" + resolved + ">"; + } + return resolved; } else if (ModelUtils.isNullType(p)) { // Handle OpenAPI 3.1 null type return "std::nullptr_t"; @@ -447,9 +2035,97 @@ public String getTypeDeclaration(Schema p) { return "boost::json::value"; } + // OAS 3.0 nullable: true → std::optional + if (ModelUtils.isNullable(p)) { + return "std::optional<" + openAPIType + ">"; + } + + // Variant models use value semantics (no shared_ptr wrapping) + if (variantModels.contains(openAPIType)) { + return openAPIType; + } + + // Fallback: wrap in shared_ptr for all other model refs. + // NOTE (Phase 1 scope): "shared_ptr only for cycles" is a planned follow-up. + // Determining cycle safety requires circular-reference analysis (setCircularReferences) + // which runs too late in the pipeline for getTypeDeclaration. Variant models (oneOf/anyOf) + // are treated as value types via variantModels. For regular object refs, we conservatively + // use shared_ptr — this will be narrowed to cycle-only in a later phase. return "std::shared_ptr<" + openAPIType + ">"; } + /** + * Resolves an inline oneOf/anyOf schema to its lowered C++ type by computing + * branch types and applying the same ordered lowering rules as model-level types. + */ + private String lowerInlineComposedSchema(Schema p) { + String composedKeyword; + List children; + if (p.getOneOf() != null) { + children = p.getOneOf(); + composedKeyword = "oneOf"; + } else { + children = p.getAnyOf(); + composedKeyword = "anyOf"; + } + + List composedBranches = new ArrayList<>(); + for (Schema child : children) { + // Compute the branch type using the full type declaration pipeline + // but strip shared_ptr for variant members (value semantics). + String childType = stripSharedPtr(getTypeDeclaration(child)); + // Resolve $ref targets that are aliased to primitive types at the + // declaration point, before resolvedAliasTypes is available (it is + // populated during postProcessModels, which runs later). This handles + // inline schemas like CreateAssistantRequest_model = oneOf [string, + // $ref AssistantSupportedModels] where the target is anyOf [string, + // string-enum] → std::string, collapsing to just std::string. + Schema resolvedChild = child; + if (!childType.startsWith("std::") && !childType.startsWith("boost::") + && !childType.startsWith("std::shared_ptr<")) { + Schema resolvedTarget = child.get$ref() != null && openAPI != null + ? ModelUtils.getReferencedSchema(openAPI, child) : null; + if (resolvedTarget != null) { + resolvedChild = resolvedTarget; + String resolved = getTypeDeclaration(resolvedTarget); + String stripped = stripSharedPtr(resolved); + if (!stripped.equals(childType)) { + childType = stripped; + } + } + } + boolean isEnum = resolvedChild.getEnum() != null && !resolvedChild.getEnum().isEmpty(); + boolean isStringLike = ModelUtils.isStringSchema(resolvedChild) + || "std::string".equals(childType); + composedBranches.add(new ComposedBranch(childType, isEnum, isStringLike)); + } + + // Deduplication is deferred to lowerComposedTypes (step 5) so that + // oneOf semantics can be preserved when duplicate types would otherwise + // cause silent single-branch collapse. + return lowerComposedTypes(composedBranches, composedKeyword); + } + + @Override + public CodegenProperty fromProperty(String name, Schema p, boolean required, + boolean schemaIsFromAdditionalProperties) { + CodegenProperty prop = super.fromProperty(name, p, required, schemaIsFromAdditionalProperties); + if (prop == null || p == null) { + return prop; + } + // Tag inline composed properties so templates can honor oneOf vs anyOf + // decode rules (exactly-one vs first-match) instead of always using + // the generic JsonValueConverter exactly-one path. + if (p.getOneOf() != null && !p.getOneOf().isEmpty()) { + prop.vendorExtensions.put("x-cpp-composed-keyword", "oneOf"); + prop.vendorExtensions.put("x-cpp-is-oneof", true); + } else if (p.getAnyOf() != null && !p.getAnyOf().isEmpty()) { + prop.vendorExtensions.put("x-cpp-composed-keyword", "anyOf"); + prop.vendorExtensions.put("x-cpp-is-anyof", true); + } + return prop; + } + @Override public String toDefaultValue(Schema p) { if (ModelUtils.isStringSchema(p)) { @@ -520,7 +2196,11 @@ public String toDefaultValue(Schema p) { String innerType = inner != null ? getTypeDeclaration(inner) : "boost::json::value"; return "std::vector<" + innerType + ">()"; } else if (!StringUtils.isEmpty(p.get$ref())) { - return "std::make_shared<" + toModelName(ModelUtils.getSimpleRef(p.get$ref())) + ">()"; + String refName = toModelName(ModelUtils.getSimpleRef(p.get$ref())); + if (variantModels.contains(refName)) { + return refName + "()"; + } + return "std::make_shared<" + refName + ">()"; } else if (ModelUtils.isNullType(p)) { return "nullptr"; } else if (ModelUtils.isAnyType(p) || ModelUtils.isFreeFormObject(p, openAPI)) { @@ -557,9 +2237,51 @@ public void postProcessParameter(CodegenParameter parameter) { if (!isPrimitiveType && !isArray && !isMap && !isString && !parameter.dataType.startsWith("std::shared_ptr") && !"boost::json::value".equals(parameter.dataType) - && !"std::nullptr_t".equals(parameter.dataType)) { - parameter.dataType = "std::shared_ptr<" + parameter.dataType + ">"; - parameter.defaultValue = "std::make_shared<" + parameter.dataType + ">()"; + && !"std::nullptr_t".equals(parameter.dataType) + && !parameter.dataType.startsWith("std::variant<") + && !parameter.dataType.startsWith("std::optional<") + && !"std::monostate".equals(parameter.dataType)) { + // Wrap non-primitive types in shared_ptr, unless: + // - The type is a variant/optional model (value semantics) + // - The type is a known variant model name from composed schemas + if (!variantModels.contains(parameter.dataType)) { + parameter.dataType = "std::shared_ptr<" + parameter.dataType + ">"; + parameter.defaultValue = "std::make_shared<" + parameter.dataType + ">()"; + } + } + + // Post-hoc unwrap: if the type ended up as std::shared_ptr, + // strip the shared_ptr wrapper (value semantics for variant types). + if (parameter.dataType != null && parameter.dataType.startsWith("std::shared_ptr<") + && parameter.dataType.endsWith(">")) { + String innerType = parameter.dataType.substring(16, parameter.dataType.length() - 1); + if (variantModels.contains(innerType)) { + parameter.dataType = innerType; + parameter.defaultValue = null; + } + } + + // Tag variant form params for branch-aware multipart serialization. + // When a form parameter's type is a variant, the template uses + // addVariantFormParameter to dispatch binary branches as file parts + // and object branches as JSON parts. + // Only set for actual std::variant types, not for models that alias + // to primitive types (e.g., VideoModel → std::string), which would + // cause instantiation of addVariantFormParameter and + // an invalid std::visit call on a non-variant type. + boolean isVariantParam = false; + if (parameter.isFormParam && parameter.dataType != null) { + if (parameter.dataType.startsWith("std::variant<")) { + isVariantParam = true; + } else if (variantModels.contains(parameter.dataType)) { + String resolved = resolveThroughAliases(parameter.dataType); + if (resolved != null && resolved.startsWith("std::variant<")) { + isVariantParam = true; + } + } + } + if (isVariantParam) { + parameter.vendorExtensions.put("x-codegen-is-variant-form-param", true); } } @@ -572,6 +2294,12 @@ public void postProcessParameter(CodegenParameter parameter) { */ @Override public String getSchemaType(Schema p) { + // Non-standard format (NOT core OAS vocabulary). Documented generator + // convenience for corpora that use Unix-epoch integer timestamps. + // Disable by not using format: unixtime in the source document. + if (p != null && "unixtime".equals(p.getFormat())) { + return "int64_t"; + } String openAPIType = super.getSchemaType(p); String type = null; String modelName; diff --git a/modules/openapi-generator/src/main/resources/cpp-boost-beast-client/CMakeLists.txt.mustache b/modules/openapi-generator/src/main/resources/cpp-boost-beast-client/CMakeLists.txt.mustache index d9537583c40c..7d0e32c77920 100644 --- a/modules/openapi-generator/src/main/resources/cpp-boost-beast-client/CMakeLists.txt.mustache +++ b/modules/openapi-generator/src/main/resources/cpp-boost-beast-client/CMakeLists.txt.mustache @@ -49,7 +49,7 @@ endif () add_library(${PROJECT_NAME} SHARED) -set_property(TARGET ${PROJECT_NAME} PROPERTY CXX_STANDARD 11) +set_property(TARGET ${PROJECT_NAME} PROPERTY CXX_STANDARD 17) set_property(TARGET ${PROJECT_NAME} PROPERTY CXX_STANDARD_REQUIRED ON) set_property(TARGET ${PROJECT_NAME} PROPERTY CXX_EXTENSIONS OFF) diff --git a/modules/openapi-generator/src/main/resources/cpp-boost-beast-client/README.mustache b/modules/openapi-generator/src/main/resources/cpp-boost-beast-client/README.mustache index e69de29bb2d1..74546c76f119 100644 --- a/modules/openapi-generator/src/main/resources/cpp-boost-beast-client/README.mustache +++ b/modules/openapi-generator/src/main/resources/cpp-boost-beast-client/README.mustache @@ -0,0 +1,235 @@ +# {{packageName}} + +{{#appDescriptionWithNewLines}}{{{.}}}{{/appDescriptionWithNewLines}} + +Generated by [OpenAPI Generator](https://openapi-generator.tech). +{{#generatorVersion}}Generator version: {{generatorVersion}}{{/generatorVersion}} + +- API version: {{appVersion}} +{{^hideGenerationTimestamp}}- Build date: {{generatedDate}}{{/hideGenerationTimestamp}} +- API namespace: `{{{apiPackage}}}` +- Model namespace: `{{{modelPackage}}}` + +--- + +## Architecture + +This client uses **variant-first composed-schema lowering** — a deliberate departure +from the default OpenAPI Generator composed-schema handling. + +### Breaking model strategy + +The generator treats composed schemas (`oneOf`, `anyOf`, `allOf`) as first-class +C++ types rather than empty object shells. This is a **breaking change**: generated +model headers, source, and API surfaces are fundamentally different from the previous +output. + +**The break is intentional and acceptable.** No known downstream project depends on +the prior composed-model output for `cpp-boost-beast-client`. The generator stability +label stays STABLE because the generated output targets a specific C++ client; the +prior output for composed schemas was non-functional (empty structs that could not +hold union data). + +| Before | After | +|--------|-------| +| Empty struct `AnyOfStringArray {}` | `std::variant>` | +| Composed → `{}` on wire | Correct encode/decode via `toJsonValue`/`fromJsonValue` | +| Polymorphism disabled | `oneOf`/`anyOf`/`allOf` fully supported | + +### Ordered composition lowering + +The generator applies the following rules in order to every composed schema: + +1. **Merge compatible `allOf` object composition** through generated inheritance and property aggregation. Conflict → error. +2. **Lower `anyOf`/`oneOf` null unions** with a single non-null branch to `std::optional` (and OAS 3.0 `nullable`). +3. **Collapse `anyOf` of only string and string-enums to `std::string`.** + Does **not** apply this collapse to `oneOf` unless exclusivity is proven. +4. **Collapse single remaining non-null branch** to that branch type. +5. **Deduplicate identical C++ branch types** inside variants. +6. **Else emit `std::variant`**. +7. **If still untyped/open**, emit `boost::json::value`. + +### OpenAPI → C++ type mapping + +| OpenAPI shape | C++ type | +|---------------|----------| +| `string` | `std::string` | +| `integer`/`int32` | `std::int32_t` | +| `integer`/`int64` | `std::int64_t` | +| `number` | `double` | +| `boolean` | `bool` | +| `anyOf: [T, null]` / nullable `T` | `std::optional` | +| `oneOf` / non-null `anyOf` unions | `std::variant<...>` | +| `allOf` object | Inherited/merged value class | +| array | `std::vector` | +| map / `additionalProperties` | `std::map` | +| free-form JSON | `boost::json::value` | +| object struct | Value class | +| cyclic edge | `std::shared_ptr` | +| `enum` string | `enum class` + string conversion | +| Discriminator literal | Fixed on encode when branch schema declares a `const` (OAS 3.1) or single-value `enum`; otherwise not injected | + +--- + +## Nullability: omit vs null policy + +### Current scope (Phase 2) + +The generated encode logic uses a **two-state model** for optional fields: + +| Field kind | Encode behavior | +|-----------|----------------| +| Non-required, disengaged optional / isSet=false | **Key omitted** from JSON output | +| Non-required, engaged optional / isSet=true | Key written with converted value | +| Required, nullable, `std::nullopt` | Key written as JSON `null` | +| Required, nullable, has value | Key written with converted value | + +For non-required fields, the current implementation omits the key when the +optional is disengaged. The `JsonValueConverter>::toJsonValue` +helper unwraps the inner value; the outer `has_value()` check at the call site +prevents unreachable `null` output for absent optionals. + +### Deferred: full tri-state + +A full **tri-state model** (field absent / field present with JSON null / field +present with JSON value) is **not yet implemented**. When the schema allows null, +the current implementation cannot distinguish "field was sent as JSON null" from +"field was absent" for non-required properties — both result in the key being +omitted on encode and `std::nullopt` on decode. + +A future phase may add a separate `null_state` flag per nullable field to +preserve the absent-vs-null distinction on the wire, matching OAS 3.1's +semantic model where a missing key and a present `null` value are distinct. + +--- + +## Compliance testing + +Composition lowering is covered by unit tests and OAS fixture specs under: + +```text +modules/openapi-generator/src/test/resources/3_1/cpp-boost-beast-client/ +modules/openapi-generator/src/test/resources/3_1/cpp-boost-beast-client/oas-compliance/ +``` + +Run: + +```sh +./mvnw -pl modules/openapi-generator \ + -Dtest=CppBoostBeastClientCodegenTest,CppBoostBeastClientApiCodegenTest test +``` + +--- + +## Media-type-driven streaming (not `stream` flags) + +OpenAPI models streaming via the response `content` map, not via boolean parameters: + +```yaml +responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/Response' + text/event-stream: + schema: + $ref: '#/components/schemas/ResponseStreamEvent' +``` + +The generator **discovers** media types and schemas from the `content` map. This +includes same-status multi-content operations where one media type is +`text/event-stream` and another is `application/json`. + +### Required behavior + +1. **Client selection** — The caller chooses representation via the `Accept` header. + Non-streaming path sets `Accept: application/json`; streaming path sets + `Accept: text/event-stream`. +2. **SSE decoding** — When `text/event-stream` is selected, the generator emits + an event-stream parser that decodes each SSE `data:` line using the + schema from the `text/event-stream` media type entry (often a `oneOf` union). +3. **Transport** — Streaming operations use `HttpClient::executeStream`, which + reads the response body incrementally and delivers complete SSE events + (WHATWG-framed `data` payloads) via callback as they arrive. + +### Explicitly forbidden as core OAS behavior + +- Hard-coding parameter name `stream` or operationId patterns from any single API. +- Assuming event type names (`ResponseStreamEvent`, etc.) in the engine. +- Treating dual media-type content as vendor-specific. + +Parameter properties named `stream` in the OpenAPI document are ordinary schema +parameters — not OAS keywords. The generator does not special-case them. + +### Incremental SSE transport + +`HttpClient::executeStream` performs incremental `async_read_some` reads and +frames Server-Sent Events per the WHATWG specification. Each callback receives +one complete event's data payload (multi-line `data:` fields joined by LF). +Generated streaming operations parse each event as it is framed. Incomplete +events at EOF are discarded. + +--- + +## Non-standard format mappings + +The generator documents non-standard format mappings as explicit type-mapping +entries, not as core OAS vocabulary. + +| Format | OAS status | C++ type | Notes | +|--------|-----------|----------|-------| +| `unixtime` | **Non-standard** (not in OAS format registry) | `std::int64_t` | Optional generator convenience in `getSchemaType` when `format: unixtime` appears. Not core OAS vocabulary. | + +This mapping is a **documented non-standard convenience** for APIs that encode +Unix-epoch seconds as integers. It does **not** imply that `unixtime` is a +standard OAS format. Specs that omit `format: unixtime` are unaffected. Any API +using a different time representation (ISO 8601 strings, millisecond epochs, +etc.) must define its own schema. + +--- + +## Large breaking PR is acceptable + +This work ships as a single, large, explicitly-breaking pull request that: + +1. Eliminates the prior empty-shell composed-model output entirely. +2. Replaces it with `std::variant` / `std::optional` / collapsed types. +3. Updates every mustache template for models and API operations. +4. Adds OAS composition fixtures and unit tests for lowering rules. +5. Adds media-type-driven streaming support. + +**No attempt is made to preserve backward compatibility with prior generated +output.** The prior output was non-functional for composed schemas, and no +known downstream project depends on it. The breaking change is confined to +a single generator (`cpp-boost-beast-client`) and does not affect any other +OpenAPI Generator language target. + +Reviewers should evaluate the PR against: +- **OAS compliance** — do the generated types and encode/decode faithfully + represent the OpenAPI specification's composition rules? +- **Fixture coverage** — does every lowering rule have dedicated unit/fixture + coverage with green tests? + +--- + +## Requirements + +- C++17 compiler (GCC 7+, Clang 8+, MSVC 2019+) +- CMake 3.14+ +- Boost 1.75+ (Beast, Asio, JSON) +- OpenSSL 1.1.0+ + +## Build + +```sh +mkdir build && cd build +cmake .. -DCMAKE_BUILD_TYPE=Release +cmake --build . +``` + +## Installation + +```sh +cmake --install . +``` diff --git a/modules/openapi-generator/src/main/resources/cpp-boost-beast-client/api-header.mustache b/modules/openapi-generator/src/main/resources/cpp-boost-beast-client/api-header.mustache index 2fef7617e314..99579f43123b 100644 --- a/modules/openapi-generator/src/main/resources/cpp-boost-beast-client/api-header.mustache +++ b/modules/openapi-generator/src/main/resources/cpp-boost-beast-client/api-header.mustache @@ -65,10 +65,19 @@ public: /// /// {{notes}} /// - virtual {{#returnType}}{{{.}}}{{/returnType}}{{^returnType}}void{{/returnType}} + virtual {{#vendorExtensions.x-codegen-streaming-response}}{{#vendorExtensions.x-codegen-stream-element-type}}std::vector<{{{.}}}>{{/vendorExtensions.x-codegen-stream-element-type}}{{^vendorExtensions.x-codegen-stream-element-type}}{{#returnType}}std::vector<{{{.}}}>{{/returnType}}{{^returnType}}void{{/returnType}}{{/vendorExtensions.x-codegen-stream-element-type}}{{/vendorExtensions.x-codegen-streaming-response}}{{^vendorExtensions.x-codegen-streaming-response}}{{#returnType}}{{{.}}}{{/returnType}}{{^returnType}}void{{/returnType}}{{/vendorExtensions.x-codegen-streaming-response}} {{#operationId}}{{{.}}}{{/operationId}}{{^operationId}}{{httpMethod}}_{{vendorExtensions.x-codegen-resource-name}}{{/operationId}}( {{#allParams}}const {{#vendorExtensions.x-codegen-is-optional-query-parameter}}boost::optional<{{/vendorExtensions.x-codegen-is-optional-query-parameter}}{{{dataType}}}{{#vendorExtensions.x-codegen-is-optional-query-parameter}}>{{/vendorExtensions.x-codegen-is-optional-query-parameter}}& {{{paramName}}}{{^-last}}, {{/-last}}{{/allParams}}); + {{#vendorExtensions.x-codegen-dual-content}} + /// + /// {{summary}} (streaming) + /// + virtual std::vector<{{#vendorExtensions.x-codegen-dual-stream-element-type}}{{{.}}}{{/vendorExtensions.x-codegen-dual-stream-element-type}}{{^vendorExtensions.x-codegen-dual-stream-element-type}}{{{vendorExtensions.x-codegen-dual-stream-return-type}}}{{/vendorExtensions.x-codegen-dual-stream-element-type}}> + {{#operationId}}{{{.}}}{{/operationId}}Stream( + {{#allParams}}const {{#vendorExtensions.x-codegen-is-optional-query-parameter}}boost::optional<{{/vendorExtensions.x-codegen-is-optional-query-parameter}}{{{dataType}}}{{#vendorExtensions.x-codegen-is-optional-query-parameter}}>{{/vendorExtensions.x-codegen-is-optional-query-parameter}}& {{{paramName}}}{{^-last}}, {{/-last}}{{/allParams}}); + + {{/vendorExtensions.x-codegen-dual-content}} {{#vendorExtensions.x-codegen-other-methods}} /// /// {{summary}} @@ -76,7 +85,7 @@ public: /// /// {{notes}} /// - virtual {{#returnType}}{{{.}}}{{/returnType}}{{^returnType}}void{{/returnType}} + virtual {{#vendorExtensions.x-codegen-streaming-response}}{{#vendorExtensions.x-codegen-stream-element-type}}std::vector<{{{.}}}>{{/vendorExtensions.x-codegen-stream-element-type}}{{^vendorExtensions.x-codegen-stream-element-type}}{{#returnType}}std::vector<{{{.}}}>{{/returnType}}{{^returnType}}void{{/returnType}}{{/vendorExtensions.x-codegen-stream-element-type}}{{/vendorExtensions.x-codegen-streaming-response}}{{^vendorExtensions.x-codegen-streaming-response}}{{#returnType}}{{{.}}}{{/returnType}}{{^returnType}}void{{/returnType}}{{/vendorExtensions.x-codegen-streaming-response}} {{#operationId}}{{{.}}}{{/operationId}}{{^operationId}}{{httpMethod}}_{{vendorExtensions.x-codegen-resource-name}}{{/operationId}}( {{#allParams}}const {{#vendorExtensions.x-codegen-is-optional-query-parameter}}boost::optional<{{/vendorExtensions.x-codegen-is-optional-query-parameter}}{{{dataType}}}{{#vendorExtensions.x-codegen-is-optional-query-parameter}}>{{/vendorExtensions.x-codegen-is-optional-query-parameter}}& {{{paramName}}}{{^-last}}, {{/-last}}{{/allParams}}); {{/vendorExtensions.x-codegen-other-methods}} diff --git a/modules/openapi-generator/src/main/resources/cpp-boost-beast-client/api-operation-source.mustache b/modules/openapi-generator/src/main/resources/cpp-boost-beast-client/api-operation-source.mustache index 7fd5e1ad7ecd..ff83945a73ef 100644 --- a/modules/openapi-generator/src/main/resources/cpp-boost-beast-client/api-operation-source.mustache +++ b/modules/openapi-generator/src/main/resources/cpp-boost-beast-client/api-operation-source.mustache @@ -1,4 +1,4 @@ -{{#returnType}}{{{.}}}{{/returnType}}{{^returnType}}void{{/returnType}} +{{#vendorExtensions.x-codegen-streaming-response}}std::vector<{{#vendorExtensions.x-codegen-stream-element-type}}{{{.}}}{{/vendorExtensions.x-codegen-stream-element-type}}{{^vendorExtensions.x-codegen-stream-element-type}}{{#returnType}}{{{.}}}{{/returnType}}{{^returnType}}void{{/returnType}}{{/vendorExtensions.x-codegen-stream-element-type}}>{{/vendorExtensions.x-codegen-streaming-response}}{{^vendorExtensions.x-codegen-streaming-response}}{{#returnType}}{{{.}}}{{/returnType}}{{^returnType}}void{{/returnType}}{{/vendorExtensions.x-codegen-streaming-response}} {{classname}}::{{#operationId}}{{{.}}}{{/operationId}}{{^operationId}}{{httpMethod}}_{{vendorExtensions.x-codegen-resource-name}}{{/operationId}}( {{#allParams}}const {{#vendorExtensions.x-codegen-is-optional-query-parameter}}boost::optional<{{/vendorExtensions.x-codegen-is-optional-query-parameter}}{{{dataType}}}{{#vendorExtensions.x-codegen-is-optional-query-parameter}}>{{/vendorExtensions.x-codegen-is-optional-query-parameter}}& {{{paramName}}}{{^-last}}, {{/-last}}{{/allParams}}) { std::string serializedRequestBody; @@ -33,10 +33,15 @@ std::vector formParameters; {{#allParams}} {{#isFormParam}} +{{#vendorExtensions.x-codegen-is-variant-form-param}} + addVariantFormParameter(formParameters, "{{{baseName}}}", {{{paramName}}}); +{{/vendorExtensions.x-codegen-is-variant-form-param}} +{{^vendorExtensions.x-codegen-is-variant-form-param}} formParameters.emplace_back( "{{{baseName}}}", toFormParameterValue({{{paramName}}}), {{#isFile}}true{{/isFile}}{{^isFile}}{{#isBinary}}true{{/isBinary}}{{^isBinary}}false{{/isBinary}}{{/isFile}}); +{{/vendorExtensions.x-codegen-is-variant-form-param}} {{/isFormParam}} {{/allParams}} if (normalizeMediaType(requestContentType) == "multipart/form-data") { @@ -140,6 +145,38 @@ {{/hasProduces}} auto statusCode = boost::beast::http::status::unknown; +{{#vendorExtensions.x-codegen-streaming-response}} +{{#returnType}} + {{#vendorExtensions.x-codegen-stream-element-type}}std::vector<{{{.}}}>{{/vendorExtensions.x-codegen-stream-element-type}}{{^vendorExtensions.x-codegen-stream-element-type}}std::vector<{{{returnType}}}>{{/vendorExtensions.x-codegen-stream-element-type}} deserializedResponse; +{{/returnType}} + try { + statusCode = m_client->executeStream( + "{{httpMethod}}", + path, + serializedRequestBody, + headers, + [&deserializedResponse](const std::string &eventData) { +{{#returnType}} + {{#vendorExtensions.x-codegen-stream-element-type}} + appendParsedEvent(deserializedResponse, eventData, fromJsonValue_{{{.}}}); + {{/vendorExtensions.x-codegen-stream-element-type}} + {{^vendorExtensions.x-codegen-stream-element-type}} + appendParsedEvent(deserializedResponse, eventData, + [](boost::json::value const& v) { + return {{#vendorExtensions.x-codegen-stream-is-oneof}}OneOf{{/vendorExtensions.x-codegen-stream-is-oneof}}ResponseJsonValueConverter<{{{returnType}}}>::convert(v); + }); + {{/vendorExtensions.x-codegen-stream-element-type}} +{{/returnType}} + }); + } + catch(const std::exception& exception) { + handleStdException(exception); + } + catch(...) { + handleUncaughtException(); + } +{{/vendorExtensions.x-codegen-streaming-response}} +{{^vendorExtensions.x-codegen-streaming-response}} std::string responseBody; try { std::tie(statusCode, responseBody) = @@ -158,13 +195,24 @@ {{#returnType}} {{{.}}} deserializedResponse = {{{defaultResponse}}}; {{/returnType}} +{{/vendorExtensions.x-codegen-streaming-response}} {{#responses}} {{^isDefault}} if ({{#isRange}}static_cast(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}} + return deserializedResponse; +{{/returnType}} +{{^returnType}} + return; +{{/returnType}} +{{/vendorExtensions.x-codegen-streaming-response}} +{{^vendorExtensions.x-codegen-streaming-response}} {{#returnType}} {{#dataType}} - ResponseBodyDeserializer<{{{dataType}}}>::deserialize( + {{#vendorExtensions.x-codegen-response-is-oneof}}OneOf{{/vendorExtensions.x-codegen-response-is-oneof}}ResponseBodyDeserializer<{{{dataType}}}>::deserialize( deserializedResponse, responseBody, responseContentType, @@ -175,6 +223,8 @@ {{^returnType}} return; {{/returnType}} +{{/vendorExtensions.x-codegen-streaming-response}} +{{/vendorExtensions.x-codegen-return-compatible}} {{/is2xx}} {{^is2xx}} throw {{classname}}Exception(statusCode, "{{{message}}}"); @@ -186,12 +236,17 @@ {{#isDefault}} {{#dataType}} {{#vendorExtensions.x-codegen-default-response-is-return-compatible}} +{{#vendorExtensions.x-codegen-streaming-response}} + return deserializedResponse; +{{/vendorExtensions.x-codegen-streaming-response}} +{{^vendorExtensions.x-codegen-streaming-response}} ResponseBodyDeserializer<{{{dataType}}}>::deserialize( deserializedResponse, responseBody, responseContentType, {{#vendorExtensions.x-codegen-empty-body-tolerant}}true{{/vendorExtensions.x-codegen-empty-body-tolerant}}{{^vendorExtensions.x-codegen-empty-body-tolerant}}false{{/vendorExtensions.x-codegen-empty-body-tolerant}}); return deserializedResponse; +{{/vendorExtensions.x-codegen-streaming-response}} {{/vendorExtensions.x-codegen-default-response-is-return-compatible}} {{^vendorExtensions.x-codegen-default-response-is-return-compatible}} throw {{classname}}Exception(statusCode, "{{{message}}}"); @@ -211,3 +266,179 @@ throw {{classname}}Exception(statusCode, "Unexpected HTTP status code"); {{/vendorExtensions.x-codegen-has-default-response}} } + +{{#vendorExtensions.x-codegen-dual-content}} + {{#vendorExtensions.x-codegen-dual-stream-element-type}}std::vector<{{{.}}}>{{/vendorExtensions.x-codegen-dual-stream-element-type}}{{^vendorExtensions.x-codegen-dual-stream-element-type}}std::vector<{{{vendorExtensions.x-codegen-dual-stream-return-type}}}>{{/vendorExtensions.x-codegen-dual-stream-element-type}} +{{classname}}::{{#operationId}}{{{.}}}{{/operationId}}Stream( + {{#allParams}}const {{#vendorExtensions.x-codegen-is-optional-query-parameter}}boost::optional<{{/vendorExtensions.x-codegen-is-optional-query-parameter}}{{{dataType}}}{{#vendorExtensions.x-codegen-is-optional-query-parameter}}>{{/vendorExtensions.x-codegen-is-optional-query-parameter}}& {{{paramName}}}{{^-last}}, {{/-last}}{{/allParams}}) { + std::string serializedRequestBody; + std::string path = m_context + "{{{path}}}"; + std::map headers; +{{#hasConsumes}} + static const std::vector contentTypes{ {{#consumes}}"{{{mediaType}}}",{{/consumes}} }; + std::string requestContentType = selectPreferredContentType(contentTypes); + headers["Content-Type"] = requestContentType; +{{/hasConsumes}} +{{#hasBodyParam}} +{{^hasConsumes}} + const std::string requestContentType = "application/json"; + headers["Content-Type"] = requestContentType; +{{/hasConsumes}} + // Body params +{{#bodyParam}} + if (isJsonContentType(requestContentType)) { + serializedRequestBody = boost::json::serialize(toRequestJsonValue({{paramName}})); + } else { +{{#vendorExtensions.x-codegen-is-raw-body}} + serializedRequestBody = toRawBodyValue({{paramName}}); +{{/vendorExtensions.x-codegen-is-raw-body}} +{{^vendorExtensions.x-codegen-is-raw-body}} + throw std::invalid_argument("Content type '" + requestContentType + "' does not support structured request bodies"); +{{/vendorExtensions.x-codegen-is-raw-body}} + } +{{/bodyParam}} +{{/hasBodyParam}} +{{#hasFormParams}} + // form param + std::vector formParameters; +{{#allParams}} +{{#isFormParam}} +{{#vendorExtensions.x-codegen-is-variant-form-param}} + addVariantFormParameter(formParameters, "{{{baseName}}}", {{{paramName}}}); +{{/vendorExtensions.x-codegen-is-variant-form-param}} +{{^vendorExtensions.x-codegen-is-variant-form-param}} + formParameters.emplace_back( + "{{{baseName}}}", + toFormParameterValue({{{paramName}}}), + {{#isFile}}true{{/isFile}}{{^isFile}}{{#isBinary}}true{{/isBinary}}{{^isBinary}}false{{/isBinary}}{{/isFile}}); +{{/vendorExtensions.x-codegen-is-variant-form-param}} +{{/isFormParam}} +{{/allParams}} + if (normalizeMediaType(requestContentType) == "multipart/form-data") { + const std::string multipartBoundary = selectMultipartBoundary(formParameters); + headers["Content-Type"] = requestContentType + "; boundary=" + multipartBoundary; + serializedRequestBody = serializeMultipartFormData(formParameters, multipartBoundary); + } else { + serializedRequestBody = serializeUrlEncodedFormData(formParameters); + } +{{/hasFormParams}} +{{#hasPathParams}} + // path params +{{#pathParams}} + replacePathParameter(path, "{{{baseName}}}", {{{paramName}}}); +{{/pathParams}} +{{/hasPathParams}} +{{#hasQueryParams}} + // query params + std::stringstream queryParameterStream; + const char* queryParameterSeparator = "?"; +{{#queryParams}} +{{#vendorExtensions.x-codegen-is-optional-query-parameter}} + if ({{{paramName}}}) { +{{/vendorExtensions.x-codegen-is-optional-query-parameter}} +{{#isMap}} +{{#vendorExtensions.x-codegen-query-map-exploded}} + appendExplodedQueryParameters( + queryParameterStream, + queryParameterSeparator, + {{#vendorExtensions.x-codegen-is-optional-query-parameter}}*{{/vendorExtensions.x-codegen-is-optional-query-parameter}}{{{paramName}}}); +{{/vendorExtensions.x-codegen-query-map-exploded}} +{{#vendorExtensions.x-codegen-query-map-deep-object}} + appendDeepObjectQueryParameters( + queryParameterStream, + queryParameterSeparator, + "{{{baseName}}}", + {{#vendorExtensions.x-codegen-is-optional-query-parameter}}*{{/vendorExtensions.x-codegen-is-optional-query-parameter}}{{{paramName}}}); +{{/vendorExtensions.x-codegen-query-map-deep-object}} +{{^vendorExtensions.x-codegen-query-map-exploded}}{{^vendorExtensions.x-codegen-query-map-deep-object}} + appendQueryParameter( + queryParameterStream, + queryParameterSeparator, + "{{{baseName}}}", + serializeQueryParameterValue( + {{#vendorExtensions.x-codegen-is-optional-query-parameter}}*{{/vendorExtensions.x-codegen-is-optional-query-parameter}}{{{paramName}}}, + "{{{vendorExtensions.x-codegen-query-collection-delimiter}}}")); +{{/vendorExtensions.x-codegen-query-map-deep-object}}{{/vendorExtensions.x-codegen-query-map-exploded}} +{{/isMap}} +{{^isMap}} +{{#isArray}} +{{#vendorExtensions.x-codegen-query-collection-multi}} + appendMultiQueryParameters( + queryParameterStream, + queryParameterSeparator, + "{{{baseName}}}", + {{#vendorExtensions.x-codegen-is-optional-query-parameter}}*{{/vendorExtensions.x-codegen-is-optional-query-parameter}}{{{paramName}}}); +{{/vendorExtensions.x-codegen-query-collection-multi}} +{{^vendorExtensions.x-codegen-query-collection-multi}} + appendQueryParameter( + queryParameterStream, + queryParameterSeparator, + "{{{baseName}}}", + serializeQueryParameterValue( + {{#vendorExtensions.x-codegen-is-optional-query-parameter}}*{{/vendorExtensions.x-codegen-is-optional-query-parameter}}{{{paramName}}}, + "{{{vendorExtensions.x-codegen-query-collection-delimiter}}}")); +{{/vendorExtensions.x-codegen-query-collection-multi}} +{{/isArray}} +{{^isArray}} + appendQueryParameter( + queryParameterStream, + queryParameterSeparator, + "{{{baseName}}}", + serializeQueryParameterValue({{#vendorExtensions.x-codegen-is-optional-query-parameter}}*{{/vendorExtensions.x-codegen-is-optional-query-parameter}}{{{paramName}}})); +{{/isArray}} +{{/isMap}} +{{#vendorExtensions.x-codegen-is-optional-query-parameter}} + } +{{/vendorExtensions.x-codegen-is-optional-query-parameter}} +{{/queryParams}} + path += queryParameterStream.str(); +{{/hasQueryParams}} +{{#hasHeaderParams}} + // headers +{{#headerParams}} + headers.emplace("{{{baseName}}}", serializeHeaderParameterValue({{{paramName}}})); +{{/headerParams}} +{{/hasHeaderParams}} + + // For streaming, force Accept to text/event-stream (ignoring JSON produces) + headers["Accept"] = "text/event-stream"; + + auto statusCode = boost::beast::http::status::unknown; + {{#vendorExtensions.x-codegen-dual-stream-element-type}}std::vector<{{{.}}}>{{/vendorExtensions.x-codegen-dual-stream-element-type}}{{^vendorExtensions.x-codegen-dual-stream-element-type}}std::vector<{{{vendorExtensions.x-codegen-dual-stream-return-type}}}>{{/vendorExtensions.x-codegen-dual-stream-element-type}} deserializedResponse; + try { + statusCode = m_client->executeStream( + "{{httpMethod}}", + path, + serializedRequestBody, + headers, + [&deserializedResponse](const std::string &eventData) { + appendParsedEvent( + deserializedResponse, + eventData, + [](boost::json::value const& value) { + return {{#vendorExtensions.x-codegen-dual-stream-is-oneof}}OneOf{{/vendorExtensions.x-codegen-dual-stream-is-oneof}}ResponseJsonValueConverter<{{{vendorExtensions.x-codegen-dual-stream-element-type}}}>::convert(value); + }); + }); + } + catch(const std::exception& exception) { + handleStdException(exception); + } + catch(...) { + handleUncaughtException(); + } + +{{#responses}} +{{^isDefault}} + if ({{#isRange}}static_cast(statusCode) / 100U == {{vendorExtensions.x-codegen-response-range}}U{{/isRange}}{{^isRange}}statusCode == boost::beast::http::status({{code}}){{/isRange}}) { +{{#is2xx}} + return deserializedResponse; +{{/is2xx}} +{{^is2xx}} + throw {{classname}}Exception(statusCode, "{{{message}}}"); +{{/is2xx}} + } +{{/isDefault}} +{{/responses}} + throw {{classname}}Exception(statusCode, "Unexpected HTTP status code"); +} +{{/vendorExtensions.x-codegen-dual-content}} diff --git a/modules/openapi-generator/src/main/resources/cpp-boost-beast-client/api-source.mustache b/modules/openapi-generator/src/main/resources/cpp-boost-beast-client/api-source.mustache index def1e6fc32a3..0cc525cf5a33 100644 --- a/modules/openapi-generator/src/main/resources/cpp-boost-beast-client/api-source.mustache +++ b/modules/openapi-generator/src/main/resources/cpp-boost-beast-client/api-source.mustache @@ -5,13 +5,17 @@ #include #include #include +#include +#include +#include #include #include #include #include #include +#include +#include -#include #include #include #include @@ -69,13 +73,16 @@ inline bool isJsonContentType(const std::string& contentType) { } struct FormParameter { - FormParameter(std::string parameterName, std::string parameterValue, bool file) - : name(std::move(parameterName)), value(std::move(parameterValue)), isFile(file) { + FormParameter(std::string parameterName, std::string parameterValue, bool file, + std::string contentType = "") + : name(std::move(parameterName)), value(std::move(parameterValue)), + isFile(file), contentType(std::move(contentType)) { } std::string name; std::string value; bool isFile; + std::string contentType; }; inline std::string toFormParameterValue(const std::string& value) { @@ -96,12 +103,32 @@ inline std::string toRawBodyValue(bool value) { template std::string toRawBodyValue(const T& value) { - return boost::lexical_cast(value); + std::ostringstream stream; + stream << value; + return stream.str(); +} + +template +std::string toRawBodyValue(const std::optional& value) { + if (value.has_value()) { + return toRawBodyValue(value.value()); + } + return ""; } template std::string toFormParameterValue(const T& value) { - return boost::lexical_cast(value); + std::ostringstream stream; + stream << value; + return stream.str(); +} + +template +std::string toFormParameterValue(const std::optional& value) { + if (value.has_value()) { + return toFormParameterValue(value.value()); + } + return ""; } template @@ -115,6 +142,11 @@ std::string toFormParameterValue(const std::vector& values) { return serializedValues.str(); } +// Binary data: treat the byte values as raw string content for file upload. +inline std::string toFormParameterValue(const std::vector& binaryValue) { + return std::string(reinterpret_cast(binaryValue.data()), binaryValue.size()); +} + inline std::string percentEncodeFormValue(const std::string& value) { static const char hexDigits[] = "0123456789ABCDEF"; std::string encodedValue; @@ -297,7 +329,7 @@ inline std::string serializeHeaderParameterValue(bool headerParameterValue) { template typename std::enable_if::value, std::string>::type serializeHeaderParameterValue(const T& headerParameterValue) { - return boost::lexical_cast(headerParameterValue); + return std::to_string(headerParameterValue); } template @@ -372,7 +404,9 @@ inline std::string serializeMultipartFormData( << escapeMultipartParameter(formParameter.name) << '"'; } serializedFormData << "\r\n"; - if (formParameter.isFile) { + if (!formParameter.contentType.empty()) { + serializedFormData << "Content-Type: " << formParameter.contentType << "\r\n"; + } else if (formParameter.isFile) { serializedFormData << "Content-Type: application/octet-stream\r\n"; } serializedFormData << "\r\n" << formParameter.value << "\r\n"; @@ -396,8 +430,31 @@ std::string base64encodeImpl(const std::string& str) { #endif } +// Trait to detect types with toJsonValue() const member (e.g. model classes) +template +struct HasRequestToJsonValue : std::false_type {}; + +template +struct HasRequestToJsonValue().toJsonValue())>> : std::true_type {}; + +// Dispatch for types with toJsonValue() — model classes template -boost::json::value toRequestJsonValue(const T& requestValue); +boost::json::value toRequestJsonValueImpl(const T& requestValue, std::true_type) { + return requestValue.toJsonValue(); +} + +// Dispatch for types without toJsonValue() — primitives, standard containers +template +boost::json::value toRequestJsonValueImpl(const T& requestValue, std::false_type) { + return boost::json::value_from(requestValue); +} + +// Base template: detect toJsonValue() at compile time and dispatch accordingly +template +boost::json::value toRequestJsonValue(const T& requestValue) { + return toRequestJsonValueImpl(requestValue, HasRequestToJsonValue{}); +} template boost::json::value toRequestJsonValue(const std::shared_ptr& requestValue); @@ -408,16 +465,32 @@ boost::json::value toRequestJsonValue(const std::vector& requestValues); template boost::json::value toRequestJsonValue(const std::map& requestValues); +template +boost::json::value toRequestJsonValue(const std::variant& requestValue); + template -boost::json::value toRequestJsonValue(const T& requestValue) { - return boost::json::value_from(requestValue); -} +boost::json::value toRequestJsonValue(const std::optional& requestValue); template boost::json::value toRequestJsonValue(const std::shared_ptr& requestValue) { return requestValue == nullptr ? boost::json::value(nullptr) : requestValue->toJsonValue(); } +template +boost::json::value toRequestJsonValue(const std::variant& requestValue) { + return std::visit([](auto const& v) -> boost::json::value { + return toRequestJsonValue(v); + }, requestValue); +} + +template +boost::json::value toRequestJsonValue(const std::optional& requestValue) { + if (requestValue.has_value()) { + return toRequestJsonValue(requestValue.value()); + } + return boost::json::value(nullptr); +} + template boost::json::value toRequestJsonValue(const std::vector& requestValues) { boost::json::array requestArray; @@ -437,10 +510,56 @@ boost::json::value toRequestJsonValue(const std::map& requestVal return requestObject; } +/// addVariantFormParameter definition (forward-declared above). +/// Must be defined after toRequestJsonValue so lambdas can find it via ADL. +template +void addVariantFormParameter( + std::vector& formParameters, + const std::string& name, + const VariantType& value) { + std::visit([&](auto const& branch) { + using BranchType = std::decay_t; + // Only explicit byte containers are treated as file/binary branches. + // std::string branches are serialized as JSON to correctly handle + // string|object variant unions (e.g., oneOf [string, DataObject]). + if constexpr (std::is_same_v>) { + // Binary branch — send as file part with octet-stream + formParameters.emplace_back(name, toFormParameterValue(branch), true, + "application/octet-stream"); + } else { + // Object or string branch — serialize as JSON part + std::string jsonValue = boost::json::serialize(toRequestJsonValue(branch)); + formParameters.emplace_back(name, jsonValue, false, "application/json"); + } + }, value); +} + +// Trait to detect types with fromJsonValue(boost::json::value const&) member +template +struct HasFromJsonValue : std::false_type {}; + +template +struct HasFromJsonValue().fromJsonValue(std::declval()))>> : std::true_type {}; + +// Dispatch for types with fromJsonValue() — model classes +template +static T convertJsonValueImpl(const boost::json::value& responseValue, std::true_type) { + T result; + result.fromJsonValue(responseValue); + return result; +} + +// Dispatch for types without fromJsonValue() — primitives, standard types +template +static T convertJsonValueImpl(const boost::json::value& responseValue, std::false_type) { + return boost::json::value_to(responseValue); +} + template struct ResponseJsonValueConverter { static T convert(const boost::json::value& responseValue) { - return boost::json::value_to(responseValue); + return convertJsonValueImpl(responseValue, HasFromJsonValue{}); } }; @@ -485,6 +604,184 @@ struct ResponseJsonValueConverter> { } }; +// Trait: detects whether a type is a specialization of a template (e.g. std::vector) +template class Template> +struct IsSpecialization : std::false_type {}; + +template