diff --git a/.circleci/config.yml b/.circleci/config.yml index bf994be945e0..fe45d7f82495 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -24,8 +24,26 @@ commands: # a reusable command with parameters libpango-1.0-0 libpangocairo-1.0-0 libstdc++6 libx11-6 libx11-xcb1 libxcb1 libxcomposite1 libxcursor1 \ libxdamage1 libxext6 libxfixes3 libxi6 libxrandr2 libxrender1 libxss1 libxtst6 ca-certificates \ fonts-liberation libappindicator1 libnss3 lsb-release xdg-utils wget libgbm1 - # The following `checkout` command checks out your code to your working directory. In 1.0 we did this implicitly. In 2.0 you can choose where in the course of a job your code should be checked out. - - checkout + # Avoid intermittent SSH checkout failures from CircleCI workers. + - run: + name: Checkout source over HTTPS with retry + command: | + if [ -n "${CIRCLE_PULL_REQUEST:-}" ]; then + ref="refs/pull/${CIRCLE_PULL_REQUEST##*/}/head" + elif [[ "${CIRCLE_BRANCH:-}" =~ ^pull/[0-9]+$ ]]; then + ref="refs/${CIRCLE_BRANCH}/head" + else + ref="$CIRCLE_SHA1" + fi + git init . + git remote add origin https://github.com/OpenAPITools/openapi-generator.git + for i in $(seq 1 5); do + if git fetch --depth=1 origin "$ref" && git checkout --detach FETCH_HEAD; then + exit 0 + fi + sleep 5 + done + exit 1 # Prepare for artifact and test results collection equivalent to how it was done on 1.0. # In many cases you can simplify this from what is generated here. # 'See docs on artifact collection here https://circleci.com/docs/2.0/artifacts/' @@ -189,7 +207,6 @@ jobs: DOCKER_GENERATOR_IMAGE_NAME: openapitools/openapi-generator DOCKER_CODEGEN_CLI_IMAGE_NAME: openapitools/openapi-generator-cli steps: - - checkout - command_build_and_test: nodeNo: "3" workflows: diff --git a/docs/generators/cpp-boost-beast-client.md b/docs/generators/cpp-boost-beast-client.md index 32e0abea44f8..e4aaf6bb3c59 100644 --- a/docs/generators/cpp-boost-beast-client.md +++ b/docs/generators/cpp-boost-beast-client.md @@ -19,8 +19,17 @@ These options may be applied as additional-properties (cli) or configOptions (pl | Option | Description | Values | Default | | ------ | ----------- | ------ | ------- | |apiPackage|C++ namespace for apis (convention: name.space.api).| |org.openapitools.client.api| +|compileWithValidation|Emit schema-validation IR and kValidateOnDecode=true in generated ValidationTypes.h (default). Set to false to omit the IR for high-throughput clients. Representation diagnostics (non-finite destinations, integer range, required properties) remain active.| |true| +|exportMacro|C++ export macro placed before public classes and functions. When non-empty, ApiExport.h is generated for Windows DLL export/import handling.| || +|formatAssertionPolicy|Format handling in composition branch matching. Only 'annotation' is supported: format metadata never affects match counts.|
**annotation**
Formats are annotations and do not affect validation
|annotation| +|inferConditionalSseOperations|Infer conditional SSE for dual JSON/SSE operations only when the request selector and event model are unambiguous. Enabled by default.| |true| |modelPackage|C++ namespace for models (convention: name.space.model).| |org.openapitools.client.model| |packageName|C++ package and library name.| |CppBoostBeastOpenAPIClient| +|sseEventTypeMappings|Comma-separated operationId=Model mappings for the JSON schema of each SSE event data payload.| |null| +|sseOperationIds|Comma-separated operationIds whose JSON request body conditionally selects text/event-stream (default request property: stream).| |null| +|sseRequestPropertyMappings|Comma-separated operationId=property mappings for the boolean request property that selects SSE.| |null| +|sseSchemaMode|SSE schema interpretation mode for text/event-stream responses. 'representation' (default): the response schema describes the media representation; callbacks receive an owning SseEvent with raw data, event, id, and retry metadata. 'jsonEventData': decode each complete event data payload against the response schema and pass both the typed value and SseEvent metadata to the callback. Use x-sse-event-data-schema for per-operation typed decoding.|
**representation**
Schema describes the media representation; callback receives SseEvent
**jsonEventData**
Schema describes each JSON event data payload
|representation| +|tolerateNonNullableNulls|Treat explicit JSON null values as absent for generated model properties whose schemas do not allow null. Enabled by default to tolerate non-conforming server responses while preserving required-key presence checks; set to false for strict schema decoding. Non-null values remain fully validated.| |true| ## IMPORT MAPPING @@ -32,8 +41,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>| @@ -51,9 +64,9 @@ These options may be applied as additional-properties (cli) or configOptions (pl
  • double
  • float
  • int
  • -
  • int32_t
  • -
  • int64_t
  • long
  • +
  • std::int32_t
  • +
  • std::int64_t
  • ## RESERVED WORDS @@ -168,14 +181,14 @@ These options may be applied as additional-properties (cli) or configOptions (pl |Int64|✓|OAS2,OAS3 |Float|✓|OAS2,OAS3 |Double|✓|OAS2,OAS3 -|Decimal|✓|ToolingExtension +|Decimal|✗|ToolingExtension |String|✓|OAS2,OAS3 -|Byte|✓|OAS2,OAS3 -|Binary|✓|OAS2,OAS3 +|Byte|✗|OAS2,OAS3 +|Binary|✗|OAS2,OAS3 |Boolean|✓|OAS2,OAS3 -|Date|✓|OAS2,OAS3 -|DateTime|✓|OAS2,OAS3 -|Password|✓|OAS2,OAS3 +|Date|✗|OAS2,OAS3 +|DateTime|✗|OAS2,OAS3 +|Password|✗|OAS2,OAS3 |File|✓|OAS2 |Uuid|✗| |Array|✓|OAS2,OAS3 @@ -217,11 +230,11 @@ These options may be applied as additional-properties (cli) or configOptions (pl |ExternalDocumentation|✓|OAS2,OAS3 |Examples|✓|OAS2,OAS3 |XMLStructureDefinitions|✗|OAS2,OAS3 -|MultiServer|✗|OAS3 +|MultiServer|✓|OAS3 |ParameterizedServer|✗|OAS3 -|ParameterStyling|✗|OAS3 -|Callbacks|✗|OAS3 -|LinkObjects|✗|OAS3 +|ParameterStyling|✓|OAS3 +|Callbacks|✓|OAS3 +|LinkObjects|✓|OAS3 ### Parameter Feature | Name | Supported | Defined By | @@ -232,19 +245,19 @@ These options may be applied as additional-properties (cli) or configOptions (pl |Body|✓|OAS2 |FormUnencoded|✓|OAS2 |FormMultipart|✓|OAS2 -|Cookie|✗|OAS3 +|Cookie|✓|OAS3 ### Schema Support Feature | Name | Supported | Defined By | | ---- | --------- | ---------- | |Simple|✓|OAS2,OAS3 |Composite|✓|OAS2,OAS3 -|Polymorphism|✗|OAS2,OAS3 -|Union|✗|OAS3 -|allOf|✗|OAS2,OAS3 -|anyOf|✗|OAS3 -|oneOf|✗|OAS3 -|not|✗|OAS3 +|Polymorphism|✓|OAS2,OAS3 +|Union|✓|OAS3 +|allOf|✓|OAS2,OAS3 +|anyOf|✓|OAS3 +|oneOf|✓|OAS3 +|not|✓|OAS3 ### Security Feature | Name | Supported | Defined By | diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/InlineModelResolver.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/InlineModelResolver.java index 4add57db345a..1b5f350451b7 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/InlineModelResolver.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/InlineModelResolver.java @@ -1173,6 +1173,13 @@ private String addSchemas(String name, Schema schema) { name = inlineSchemaNameMapping.get(name); } + // Recursive flattening can add a nested schema after its parent's name + // was chosen. Re-check here so the parent cannot overwrite that child. + if (openAPI.getComponents().getSchemas().containsKey(name) + || uniqueNames.contains(name)) { + name = uniqueName(name); + } + addGenerated(name, schema); openAPI.getComponents().addSchemas(name, schema); if (!name.equals(schema.getTitle()) && !inlineSchemaNameMappingValues.contains(name)) { diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/AbstractCppCodegen.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/AbstractCppCodegen.java index 8950c1d4b761..262bb94b8596 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/AbstractCppCodegen.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/AbstractCppCodegen.java @@ -365,7 +365,7 @@ public void preprocessOpenAPI(OpenAPI openAPI) { if (!scheme.isEmpty()) { this.additionalProperties.put("scheme", scheme); } - if (!serverList.isEmpty()) { + if (serverList != null && !serverList.isEmpty()) { for (Server server : serverList) { CodegenServer s = new CodegenServer(); s.description = server.getDescription(); 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..3cf90aa05d4e 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,13 +1,30 @@ package org.openapitools.codegen.languages; +import com.fasterxml.jackson.databind.JsonNode; +import com.google.common.collect.ImmutableMap; + +import com.samskivert.mustache.Mustache.Lambda; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.PathItem; +import io.swagger.v3.oas.models.Operation; +import io.swagger.v3.oas.models.media.MediaType; import io.swagger.v3.oas.models.media.Schema; import io.swagger.v3.oas.models.parameters.Parameter; +import io.swagger.v3.oas.models.responses.ApiResponse; import org.apache.commons.lang3.StringUtils; +import org.apache.commons.text.StringEscapeUtils; import org.openapitools.codegen.*; +import org.openapitools.codegen.languages.Oas31CompositionLowering.AllOfIntersection; +import org.openapitools.codegen.languages.Oas31CompositionLowering.CompositionBranchDescriptor; +import org.openapitools.codegen.languages.Oas31CompositionLowering.CompositionDescriptor; +import org.openapitools.codegen.languages.Oas31CompositionLowering.DiscriminatorDescriptor; 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; @@ -19,27 +36,106 @@ import static org.openapitools.codegen.utils.StringUtils.camelize; -public class CppBoostBeastClientCodegen extends AbstractCppCodegen { +public class CppBoostBeastClientCodegen extends CppBoostBeastModelCodegen { public static final String DEFAULT_PACKAGE_NAME = "CppBoostBeastOpenAPIClient"; - private static final String X_CODEGEN_DEFAULT_RESPONSE_IS_RETURN_COMPATIBLE = - "x-codegen-default-response-is-return-compatible"; - private static final String X_CODEGEN_EMPTY_BODY_TOLERANT = "x-codegen-empty-body-tolerant"; - private static final String X_CODEGEN_HAS_DEFAULT_RESPONSE = "x-codegen-has-default-response"; + public static final String EXPORT_MACRO = "exportMacro"; + private static final String HAS_EXPORT_MACRO = "hasExportMacro"; + + /** Policy for format metadata in composition branch matching. + * Formats remain annotations and never affect branch match counts. */ + private String formatAssertionPolicy = "annotation"; + + /** Value type for the formatAssertion option. */ + private static final String FORMAT_ASSERTION_POLICY_ANNOTATION = "annotation"; + + /** SSE schema interpretation mode. */ + private String sseSchemaMode = "representation"; + private static final String SSE_SCHEMA_MODE_REPRESENTATION = "representation"; + private static final String SSE_SCHEMA_MODE_JSON_EVENT_DATA = "jsonEventData"; + /** Explicit conditional-streaming contracts, keyed by operationId. */ + private Set sseOperationIds = Collections.emptySet(); + private Map sseRequestPropertyMappings = Collections.emptyMap(); + private Map sseEventTypeMappings = Collections.emptyMap(); + private boolean inferConditionalSseOperations = true; + /** Controls composition-branch validation during model decoding. */ + private boolean validateOnDecode = true; + private static final String X_CODEGEN_IS_RAW_BODY = "x-codegen-is-raw-body"; private static final String X_CODEGEN_IS_OPTIONAL_QUERY_PARAMETER = "x-codegen-is-optional-query-parameter"; - private static final String X_CODEGEN_QUERY_COLLECTION_DELIMITER = - "x-codegen-query-collection-delimiter"; - private static final String X_CODEGEN_QUERY_COLLECTION_MULTI = - "x-codegen-query-collection-multi"; - private static final String X_CODEGEN_QUERY_MAP_EXPLODED = - "x-codegen-query-map-exploded"; - 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 final Logger LOGGER = LoggerFactory.getLogger(CppBoostBeastClientCodegen.class); + // Authoritative parameter serialization facts stamped by codegenParameterStyled(). + private static final String X_CODEGEN_PARAM_STYLE = "x-codegen-param-style"; + private static final String X_CODEGEN_PARAM_EXPLODE = "x-codegen-param-explode"; + private static final String X_CODEGEN_PARAM_ALLOW_RESERVED = + "x-codegen-param-allow-reserved"; + private static final String X_CODEGEN_PARAM_ALLOW_EMPTY_VALUE = + "x-codegen-param-allow-empty-value"; + private Map componentSchemaIdsByName = Collections.emptyMap(); + + + + /** Starts an isolated state set for one generator invocation. */ + private void beginGeneration(OpenAPI openApi) { + sourceOpenApi = openApi; + variantModels = new HashSet<>(); + resolvedAliasTypes = new HashMap<>(); + composedKeywordsByModel = new HashMap<>(); + compositionDescriptors = new LinkedHashMap<>(); + compositionDescriptorSets = new LinkedHashMap<>(); + webhookPreservation = new ArrayList<>(); + operationCallbacks = new HashMap<>(); + operationLinks = new HashMap<>(); + allOfIntersections = new LinkedHashMap<>(); + refreshComponentSchemaIds(openApi); + + } + + /** + * swagger-parser materializes the implicit root server as {@code /}, which + * is indistinguishable from a source-level {@code servers: [{url: /}]} in + * the model. Consult the raw document before server-precedence assembly. + */ + private boolean detectExplicitRootServers() { + String inputSpec = getInputSpec(); + if (inputSpec == null || inputSpec.isEmpty()) { + // Programmatic OpenAPI instances have no parser-injected source. + return true; + } + try { + JsonNode document = Oas31RawSpecRecovery.readRawDocument(inputSpec); + return document != null && document.isObject() && document.has("servers"); + } catch (Exception exception) { + throw new IllegalStateException( + "Unable to inspect the source OpenAPI document for root servers", exception); + } + } + + /** + * Returns the composition descriptor for the given schema name, or null + * if the schema is not composed or was not indexed. + */ + public CompositionDescriptor getCompositionDescriptor(String schemaName) { + return compositionDescriptors.get(schemaName); + } + + /** + * Returns an unmodifiable view of the full composition descriptor index. + */ + public Map getCompositionDescriptors() { + return Collections.unmodifiableMap(compositionDescriptors); + } + + /** + * Returns every composition descriptor present on a schema, in keyword + * order: oneOf, anyOf, then allOf. + */ + public List getCompositionDescriptorsForSchema(String schemaName) { + return compositionDescriptorSets.getOrDefault(schemaName, Collections.emptyList()); + } + protected String packageName = DEFAULT_PACKAGE_NAME; + private String exportMacro = ""; public CodegenType getTag() { return CodegenType.CLIENT; @@ -53,26 +149,218 @@ public String getHelp() { return "Generates a cpp-boost-beast client."; } + @Override + public void preprocessOpenAPI(OpenAPI openAPI) { + beginGeneration(openAPI); + hasExplicitRootServers = detectExplicitRootServers(); + + List policyDiagnostics = validateDialectPolicy(openAPI); + if (!policyDiagnostics.isEmpty()) { + throw new IllegalArgumentException(String.join("; ", policyDiagnostics)); + } + super.preprocessOpenAPI(openAPI); + // Webhooks are inbound-only metadata for a client generator. Upstream + // folds them into the API map under the same fallback classname as path + // operations, which can replace the path API. Preserve their metadata, + // then remove them so outbound paths still generate; no listener is emitted. + if (openAPI.getWebhooks() != null && !openAPI.getWebhooks().isEmpty()) { + for (Map.Entry e : openAPI.getWebhooks().entrySet()) { + PathItem item = e.getValue(); + List methods = new ArrayList<>(); + if (item.getGet() != null) methods.add("GET " + idOf(item.getGet())); + if (item.getPut() != null) methods.add("PUT " + idOf(item.getPut())); + if (item.getPost() != null) methods.add("POST " + idOf(item.getPost())); + if (item.getDelete() != null) methods.add("DELETE " + idOf(item.getDelete())); + if (item.getPatch() != null) methods.add("PATCH " + idOf(item.getPatch())); + if (item.getHead() != null) methods.add("HEAD " + idOf(item.getHead())); + if (item.getOptions() != null) methods.add("OPTIONS " + idOf(item.getOptions())); + if (item.getTrace() != null) methods.add("TRACE " + idOf(item.getTrace())); + webhookPreservation.add(e.getKey() + + "[" + String.join(", ", methods) + "]"); + } + openAPI.setWebhooks(null); + } + // Capture callback and response-link names for generated API comments. + captureOperationMetadata(openAPI); + // Recover prefixItems dropped when the shared OAS 3.1 normalizer + // converts a type-array JsonSchema to ArraySchema. This must precede + // descriptor scanning so child schemas retain the pristine value. + Oas31RawSpecRecovery.restoreNormalizerDroppedPrefixItems(openAPI, getInputSpec()); + Oas31RawSpecRecovery.recoverPristineLiterals(openAPI, getInputSpec()); + // Populate variantModels and build composition descriptors before + // model processing begins so that getTypeDeclaration can resolve $ref + // to composed models as value types and branch semantics are captured + // before fromModel consumes composed schemas. + Map schemas = openAPI.getComponents() != null + ? openAPI.getComponents().getSchemas() : null; + if (schemas != null) { + // Build descriptor index: must happen after inline model resolver + // flattening so all inline schemas have been extracted to component + // references with stable $ref targets. + for (Map.Entry entry : schemas.entrySet()) { + String schemaName = entry.getKey(); + Schema schema = entry.getValue(); + List descriptors = + Oas31CompositionLowering.buildCompositionDescriptors( + schemaName, schema, openAPI, schemas); + if (!descriptors.isEmpty()) { + String modelName = toModelName(schemaName); + // The primary descriptor drives representation lowering; + // retain and validate every composition keyword separately. + compositionDescriptors.put(modelName, descriptors.get(0)); + compositionDescriptorSets.put(modelName, Collections.unmodifiableList( + new ArrayList<>(descriptors))); + for (CompositionDescriptor descriptor : descriptors) { + Oas31CompositionLowering.validateDescriptorAssertions(descriptor); + } + } + // allOf affects object storage even when oneOf or anyOf selects + // the public representation. + if (schema.getAllOf() != null && !schema.getAllOf().isEmpty()) { + AllOfIntersection intersection = + Oas31CompositionLowering.computeAllOfIntersection( + schemaName, schema, openAPI, schemas, new HashSet<>()); + if (intersection != null) { + allOfIntersections.put(toModelName(schemaName), intersection); + } + } + if ((schema.getOneOf() != null && !schema.getOneOf().isEmpty()) + || (schema.getAnyOf() != null && !schema.getAnyOf().isEmpty())) { + variantModels.add(schemaName); + } + } + } +} + + // ======================================================================== + // OAS 3.1 dialect and schema policy + // ======================================================================== + + /** Pinned OAS 3.1 Schema dialect (spec.openapis.org/oas/3.1/dialect/2024-11-10). */ + public static final String OAS_31_DIALECT = + "https://spec.openapis.org/oas/3.1/dialect/2024-11-10"; + + /** OAS alias accepted only as the identifier for the same pinned revision. */ + public static final String OAS_31_DIALECT_BASE_ALIAS = + "https://spec.openapis.org/oas/3.1/dialect/base"; + + /** Plain JSON Schema Draft 2020-12 core identifier (non-OAS dialect). */ + public static final String DRAFT_2020_12 = + "https://json-schema.org/draft/2020-12/schema"; + + /** Classified effective schema dialect for an OpenAPI document. */ + public enum OasDialect { + /** OAS 3.1 pinned dialect (or its base alias). */ + OAS_31, + /** Plain JSON Schema Draft 2020-12 (not OAS-wrapped). */ + DRAFT_2020_12_REC, + /** A dialect identifier not recognized by this program. */ + UNRECOGNIZED, + /** No dialect declared (OAS 3.1 default applies for OAS 3.1 documents). */ + UNSPECIFIED + } + + /** + * Dialect resolution, normative-structure checks, and the exhaustive + * keyword-occurrence scanner live in {@link Oas31KeywordScanner}; + * the delegates below keep this generator's public API stable for + * tests and templates. + */ + public static OasDialect resolveEffectiveDialect(String jsonSchemaDialect, String rootSchema) { + return Oas31KeywordScanner.resolveEffectiveDialect(jsonSchemaDialect, rootSchema); + } + + /** Resolve the effective dialect of an OpenAPI document from its declared knobs. */ + public static OasDialect resolveDocumentDialect(OpenAPI openAPI) { + return Oas31KeywordScanner.resolveDocumentDialect(openAPI); + } + + /** OAS 3 structural normative checks (see {@link Oas31KeywordScanner}). */ + public List validateNormativeOas3Structure(OpenAPI openAPI) { + return Oas31KeywordScanner.validateNormativeOas3Structure(openAPI); + } + + /** Dialect/metaschema policy gate (see {@link Oas31KeywordScanner}). */ + public List validateDialectPolicy(OpenAPI openAPI) { + return Oas31KeywordScanner.validateDialectPolicy(openAPI); + } + + + /** + * Exhaustive schema-valued-position scanner (see {@link Oas31KeywordScanner}). + */ + public Oas31KeywordScanner.KeywordOccurrenceLedger scanSchemaKeywordOccurrences( + OpenAPI openAPI) { + return Oas31KeywordScanner.scanSchemaKeywordOccurrences(openAPI); + } + + + /** Set of fail-closed required-vocabulary keywords for this document. */ + public Set failClosedKeywords(OpenAPI openAPI) { + return Oas31KeywordScanner.failClosedKeywords(openAPI); + } + + public CppBoostBeastClientCodegen() { super(); + openapiNormalizer.put("NORMALIZER_CLASS", CppBoostBeastOpenAPINormalizer.class.getName()); modifyFeatureSet(features -> features .includeDocumentationFeatures(DocumentationFeature.Readme) .securityFeatures(EnumSet.noneOf(SecurityFeature.class)) - .excludeGlobalFeatures( - GlobalFeature.XMLStructureDefinitions, - GlobalFeature.Callbacks, - GlobalFeature.LinkObjects, + .includeGlobalFeatures( GlobalFeature.ParameterStyling, - GlobalFeature.MultiServer + GlobalFeature.MultiServer, + // Preserve callback, webhook, and link metadata visibly; + // an outbound client does not generate inbound listeners. + GlobalFeature.Callbacks, + GlobalFeature.LinkObjects + ) + .excludeGlobalFeatures( + GlobalFeature.XMLStructureDefinitions ) - .excludeSchemaSupportFeatures( - SchemaSupportFeature.Polymorphism + .includeSchemaSupportFeatures( + SchemaSupportFeature.Polymorphism, + SchemaSupportFeature.Composite, + SchemaSupportFeature.oneOf, + SchemaSupportFeature.anyOf, + SchemaSupportFeature.allOf, + SchemaSupportFeature.not, + SchemaSupportFeature.Union ) .includeDataTypeFeatures( - DataTypeFeature.AnyType, - DataTypeFeature.Null + // Destination numeric domains validated by the corpus. + // Floating-point destinations narrow after exact validation; + // non-finite destinations produce representation diagnostics. + DataTypeFeature.Int32, + DataTypeFeature.Int64, + DataTypeFeature.Float, + DataTypeFeature.Double, + DataTypeFeature.String, + DataTypeFeature.Boolean, + DataTypeFeature.Enum, + DataTypeFeature.Array, + DataTypeFeature.Maps, + DataTypeFeature.Object, + DataTypeFeature.Null, + DataTypeFeature.AnyType ) - .excludeParameterFeatures( + .excludeDataTypeFeatures( + // No decimal destination domain exists (format: + // decimal maps to double; exact decimals are not + // a declared C++ type) — the base set lists it. + DataTypeFeature.Decimal, + // Formats are annotations by default. String-domain + // formats map to std::string, so the generator does not + // advertise format-specific destination types. + DataTypeFeature.Date, + DataTypeFeature.DateTime, + DataTypeFeature.Uuid, + DataTypeFeature.Byte, + DataTypeFeature.Binary, + DataTypeFeature.Password + ) + // Form-style cookie parameters are joined into the Cookie header. + .includeParameterFeatures( ParameterFeature.Cookie ) ); @@ -92,30 +380,108 @@ public CppBoostBeastClientCodegen() { // CLI options addOption(CodegenConstants.PACKAGE_NAME, "C++ package and library name.", DEFAULT_PACKAGE_NAME); + addOption(EXPORT_MACRO, + "C++ export macro placed before public classes and functions. When non-empty," + + " ApiExport.h is generated for Windows DLL export/import handling.", + exportMacro); addOption(CodegenConstants.MODEL_PACKAGE, "C++ namespace for models (convention: name.space.model).", this.modelPackage); addOption(CodegenConstants.API_PACKAGE, "C++ namespace for apis (convention: name.space.api).", this.apiPackage); + CliOption formatAssertionOption = new CliOption("formatAssertionPolicy", + "Format handling in composition branch matching. Only 'annotation'" + + " is supported: format metadata never affects match counts."); + formatAssertionOption.defaultValue(FORMAT_ASSERTION_POLICY_ANNOTATION); + formatAssertionOption.addEnum(FORMAT_ASSERTION_POLICY_ANNOTATION, + "Formats are annotations and do not affect validation"); + cliOptions.add(formatAssertionOption); + + CliOption sseSchemaModeOption = new CliOption("sseSchemaMode", + "SSE schema interpretation mode for text/event-stream responses." + + " 'representation' (default): the response schema describes the" + + " media representation; callbacks receive an owning SseEvent with" + + " raw data, event, id, and retry metadata. 'jsonEventData': decode" + + " each complete event data payload against the response schema and" + + " pass both the typed value and SseEvent metadata to the callback." + + " Use x-sse-event-data-schema for per-operation typed decoding."); + sseSchemaModeOption.defaultValue(SSE_SCHEMA_MODE_REPRESENTATION); + sseSchemaModeOption.addEnum(SSE_SCHEMA_MODE_REPRESENTATION, + "Schema describes the media representation; callback receives SseEvent"); + sseSchemaModeOption.addEnum(SSE_SCHEMA_MODE_JSON_EVENT_DATA, + "Schema describes each JSON event data payload"); + cliOptions.add(sseSchemaModeOption); + cliOptions.add(new CliOption("sseOperationIds", + "Comma-separated operationIds whose JSON request body conditionally" + + " selects text/event-stream (default request property: stream).")); + cliOptions.add(new CliOption("sseRequestPropertyMappings", + "Comma-separated operationId=property mappings for the boolean request" + + " property that selects SSE.")); + cliOptions.add(new CliOption("sseEventTypeMappings", + "Comma-separated operationId=Model mappings for the JSON schema of each" + + " SSE event data payload.")); + CliOption inferConditionalSseOption = CliOption.newBoolean( + "inferConditionalSseOperations", + "Infer conditional SSE for dual JSON/SSE operations only when the" + + " request selector and event model are unambiguous. Enabled by default."); + inferConditionalSseOption.defaultValue(Boolean.TRUE.toString()); + cliOptions.add(inferConditionalSseOption); + CliOption compileWithValidationOption = new CliOption("compileWithValidation", + "Emit schema-validation IR and kValidateOnDecode=true in generated" + + " ValidationTypes.h (default). Set to false to omit the IR for" + + " high-throughput clients. Representation diagnostics (non-finite" + + " destinations, integer range, required properties) remain active."); + compileWithValidationOption.defaultValue(Boolean.TRUE.toString()); + cliOptions.add(compileWithValidationOption); + CliOption tolerateNonNullableNullsOption = new CliOption( + "tolerateNonNullableNulls", + "Treat explicit JSON null values as absent for generated model properties" + + " whose schemas do not allow null. Enabled by default to tolerate" + + " non-conforming server responses while preserving required-key" + + " presence checks; set to false for strict schema decoding." + + " Non-null values remain fully validated."); + tolerateNonNullableNullsOption.defaultValue(Boolean.TRUE.toString()); + cliOptions.add(tolerateNonNullableNullsOption); + + + supportingFiles.add(new SupportingFile("validation-types.mustache", "model", "ValidationTypes.h")); + supportingFiles.add(new SupportingFile("NullableField.h.mustache", "model", "NullableField.h")); supportingFiles.add(new SupportingFile("README.mustache", "", "README.md")); supportingFiles.add(new SupportingFile("CMakeLists.txt.mustache", "", "CMakeLists.txt")); supportingFiles.add(new SupportingFile("http-client-header.mustache", "api", "HttpClient.h")); supportingFiles.add(new SupportingFile("http-client-impl-header.mustache", "api", "HttpClientImpl.h")); supportingFiles.add(new SupportingFile("http-client-impl-source.mustache", "api", "HttpClientImpl.cpp")); supportingFiles.add(new SupportingFile("anytype-header.mustache", "model", "AnyType.h")); + supportingFiles.add(new SupportingFile("MultipartWireTest.cpp.mustache", "test", "MultipartWireTest.cpp")); + + // Header-only schema-validation support. The templates place their + // implementation types under the configured model namespace. + supportingFiles.add(new SupportingFile("oas31_exact_number.mustache", "model", "Oas31ExactNumber.h")); + supportingFiles.add(new SupportingFile( + "oas31_exact_number_source.mustache", "model", "Oas31ExactNumber.cpp")); + supportingFiles.add(new SupportingFile("oas31_schema_ir.mustache", "model", "Oas31SchemaIr.h")); + supportingFiles.add(new SupportingFile("oas31_deep_equal.mustache", "model", "Oas31DeepEqual.h")); + supportingFiles.add(new SupportingFile("oas31_exact_json.mustache", "model", "Oas31ExactJson.h")); + supportingFiles.add(new SupportingFile("oas31_validator.mustache", "model", "Oas31Validator.h")); + // Generation-time IR tables and optional bounded source chunks. Content + // is rendered from supporting-file data. + supportingFiles.add(new SupportingFile("oas31_schema_ir_header.mustache", "model", "Oas31SchemaRegistry.h")); + supportingFiles.add(new SupportingFile("oas31_schema_ir_source.mustache", "model", "schema_ir.generated.cpp")); languageSpecificPrimitives = new HashSet( - Arrays.asList("int", "char", "bool", "long", "float", "double", "int32_t", "int64_t")); + Arrays.asList("int", "char", "bool", "long", "float", "double", "std::int32_t", "std::int64_t")); super.typeMapping = new HashMap(); typeMapping.put("date", "std::string"); typeMapping.put("DateTime", "std::string"); typeMapping.put("string", "std::string"); - typeMapping.put("integer", "int32_t"); - typeMapping.put("long", "int64_t"); + typeMapping.put("integer", "std::int32_t"); + typeMapping.put("long", "std::int64_t"); typeMapping.put("boolean", "bool"); typeMapping.put("array", "std::vector"); + // uniqueItems constrains JSON arrays; it does not change their ordered wire representation. + typeMapping.put("set", "std::vector"); typeMapping.put("map", "std::map"); typeMapping.put("file", "std::string"); typeMapping.put("object", "boost::json::value"); @@ -123,7 +489,7 @@ public CppBoostBeastClientCodegen() { typeMapping.put("UUID", "std::string"); typeMapping.put("URI", "std::string"); typeMapping.put("ByteArray", "std::string"); - + super.importMapping = new HashMap(); importMapping.put("std::vector", "#include "); importMapping.put("std::map", "#include "); @@ -133,35 +499,81 @@ 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\""); } + @Override + protected ImmutableMap.Builder addMustacheLambdas() { + return super.addMustacheLambdas() + .put("cppStringLiteral", (fragment, writer) -> writer.write( + escapeCppStringContent( + StringEscapeUtils.unescapeHtml4(fragment.execute())))); + } @Override - public Map updateAllModels(Map objs) { - // Index all CodegenModels by model name. - Map allModels = getAllModels(objs); - - // Clean interfaces of ambiguity - for (Map.Entry cm : allModels.entrySet()) { - if (cm.getValue().interfaces != null && !cm.getValue().interfaces.isEmpty()) { - List newIntf = new ArrayList<>(cm.getValue().interfaces); - - for (String intf : allModels.get(cm.getKey()).interfaces) { - if (allModels.get(intf).interfaces != null && !allModels.get(intf).interfaces.isEmpty()) { - for (String intfInner : allModels.get(intf).interfaces) { - newIntf.remove(intfInner); - } - } - } - cm.getValue().interfaces = newIntf; + public String escapeText(String input) { + return input == null ? null : escapeCppStringContent(input); + } + + /** + * Generator-specific normalizer that preserves composition structure + * (branch cardinality, null multiplicity, original keyword) for all + * oneOf/anyOf/anyOf-string-enum schemas. Set-equivalent simplification + * happens later in the generator's semantic analyzer (processComposedModel), + * never in the pre-descriptor normalizer. + */ + public static class CppBoostBeastOpenAPINormalizer extends OpenAPINormalizer { + public CppBoostBeastOpenAPINormalizer(OpenAPI openAPI, Map inputRules) { + super(openAPI, inputRules); + } + + @Override + protected Schema processSimplifyAnyOf(Schema schema) { + if (schema.getAnyOf() != null && !schema.getAnyOf().isEmpty()) { + return schema; } + return super.processSimplifyAnyOf(schema); } - objs = super.updateAllModels(objs); - return objs; + @Override + protected Schema processSimplifyOneOf(Schema schema) { + if (schema.getOneOf() != null && !schema.getOneOf().isEmpty()) { + return schema; + } + return super.processSimplifyOneOf(schema); + } + + @Override + protected Schema processSimplifyAnyOfStringAndEnumString(Schema schema) { + if (schema.getAnyOf() != null && !schema.getAnyOf().isEmpty()) { + return schema; + } + return super.processSimplifyAnyOfStringAndEnumString(schema); + } + + @Override + protected Schema processSimplifyOneOfEnum(Schema schema) { + if (schema.getOneOf() != null && !schema.getOneOf().isEmpty()) { + return schema; + } + return super.processSimplifyOneOfEnum(schema); + } + + @Override + protected Schema processSimplifyAnyOfEnum(Schema schema) { + if (schema.getAnyOf() != null && !schema.getAnyOf().isEmpty()) { + return schema; + } + return super.processSimplifyAnyOfEnum(schema); + } } + + /** * Camelize the method name of the getter and setter, but keep underscores at the front * @@ -183,6 +595,72 @@ public String getterAndSetterCapitalize(String name) { return camelize(name); } + private static boolean isSchemaValidationSupportingFile(SupportingFile file) { + String destination = file.getDestinationFilename(); + return "Oas31SchemaRegistry.h".equals(destination) + || "schema_ir.generated.cpp".equals(destination) + || (destination.startsWith("schema_ir.generated.chunk") + && destination.endsWith(".cpp")); + } + + private static Set parseNameSet(Object rawValue, String optionName) { + if (rawValue == null || rawValue.toString().trim().isEmpty()) { + return Collections.emptySet(); + } + Collection values = rawValue instanceof Collection + ? (Collection) rawValue + : Arrays.asList(rawValue.toString().split(",", -1)); + Set result = new LinkedHashSet<>(); + for (Object value : values) { + String name = value == null ? "" : value.toString().trim(); + if (name.isEmpty()) { + throw new IllegalArgumentException(optionName + + " contains an empty operationId"); + } + result.add(name); + } + return Collections.unmodifiableSet(result); + } + + private static Map parseNameMappings( + Object rawValue, String optionName) { + if (rawValue == null || rawValue.toString().trim().isEmpty()) { + return Collections.emptyMap(); + } + Map result = new LinkedHashMap<>(); + if (rawValue instanceof Map) { + for (Map.Entry entry : ((Map) rawValue).entrySet()) { + putNameMapping(result, entry.getKey(), entry.getValue(), optionName); + } + } else { + for (String mapping : rawValue.toString().split(",", -1)) { + int separator = mapping.indexOf('='); + if (separator <= 0 || separator == mapping.length() - 1 + || mapping.indexOf('=', separator + 1) >= 0) { + throw new IllegalArgumentException(optionName + + " entries must use operationId=value syntax: " + mapping); + } + putNameMapping(result, mapping.substring(0, separator), + mapping.substring(separator + 1), optionName); + } + } + return Collections.unmodifiableMap(result); + } + + private static void putNameMapping(Map target, + Object rawKey, Object rawValue, String optionName) { + String key = rawKey == null ? "" : rawKey.toString().trim(); + String value = rawValue == null ? "" : rawValue.toString().trim(); + if (key.isEmpty() || value.isEmpty()) { + throw new IllegalArgumentException(optionName + + " entries require non-empty operationId and value"); + } + if (target.putIfAbsent(key, value) != null) { + throw new IllegalArgumentException(optionName + + " contains duplicate operationId: " + key); + } + } + @Override public void processOpts() { super.processOpts(); @@ -192,10 +670,118 @@ public void processOpts() { throw new IllegalArgumentException("packageName must not be blank"); } additionalProperties.put(CodegenConstants.PACKAGE_NAME, packageName); + Object configuredExportMacro = additionalProperties.get(EXPORT_MACRO); + exportMacro = configuredExportMacro == null + ? "" : configuredExportMacro.toString().trim(); + if (!exportMacro.isEmpty() + && !exportMacro.matches("[A-Za-z_][A-Za-z0-9_]*")) { + throw new IllegalArgumentException( + "exportMacro must be empty or a valid C preprocessor identifier: " + + exportMacro); + } + additionalProperties.put(EXPORT_MACRO, exportMacro); + additionalProperties.put(HAS_EXPORT_MACRO, !exportMacro.isEmpty()); + supportingFiles.removeIf(file -> "ApiExport.h".equals( + file.getDestinationFilename())); + if (!exportMacro.isEmpty()) { + String exportPrefix = toPreprocessorIdentifier(packageName); + additionalProperties.put("exportDefine", exportPrefix + "_EXPORTS"); + additionalProperties.put("exportHeaderGuard", + exportPrefix.toUpperCase(Locale.ROOT) + "_API_EXPORT_H_"); + supportingFiles.add(new SupportingFile( + "api-export.mustache", "api", "ApiExport.h")); + } else { + additionalProperties.remove("exportDefine"); + additionalProperties.remove("exportHeaderGuard"); + } + String modelNamespace = modelPackage.replaceAll("\\.", "::"); additionalProperties.put("modelNamespaceDeclarations", modelPackage.split("\\.")); - additionalProperties.put("modelNamespace", modelPackage.replaceAll("\\.", "::")); + additionalProperties.put("modelNamespace", modelNamespace); + additionalProperties.put("schemaValidationNamespace", + modelNamespace + "::detail::schema_validation"); + additionalProperties.put("schemaValidationHeaderGuardPrefix", + modelPackage.replaceAll("[^A-Za-z0-9]", "_").toUpperCase(Locale.ROOT)); + additionalProperties.put("apiHeaderGuardPrefix", + apiPackage.replaceAll("[^A-Za-z0-9]", "_").toUpperCase(Locale.ROOT)); additionalProperties.put("apiNamespaceDeclarations", apiPackage.split("\\.")); additionalProperties.put("apiNamespace", apiPackage.replaceAll("\\.", "::")); + + if (additionalProperties.containsKey("formatAssertionPolicy")) { + String policy = additionalProperties.get("formatAssertionPolicy") + .toString().trim().toLowerCase(Locale.ROOT); + if (!FORMAT_ASSERTION_POLICY_ANNOTATION.equals(policy)) { + throw new IllegalArgumentException( + "formatAssertionPolicy supports only 'annotation'; " + + "format assertions are not implemented"); + } + } + formatAssertionPolicy = FORMAT_ASSERTION_POLICY_ANNOTATION; + additionalProperties.put("formatAssertionPolicy", formatAssertionPolicy); + + // Configure whether SSE schemas describe the wire representation or the + // parsed JSON event data. Unknown values use the documented default. + if (additionalProperties.containsKey("sseSchemaMode")) { + String raw = additionalProperties.get("sseSchemaMode").toString().trim(); + if (raw.equalsIgnoreCase(SSE_SCHEMA_MODE_JSON_EVENT_DATA)) { + sseSchemaMode = SSE_SCHEMA_MODE_JSON_EVENT_DATA; + } else if (raw.equalsIgnoreCase(SSE_SCHEMA_MODE_REPRESENTATION)) { + sseSchemaMode = SSE_SCHEMA_MODE_REPRESENTATION; + } else { + throw new IllegalArgumentException("sseSchemaMode must be '" + + SSE_SCHEMA_MODE_REPRESENTATION + "' or '" + + SSE_SCHEMA_MODE_JSON_EVENT_DATA + "': " + raw); + } + } + additionalProperties.put("sseSchemaMode", sseSchemaMode); + + sseOperationIds = parseNameSet( + additionalProperties.get("sseOperationIds"), "sseOperationIds"); + sseRequestPropertyMappings = parseNameMappings( + additionalProperties.get("sseRequestPropertyMappings"), + "sseRequestPropertyMappings"); + sseEventTypeMappings = parseNameMappings( + additionalProperties.get("sseEventTypeMappings"), + "sseEventTypeMappings"); + if (additionalProperties.containsKey("inferConditionalSseOperations")) { + Object raw = additionalProperties.get("inferConditionalSseOperations"); + if (raw instanceof Boolean) { + inferConditionalSseOperations = (Boolean) raw; + } else { + String value = raw.toString().trim(); + if (!"true".equalsIgnoreCase(value) && !"false".equalsIgnoreCase(value)) { + throw new IllegalArgumentException( + "inferConditionalSseOperations must be true or false: " + value); + } + inferConditionalSseOperations = Boolean.parseBoolean(value); + } + } + additionalProperties.put("inferConditionalSseOperations", + inferConditionalSseOperations); + + // compileWithValidation controls decode-time composition-branch checks. + // Representation safety checks remain active regardless of this option. + if (additionalProperties.containsKey("compileWithValidation")) { + Object raw = additionalProperties.get("compileWithValidation"); + if (raw instanceof Boolean) { + validateOnDecode = (Boolean) raw; + } else { + validateOnDecode = Boolean.parseBoolean(raw.toString().trim()); + } + } + additionalProperties.put("validateOnDecode", validateOnDecode); + additionalProperties.put("compileWithValidation", validateOnDecode); + if (!validateOnDecode) { + supportingFiles.removeIf(CppBoostBeastClientCodegen::isSchemaValidationSupportingFile); + } + if (additionalProperties.containsKey("tolerateNonNullableNulls")) { + Object raw = additionalProperties.get("tolerateNonNullableNulls"); + if (raw instanceof Boolean) { + tolerateNonNullableNulls = (Boolean) raw; + } else { + tolerateNonNullableNulls = Boolean.parseBoolean(raw.toString().trim()); + } + } + additionalProperties.put("tolerateNonNullableNulls", tolerateNonNullableNulls); } /** @@ -227,11 +813,125 @@ public String toModelImport(String name) { @Override public CodegenModel fromModel(String name, Schema model) { - CodegenModel codegenModel = super.fromModel(name, model); + // Flatten allOf into a synthetic schema with intersected properties and + // unioned required names. Clearing allOf gives every property direct owned + // storage rather than generated inheritance. + Schema modelArg = model; + if (model != null && model.getAllOf() != null && !model.getAllOf().isEmpty()) { + AllOfIntersection intersection = allOfIntersections.get( + toModelName(name)); + if (intersection != null) { + // Check for unsatisfiable required properties / scalar conflicts + if (!intersection.isSatisfiable()) { + throw new AllOfRequiredUnsatisfiableException( + name, intersection.getUnsatisfiableReason()); + } + + Schema synthetic = Oas31CompositionLowering.buildSyntheticAllOfSchema( + name, intersection); + // Copy top-level attributes from original model + if (model.getDiscriminator() != null) { + synthetic.setDiscriminator(model.getDiscriminator()); + } + if (Boolean.TRUE.equals(model.getNullable())) { + synthetic.setNullable(true); + } + if (model.getDescription() != null) { + synthetic.setDescription(model.getDescription()); + } + if (model.getFormat() != null && intersection.getRootScalarType() != null) { + synthetic.setFormat(model.getFormat()); + } + // Optional impossible properties retain their API surface but + // reject any JSON object in which they are present. + if (!intersection.getOptionalImpossibleProperties().isEmpty()) { + Map ext = synthetic.getExtensions(); + if (ext == null) { + ext = new LinkedHashMap<>(); + synthetic.setExtensions(ext); + } + ext.put("x-cpp-optional-impossible-properties", + new ArrayList<>(intersection.getOptionalImpossibleProperties())); + } + // Flat: allOf = null so super.fromModel sees no parent + synthetic.setAllOf(null); + modelArg = synthetic; + } + } + + // 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, modelArg); if (codegenModel == null) { return null; } + codegenModel.vendorExtensions.put( + "x-cpp-component-schema-id", + componentSchemaId(name, componentSchemaIdsByName)); + + // 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 +942,84 @@ 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{\"" + + escapeCppStringContent(constRawValue) + "\"}"; + } else if (isStringConst || "std::string".equals(var.dataType)) { + inlineValue = "\"" + escapeCppStringContent(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; } @@ -249,6 +1027,8 @@ public CodegenModel fromModel(String name, Schema model) { @Override public CodegenParameter fromParameter(Parameter parameter, Set imports) { CodegenParameter codegenParameter = super.fromParameter(parameter, imports); + // Preserve serialization facts for every parameter location. + codegenParameterStyled(parameter, codegenParameter); if (!codegenParameter.isQueryParam) { return codegenParameter; } @@ -256,56 +1036,36 @@ public CodegenParameter fromParameter(Parameter parameter, Set imports) if (!codegenParameter.required) { codegenParameter.vendorExtensions.put(X_CODEGEN_IS_OPTIONAL_QUERY_PARAMETER, true); } - if (!codegenParameter.isArray && !codegenParameter.isMap) { - return codegenParameter; - } + return codegenParameter; + } - // OAS 3 query parameters default to form/explode=true. DefaultCodegen - // currently represents an omitted style as CSV, so normalize it here. - boolean usesExplodedFormStyle = !Boolean.FALSE.equals(parameter.getExplode()) - && (parameter.getStyle() == null || parameter.getStyle() == Parameter.StyleEnum.FORM); - if (codegenParameter.isMap) { - if (parameter.getStyle() == Parameter.StyleEnum.DEEPOBJECT) { - codegenParameter.vendorExtensions.put(X_CODEGEN_QUERY_MAP_DEEP_OBJECT, true); - } else if (usesExplodedFormStyle) { - codegenParameter.vendorExtensions.put(X_CODEGEN_QUERY_MAP_EXPLODED, true); + /** + * Records the OAS 3.1 serialization facts consumed by the C++ wire layer. + * Style defaults to form for query/cookie and simple for path/header. Explode + * defaults to true only for form. allowReserved is surfaced consistently; + * allowEmptyValue applies only to form-style query parameters. + */ + private void codegenParameterStyled(Parameter parameter, + CodegenParameter codegenParameter) { + String style = parameter.getStyle() == null + ? null : parameter.getStyle().toString(); + if (style == null) { + if (codegenParameter.isQueryParam || codegenParameter.isCookieParam) { + style = "form"; } else { - codegenParameter.vendorExtensions.put( - X_CODEGEN_QUERY_COLLECTION_DELIMITER, - queryCollectionDelimiter(parameter.getStyle())); + style = "simple"; // path, header } - return codegenParameter; } - - boolean isMulti = codegenParameter.isCollectionFormatMulti || usesExplodedFormStyle; - if (isMulti) { - codegenParameter.isCollectionFormatMulti = true; - codegenParameter.collectionFormat = "multi"; - codegenParameter.vendorExtensions.put(X_CODEGEN_QUERY_COLLECTION_MULTI, true); - return codegenParameter; - } - - String collectionDelimiter; - switch (codegenParameter.collectionFormat) { - case "csv": - collectionDelimiter = ","; - break; - case "ssv": - collectionDelimiter = "%20"; - break; - case "tsv": - collectionDelimiter = "%09"; - break; - case "pipes": - collectionDelimiter = "%7C"; - break; - default: - throw new IllegalArgumentException( - "Unsupported query collection format: " + codegenParameter.collectionFormat); + Boolean explode = Boolean.TRUE.equals(parameter.getExplode()); + if (parameter.getExplode() == null) { + explode = "form".equals(style); // spec default } - codegenParameter.vendorExtensions.put( - X_CODEGEN_QUERY_COLLECTION_DELIMITER, collectionDelimiter); - return codegenParameter; + codegenParameter.vendorExtensions.put(X_CODEGEN_PARAM_STYLE, style); + codegenParameter.vendorExtensions.put(X_CODEGEN_PARAM_EXPLODE, explode); + codegenParameter.vendorExtensions.put(X_CODEGEN_PARAM_ALLOW_RESERVED, + Boolean.TRUE.equals(parameter.getAllowReserved())); + codegenParameter.vendorExtensions.put(X_CODEGEN_PARAM_ALLOW_EMPTY_VALUE, + Boolean.TRUE.equals(parameter.getAllowEmptyValue())); } private String queryCollectionDelimiter(Parameter.StyleEnum style) { @@ -338,77 +1098,23 @@ public String toApiFilename(String name) { return toApiName(name); } - @SuppressWarnings("unchecked") @Override - public OperationsMap postProcessOperationsWithModels(OperationsMap objs, List allModels) { - Map operations = (Map) objs.get("operations"); - List operationList = (List) operations.get("operation"); - List newOpList = new ArrayList<>(); - - for (CodegenOperation op : operationList) { - addApiResponseMetadata(op); - String path = op.path; - - String[] items = path.split("/", -1); - String resourceNameCamelCase = ""; - for (String item : items) { - if (item.length() > 1) { - if (item.matches("^\\{(.*)\\}$")) { - String tmpResourceName = item.substring(1, item.length() - 1); - resourceNameCamelCase += Character.toUpperCase(tmpResourceName.charAt(0)) + tmpResourceName.substring(1); - } else { - resourceNameCamelCase += Character.toUpperCase(item.charAt(0)) + item.substring(1); - } - } else if (item.length() == 1) { - resourceNameCamelCase += Character.toUpperCase(item.charAt(0)); - } - } - op.path = path.replaceFirst("/$", ""); - - op.vendorExtensions.put("x-codegen-resource-name", resourceNameCamelCase); - - boolean foundInNewList = false; - for (CodegenOperation op1 : newOpList) { - if (!foundInNewList) { - if (op1.path.equals(op.path)) { - foundInNewList = true; - final String X_CODEGEN_OTHER_METHODS = "x-codegen-other-methods"; - List currentOtherMethodList = (List) op1.vendorExtensions.get(X_CODEGEN_OTHER_METHODS); - if (currentOtherMethodList == null) { - currentOtherMethodList = new ArrayList<>(); - } - op.operationIdCamelCase = op1.operationIdCamelCase; - currentOtherMethodList.add(op); - op1.vendorExtensions.put(X_CODEGEN_OTHER_METHODS, currentOtherMethodList); - } - } - } - if (!foundInNewList) { - newOpList.add(op); - } - } - operations.put("operation", newOpList); - return objs; + public OperationsMap postProcessOperationsWithModels( + OperationsMap objs, List allModels) { + return new CppBoostBeastTemplateModelAssembler( + sourceOpenApi, + webhookPreservation, + operationCallbacks, + operationLinks, + composedKeywordsByModel, + sseSchemaMode, + sseOperationIds, + sseRequestPropertyMappings, + sseEventTypeMappings, + inferConditionalSseOperations, + hasExplicitRootServers).assemble(objs, allModels); } - private void addApiResponseMetadata(CodegenOperation operation) { - boolean hasDefaultResponse = false; - for (CodegenResponse response : operation.responses) { - response.vendorExtensions.put(X_CODEGEN_EMPTY_BODY_TOLERANT, - response.isMap || response.isFreeFormObject || response.isAnyType); - if (response.isRange()) { - response.vendorExtensions.put( - X_CODEGEN_RESPONSE_RANGE, response.code.substring(0, 1)); - } - - if (response.isDefault) { - hasDefaultResponse = true; - response.vendorExtensions.put(X_CODEGEN_DEFAULT_RESPONSE_IS_RETURN_COMPATIBLE, - operation.returnType != null && Objects.equals(operation.returnType, response.dataType)); - } - } - operation.vendorExtensions.put(X_CODEGEN_HAS_DEFAULT_RESPONSE, hasDefaultResponse); - } /** * Optional - type declaration. This is a String which is used by the @@ -420,26 +1126,60 @@ 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"; } - return "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 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); + || languageSpecificPrimitives.contains(openAPIType) + || typeMapping.containsKey(openAPIType) + || typeMapping.values().contains(openAPIType)) { + // Resolve through type mapping for scalar allOf: composed schemas + // return OAS raw types (e.g. "string") or mapped types (e.g. + // "std::string") depending on branch resolution path. + // Re-map if the value is already in the type mapping values. + String resolved = typeMapping.containsKey(openAPIType) + ? typeMapping.get(openAPIType) + : 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,14 +1187,231 @@ 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; + } + + // Object references use shared ownership because circular-reference facts + // are unavailable when this declaration is computed. Variant aliases are + // handled above as value types. 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, -1)); + } + + // Deduplicate inside lowerComposedTypes so oneOf branch identity survives + // identical lowered C++ types. + return Oas31CompositionLowering.lowerComposedTypes( + composedBranches, composedKeyword, null, LOGGER::warn); + } + + @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); + } + if (Oas31RawSpecRecovery.hasExplicitDefault(p)) { + String defaultValue = explicitScalarDefaultValue(prop, p); + if (defaultValue != null) { + prop.defaultValue = defaultValue; + prop.vendorExtensions.put("x-cpp-has-explicit-default", true); + prop.vendorExtensions.put(X_CPP_EXPLICIT_DEFAULT_SCALAR, defaultValue); + prop.vendorExtensions.put("x-cpp-default-is-null", + "null".equals(Oas31RawSpecRecovery.defaultJsonOf(p))); + } + } + return prop; + } + + private String explicitScalarDefaultValue(CodegenProperty property, Schema schema) { + String json = Oas31RawSpecRecovery.defaultJsonOf(schema); + if (json == null) { + return null; + } + + com.fasterxml.jackson.databind.JsonNode value; + try { + value = io.swagger.v3.core.util.Json31.mapper().readTree(json); + } catch (com.fasterxml.jackson.core.JsonProcessingException exception) { + throw new IllegalArgumentException( + "Unable to parse default for property '" + property.baseName + "'", exception); + } + if (value == null || !value.isValueNode()) { + return null; + } + + Object nullableInner = property.vendorExtensions.get( + "x-cpp-nullable-field-inner-type"); + if (value.isNull()) { + if (nullableInner != null) { + return "NullableField<" + nullableInner + ">::makeDefaultNull()"; + } + if (property.dataType != null + && property.dataType.startsWith("std::optional<")) { + return "std::nullopt"; + } + if ("std::nullptr_t".equals(property.dataType)) { + return "nullptr"; + } + if ("boost::json::value".equals(property.dataType)) { + return "boost::json::value(nullptr)"; + } + if (property.dataType != null + && property.dataType.startsWith("std::shared_ptr<")) { + // A branch-local default:null is an annotation, not a model value. + // Ignore it rather than rejecting an otherwise legal schema. + return null; + } + + throw new IllegalArgumentException( + "JSON null default is not representable by C++ property '" + + property.baseName + "' of type " + property.dataType); + } + + String expression; + if (value.isTextual()) { + expression = "\"" + escapeCppStringContent(value.textValue()) + "\""; + } else if (value.isBoolean()) { + expression = value.booleanValue() ? "true" : "false"; + } else if (value.isNumber()) { + expression = explicitNumericDefault(property, value.decimalValue()); + } else { + return null; + } + + if (nullableInner != null) { + return "NullableField<" + nullableInner + ">::makeDefaultValue(" + + expression + ")"; + } + return expression; + } + + private static String explicitNumericDefault( + CodegenProperty property, java.math.BigDecimal value) { + if (property.isInteger || property.isLong) { + java.math.BigInteger integer; + try { + integer = value.toBigIntegerExact(); + } catch (ArithmeticException exception) { + throw new IllegalArgumentException( + "Non-integral default is not representable by integer property '" + + property.baseName + "'", exception); + } + if (property.isLong || "std::int64_t".equals(property.dataType)) { + java.math.BigInteger min = java.math.BigInteger.valueOf(Long.MIN_VALUE); + java.math.BigInteger max = java.math.BigInteger.valueOf(Long.MAX_VALUE); + if (integer.compareTo(min) < 0 || integer.compareTo(max) > 0) { + throw new IllegalArgumentException( + "Default is outside int64 range for property '" + + property.baseName + "'"); + } + if (integer.equals(min)) { + return "std::int64_t{-9223372036854775807LL - 1LL}"; + } + return "std::int64_t{" + integer + "LL}"; + } + try { + return "std::int32_t{" + integer.intValueExact() + "}"; + } catch (ArithmeticException exception) { + throw new IllegalArgumentException( + "Default is outside int32 range for property '" + + property.baseName + "'", exception); + } + } + + String literal = value.toString(); + boolean hasFloatingMarker = literal.indexOf('.') >= 0 + || literal.indexOf('e') >= 0 || literal.indexOf('E') >= 0; + if (!hasFloatingMarker) { + literal += ".0"; + } + if (property.isFloat || "float".equals(property.dataType)) { + float narrowed = value.floatValue(); + if (!Float.isFinite(narrowed) + || (value.signum() != 0 && narrowed == 0.0f)) { + throw new IllegalArgumentException( + "Default is outside finite float range for property '" + + property.baseName + "'"); + } + return literal + "F"; + } + double narrowed = value.doubleValue(); + if (!Double.isFinite(narrowed) + || (value.signum() != 0 && narrowed == 0.0)) { + throw new IllegalArgumentException( + "Default is outside finite double range for property '" + + property.baseName + "'"); + } + return literal; + } + @Override public String toDefaultValue(Schema p) { if (ModelUtils.isStringSchema(p)) { if (p.getDefault() != null) { - return "\"" + p.getDefault().toString() + "\""; + return "\"" + escapeCppStringContent(p.getDefault().toString()) + "\""; } else { return "\"\""; } @@ -466,13 +1423,13 @@ public String toDefaultValue(Schema p) { } } else if (ModelUtils.isDateSchema(p)) { if (p.getDefault() != null) { - return "\"" + p.getDefault().toString() + "\""; + return "\"" + escapeCppStringContent(p.getDefault().toString()) + "\""; } else { return "\"\""; } } else if (ModelUtils.isDateTimeSchema(p)) { if (p.getDefault() != null) { - return "\"" + p.getDefault().toString() + "\""; + return "\"" + escapeCppStringContent(p.getDefault().toString()) + "\""; } else { return "\"\""; } @@ -506,7 +1463,7 @@ public String toDefaultValue(Schema p) { } } else if (ModelUtils.isByteArraySchema(p)) { if (p.getDefault() != null) { - return "\"" + p.getDefault().toString() + "\""; + return "\"" + escapeCppStringContent(p.getDefault().toString()) + "\""; } else { return "\"\""; } @@ -520,7 +1477,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)) { @@ -539,10 +1500,43 @@ public String toDefaultValue(CodegenProperty codegenProperty, Schema schema) { if ("boost::json::value".equals(codegenProperty.dataType)) { return "boost::json::value()"; } + Schema referenceSchema = Oas31CompositionLowering.referenceSchemaOf(schema); + if (referenceSchema != null && referenceSchema != schema + && schema.getDefault() == null) { + Schema referencedTarget = ModelUtils.getReferencedSchema(openAPI, referenceSchema); + if (referencedTarget != null && referencedTarget != referenceSchema + && codegenProperty.dataType != null + && codegenProperty.dataType.equals(getTypeDeclaration(referencedTarget))) { + return toDefaultValue(referencedTarget); + } + } } return super.toDefaultValue(codegenProperty, schema); } + @Override + public void setParameterEncodingValues(CodegenParameter codegenParameter, MediaType mediaType) { + super.setParameterEncodingValues(codegenParameter, mediaType); + // Detect Encoding Object headers that cannot be propagated to + // multipart parts. When an Encoding Object specifies headers, + // emit a diagnostic instead of silently dropping them. + if (codegenParameter.isFormParam && mediaType != null + && mediaType.getEncoding() != null) { + io.swagger.v3.oas.models.media.Encoding encoding = + mediaType.getEncoding().get(codegenParameter.baseName); + if (encoding != null && encoding.getHeaders() != null + && !encoding.getHeaders().isEmpty()) { + LOGGER.warn("Encoding Object on form parameter '{}' specifies {} header(s) " + + "that are not propagated to the multipart part. " + + "Generated code uses only the contentType field. " + + "Header keys: {}", + codegenParameter.baseName, + encoding.getHeaders().size(), + encoding.getHeaders().keySet()); + } + } + } + @Override public void postProcessParameter(CodegenParameter parameter) { super.postProcessParameter(parameter); @@ -557,9 +1551,73 @@ 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; + } + } + + // For form params, validate that encoding style/explode combinations + // are representable in multipart/form-data. Only form-style is supported + // for multipart (space-delimited, pipe-delimited, and deep-object styles + // are not representable). Fail closed with a targeted diagnostic. + if (parameter.isFormParam) { + if (Boolean.TRUE.equals(parameter.isSpaceDelimited)) { + throw new UnsupportedSchemaAssertionException( + parameter.baseName, + "encoding-style"); + } + if (Boolean.TRUE.equals(parameter.isPipeDelimited)) { + throw new UnsupportedSchemaAssertionException( + parameter.baseName, + "encoding-style"); + } + if (Boolean.TRUE.equals(parameter.isDeepObject)) { + throw new UnsupportedSchemaAssertionException( + parameter.baseName, + "encoding-style"); + } + } + + // 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 +1630,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; @@ -592,4 +1656,58 @@ public void updateCodegenPropertyEnum(CodegenProperty var) { super.updateCodegenPropertyEnum(var); var.defaultValue = originalDefaultValue; } -} + @Override + public Map updateAllModels(Map objs) { + Map updatedModels = super.updateAllModels(objs); + refreshComponentSchemaIds(openAPI); + for (Map.Entry entry : updatedModels.entrySet()) { + for (ModelMap modelMap : entry.getValue().getModels()) { + CodegenModel model = modelMap.getModel(); + String schemaName = model.schemaName != null ? model.schemaName : entry.getKey(); + model.vendorExtensions.put("x-cpp-component-schema-id", + componentSchemaId(schemaName, componentSchemaIdsByName)); + } + } + return updatedModels; + } + + private void refreshComponentSchemaIds(OpenAPI openApi) { + if (openApi == null || openApi.getComponents() == null + || openApi.getComponents().getSchemas() == null) { + componentSchemaIdsByName = Collections.emptyMap(); + return; + } + componentSchemaIdsByName = componentSchemaIds( + openApi.getComponents().getSchemas().keySet()); + } + + + + @Override + public Map postProcessSupportingFileData(Map objs) { + Map processed = super.postProcessSupportingFileData(objs); + if (!validateOnDecode) { + return processed; + } + // Model processing can replace inline branch schema objects after the + // initial recovery pass; refresh the emitted graph from the raw spec. + Oas31RawSpecRecovery.recoverPristineLiterals(openAPI, getInputSpec()); + refreshComponentSchemaIds(openAPI); + Oas31SchemaIrEmitter emitter = new Oas31SchemaIrEmitter( + openAPI, compositionDescriptors, additionalProperties(), componentSchemaIdsByName); + Map produced = emitter.produce(processed); + supportingFiles.removeIf(file -> { + String destination = file.getDestinationFilename(); + return destination.startsWith("schema_ir.generated.chunk") + && destination.endsWith(".cpp"); + }); + int chunkCount = ((Number) produced.get("oas31SchemaIrChunkCount")).intValue(); + for (int chunk = 0; chunk < chunkCount; chunk++) { + supportingFiles.add(new SupportingFile( + Oas31SchemaIrEmitter.schemaIrChunkTemplate(chunk), + "model", Oas31SchemaIrEmitter.schemaIrChunkFilename(chunk))); + } + return produced; + } + + } diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/CppBoostBeastModelCodegen.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/CppBoostBeastModelCodegen.java new file mode 100644 index 000000000000..7e24ce105452 --- /dev/null +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/CppBoostBeastModelCodegen.java @@ -0,0 +1,2014 @@ +/* + * Copyright 2026 OpenAPI-Generator Contributors (https://openapi-generator.tech) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.openapitools.codegen.languages; + +import com.fasterxml.jackson.databind.JsonNode; +import com.google.common.collect.ImmutableMap; +import com.samskivert.mustache.Mustache.Lambda; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.Operation; +import io.swagger.v3.oas.models.PathItem; +import io.swagger.v3.oas.models.media.MediaType; +import io.swagger.v3.oas.models.media.Schema; +import io.swagger.v3.oas.models.parameters.Parameter; +import io.swagger.v3.oas.models.responses.ApiResponse; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.text.StringEscapeUtils; +import org.openapitools.codegen.*; +import org.openapitools.codegen.languages.Oas31CompositionLowering.AllOfIntersection; +import org.openapitools.codegen.languages.Oas31CompositionLowering.CompositionBranchDescriptor; +import org.openapitools.codegen.languages.Oas31CompositionLowering.CompositionDescriptor; +import org.openapitools.codegen.languages.Oas31CompositionLowering.DiscriminatorDescriptor; +import org.openapitools.codegen.meta.features.*; +import org.openapitools.codegen.model.ModelMap; +import org.openapitools.codegen.model.ModelsMap; +import org.openapitools.codegen.model.OperationsMap; +import org.openapitools.codegen.utils.ModelUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.File; +import java.util.*; +import java.util.Map; +import java.util.HashMap; +import java.util.stream.Collectors; + +import static org.openapitools.codegen.utils.StringUtils.camelize; + +/** + * Model-lowering and composition post-processing shared by the Boost.Beast + * generator. Operation assembly and output configuration stay on the client + * generator subclass. + */ +public abstract class CppBoostBeastModelCodegen extends AbstractCppCodegen { + protected static final String X_CPP_EXPLICIT_DEFAULT_SCALAR = + "x-cpp-explicit-default-scalar"; + protected static final String X_CPP_TOLERATE_NONNULLABLE_NULL = + "x-cpp-tolerate-nonnullable-null"; + private static final String SHARED_PTR_PREFIX = "std::shared_ptr<"; + /** Compatibility mode for server responses that send undeclared nulls. */ + protected boolean tolerateNonNullableNulls = true; + protected final Logger LOGGER = LoggerFactory.getLogger(CppBoostBeastClientCodegen.class); + /** Tracks model names resolved as oneOf/anyOf variant types for shared_ptr exclusion. */ + protected Set variantModels = new HashSet<>(); + /** Caches resolved C++ types for composed models so postProcessModels can + * transitively resolve $ref chains through model aliases (for example, + * ModelIds referencing ModelIdsShared, both ultimately std::string). */ + protected Map resolvedAliasTypes = new HashMap<>(); + /** Retains composition semantics after named schemas are lowered to C++ aliases. */ + protected Map composedKeywordsByModel = new HashMap<>(); + /** Descriptor index mapping schema name to composition descriptor, populated + * in preprocessOpenAPI after inline model flattening. Replaces raw schema + * inspection as the semantic source for branch lowering. */ + protected Map compositionDescriptors = new LinkedHashMap<>(); + /** All descriptors indexed for schemas that combine composition keywords. */ + protected Map> compositionDescriptorSets = + new LinkedHashMap<>(); + /** OpenAPI document retained for operation and model post-processing. */ + protected OpenAPI sourceOpenApi; + /** Whether the source document explicitly declares its root servers field. */ + protected boolean hasExplicitRootServers; + + /** Preserved inbound-only webhook metadata. Webhooks are removed from + * outbound API generation so upstream folding cannot replace path APIs. */ + protected List webhookPreservation = new ArrayList<>(); + + public List getWebhookPreservation() { + return new ArrayList<>(webhookPreservation); + } + + protected static String idOf(io.swagger.v3.oas.models.Operation op) { + return op.getOperationId() == null ? "(no operationId)" : op.getOperationId(); + } + + /** Callback and response-link names keyed by path and method. */ + protected Map> operationCallbacks = new HashMap<>(); + protected Map> operationLinks = new HashMap<>(); + + protected void captureOperationMetadata(OpenAPI openAPI) { + operationCallbacks.clear(); + operationLinks.clear(); + if (openAPI == null || openAPI.getPaths() == null) { + return; + } + for (Map.Entry pathEntry : openAPI.getPaths().entrySet()) { + PathItem pathItem = pathEntry.getValue(); + if (pathItem == null || pathItem.readOperationsMap() == null) { + continue; + } + for (Map.Entry operationEntry + : pathItem.readOperationsMap().entrySet()) { + Operation operation = operationEntry.getValue(); + if (operation == null) { + continue; + } + String key = pathEntry.getKey() + '\0' + operationEntry.getKey().name(); + List callbackNames = operation.getCallbacks() == null + ? Collections.emptyList() + : new ArrayList<>(operation.getCallbacks().keySet()); + operationCallbacks.put(key, callbackNames); + + Set linkNames = new LinkedHashSet<>(); + if (operation.getResponses() != null) { + for (ApiResponse candidate : operation.getResponses().values()) { + if (candidate == null) { + continue; + } + if (candidate.getLinks() != null) { + linkNames.addAll(candidate.getLinks().keySet()); + } + ApiResponse resolved = ModelUtils.getReferencedApiResponse( + openAPI, candidate); + if (resolved != null && resolved.getLinks() != null) { + linkNames.addAll(resolved.getLinks().keySet()); + } + } + } + operationLinks.put(key, new ArrayList<>(linkNames)); + } + } + } + /** Cached allOf intersections keyed by model name. Populated during + * preprocessOpenAPI and consumed by fromModel to build synthetic schemas. */ + protected Map allOfIntersections = new LinkedHashMap<>(); + @Override + public Map updateAllModels(Map objs) { + // Index all CodegenModels by model name. + Map allModels = getAllModels(objs); + + // Clean interfaces of ambiguity + for (Map.Entry cm : allModels.entrySet()) { + if (cm.getValue().interfaces != null && !cm.getValue().interfaces.isEmpty()) { + List newIntf = new ArrayList<>(cm.getValue().interfaces); + + for (String intf : allModels.get(cm.getKey()).interfaces) { + if (allModels.get(intf).interfaces != null && !allModels.get(intf).interfaces.isEmpty()) { + for (String intfInner : allModels.get(intf).interfaces) { + newIntf.remove(intfInner); + } + } + } + cm.getValue().interfaces = newIntf; + } + } + + // --- 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, so cycle edges would lose shared_ptr wrappers and emit + // invalid value self-references. + // + // 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, modelSaves); + } + } + } + + // Phase: Strip std::shared_ptr from non-cyclic object refs. + // DefaultCodegen only records the immediate container item type, while + // nested containers can contain a recursive model reference farther down. + // Build the full model-reference graph before changing any C++ types. + Map> cyclicModelReferences = findCyclicModelReferences(allModels); + for (CodegenModel cm : allModels.values()) { + Set cyclicTargets = cyclicModelReferences.getOrDefault( + cm.classname, Collections.emptySet()); + for (CodegenProperty var : allVarsOf(cm)) { + stripNonCyclicSharedPtrs(var, cyclicTargets); + } + } + + 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; + } + } + + /** + * Finds model-reference edges that participate in a cycle, including model + * references nested inside array and map C++ types. + */ + private static Map> findCyclicModelReferences( + Map allModels) { + Map> dependencies = new LinkedHashMap<>(); + for (CodegenModel model : allModels.values()) { + dependencies.putIfAbsent(model.classname, new LinkedHashSet<>()); + } + + Set modelNames = dependencies.keySet(); + for (CodegenModel model : allModels.values()) { + Set references = dependencies.get(model.classname); + for (CodegenProperty property : allVarsOf(model)) { + collectModelReferences(property == null ? null : property.dataType, + modelNames, references); + } + } + + Map> cyclicReferences = new LinkedHashMap<>(); + for (Map.Entry> entry : dependencies.entrySet()) { + Set cyclicTargets = new LinkedHashSet<>(); + for (String target : entry.getValue()) { + if (hasModelPath(target, entry.getKey(), dependencies)) { + cyclicTargets.add(target); + } + } + cyclicReferences.put(entry.getKey(), cyclicTargets); + } + return cyclicReferences; + } + + private static void collectModelReferences(String dataType, Set modelNames, + Set references) { + if (dataType == null) { + return; + } + int cursor = 0; + while (cursor < dataType.length()) { + int pointerStart = dataType.indexOf(SHARED_PTR_PREFIX, cursor); + if (pointerStart < 0) { + return; + } + int contentStart = pointerStart + SHARED_PTR_PREFIX.length(); + int pointerEnd = matchingTemplateEnd(dataType, contentStart); + if (pointerEnd < 0) { + return; + } + String pointedType = dataType.substring(contentStart, pointerEnd); + if (modelNames.contains(pointedType)) { + references.add(pointedType); + } + collectModelReferences(pointedType, modelNames, references); + cursor = pointerEnd + 1; + } + } + + private static boolean hasModelPath(String start, String target, + Map> dependencies) { + Deque pending = new ArrayDeque<>(); + Set visited = new HashSet<>(); + pending.add(start); + while (!pending.isEmpty()) { + String current = pending.removeFirst(); + if (!visited.add(current)) { + continue; + } + if (target.equals(current)) { + return true; + } + pending.addAll(dependencies.getOrDefault(current, Collections.emptySet())); + } + return false; + } + + /** + * Removes every shared_ptr wrapper whose referenced model is not on a + * recursive edge. The parser tracks nested container structure, but the + * rendered C++ dataType is authoritative for replacing every level. + */ + private static void stripNonCyclicSharedPtrs(CodegenProperty property, + Set cyclicTargets) { + if (property == null) { + return; + } + String strippedDataType = stripNonCyclicSharedPtrType(property.dataType, cyclicTargets); + if (!Objects.equals(property.dataType, strippedDataType)) { + property.dataType = strippedDataType; + property.defaultValue = null; + } + stripNonCyclicSharedPtrs(property.items, cyclicTargets); + } + + private static String stripNonCyclicSharedPtrType(String dataType, + Set cyclicTargets) { + if (dataType == null) { + return null; + } + + StringBuilder result = new StringBuilder(dataType.length()); + int cursor = 0; + while (cursor < dataType.length()) { + int pointerStart = dataType.indexOf(SHARED_PTR_PREFIX, cursor); + if (pointerStart < 0) { + result.append(dataType, cursor, dataType.length()); + break; + } + result.append(dataType, cursor, pointerStart); + int contentStart = pointerStart + SHARED_PTR_PREFIX.length(); + int pointerEnd = matchingTemplateEnd(dataType, contentStart); + if (pointerEnd < 0) { + return dataType; + } + + String pointedType = dataType.substring(contentStart, pointerEnd); + String strippedPointedType = stripNonCyclicSharedPtrType(pointedType, cyclicTargets); + if (cyclicTargets.contains(pointedType)) { + result.append(SHARED_PTR_PREFIX).append(strippedPointedType).append('>'); + } else { + result.append(strippedPointedType); + } + cursor = pointerEnd + 1; + } + return result.toString(); + } + + private static int matchingTemplateEnd(String dataType, int contentStart) { + int depth = 1; + for (int index = contentStart; index < dataType.length(); index++) { + char character = dataType.charAt(index); + if (character == '<') { + depth++; + } else if (character == '>' && --depth == 0) { + return index; + } + } + return -1; + } + + @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); + + // Lower oneOf/anyOf models before template-dispatch metadata is derived. + for (ModelMap mo : result.getModels()) { + processComposedModel(mo.getModel()); + } + + // 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). + // A descriptor, when present, is the semantic source rather than dataType. + for (ModelMap mo : result.getModels()) { + CodegenModel cm = mo.getModel(); + if (cm.vendorExtensions.containsKey("x-cpp-is-alias")) { + continue; + } + if (compositionDescriptors.containsKey(cm.classname)) { + continue; // descriptor provides semantics; skip dataType heuristic + } + 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. + // Apply only when a composition descriptor establishes the schema semantics. + for (ModelMap mo : result.getModels()) { + CodegenModel cm = mo.getModel(); + if (cm.vendorExtensions.containsKey("x-cpp-is-alias")) { + continue; + } + if (compositionDescriptors.containsKey(cm.classname)) { + continue; // descriptor provides semantics; skip dataType heuristic + } + 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"); + } + } + + // Recover all-null oneOf/anyOf models that still reach model processing + // as one std::nullptr_t branch. Preserve the authored cardinality with + // tagged alternatives and add matching descriptors so the schema IR + // owns every generated branch validator. + for (ModelMap mo : result.getModels()) { + CodegenModel cm = mo.getModel(); + String checkType = (String) cm.vendorExtensions.get("x-cpp-type"); + if (checkType == null && cm.isAlias) { + checkType = cm.dataType; + } + if ("std::nullptr_t".equals(checkType) + && !cm.vendorExtensions.containsKey("x-cpp-is-variant")) { + CompositionDescriptor descriptor = compositionDescriptors.get(cm.classname); + int branchCount = descriptor != null && descriptor.getBranches().size() > 1 + ? descriptor.getBranches().size() : 2; + String keyword = descriptor == null ? "oneOf" : descriptor.getKeyword(); + boolean isNullComposition = branchCount > 1 + && ("oneOf".equals(keyword) || "anyOf".equals(keyword)); + if (isNullComposition) { + String rawSchemaName = cm.schemaName != null && !cm.schemaName.isEmpty() + ? cm.schemaName : cm.classname; + if (descriptor == null || descriptor.getBranches().size() != branchCount) { + List nullBranches = new ArrayList<>(); + String validatorPrefix = toValidIdentifier(rawSchemaName); + for (int bi = 0; bi < branchCount; bi++) { + String storageType = + "CompositionBranchValue<" + bi + ", std::nullptr_t>"; + Map validateParams = new LinkedHashMap<>(); + validateParams.put("validation-type", "null"); + nullBranches.add(new CompositionBranchDescriptor( + bi, null, "null", storageType, + validatorPrefix + "_branch_" + bi, + CompositionBranchDescriptor.NullCapability.ALWAYS, + List.of("type"), Collections.emptyList(), validateParams)); + } + String schemaLocation = descriptor != null + ? descriptor.getSchemaLocation() + : "#/components/schemas/" + + rawSchemaName.replace("~", "~0") + .replace("/", "~1"); + descriptor = new CompositionDescriptor( + rawSchemaName, schemaLocation, keyword, nullBranches, null); + compositionDescriptors.put(cm.classname, descriptor); + } + + List tagged = new ArrayList<>(); + for (int bi = 0; bi < branchCount; bi++) { + tagged.add("CompositionBranchValue<" + bi + ", std::nullptr_t>"); + } + String variantType = "std::variant<" + String.join(", ", tagged) + ">"; + cm.vendorExtensions.put("x-cpp-type", variantType); + cm.dataType = variantType; + resolvedAliasTypes.put(cm.classname, variantType); + variantModels.add(cm.classname); + cm.vendorExtensions.put("x-cpp-is-variant", true); + cm.vendorExtensions.put("x-cpp-is-alias", true); + cm.vendorExtensions.put("x-cpp-has-duplicate-types", true); + cm.vendorExtensions.put("x-cpp-composed-keyword", keyword); + composedKeywordsByModel.put(cm.classname, keyword); + cm.vendorExtensions.put("x-cpp-branches", + new ArrayList<>(Collections.nCopies( + branchCount, "std::nullptr_t"))); + + Map templateMap = descriptor.toTemplateMap(); + templateMap.put("has-duplicate-types", true); + @SuppressWarnings("unchecked") + List> branchMaps = + (List>) templateMap.get("branches"); + for (int bi = 0; bi < branchMaps.size(); bi++) { + branchMaps.get(bi).put("storage-cpp-type", + "CompositionBranchValue<" + bi + ", std::nullptr_t>"); + branchMaps.get(bi).put("inner-cpp-type", "std::nullptr_t"); + } + cm.vendorExtensions.put("x-cpp-composition-branches", templateMap); + } + } + } + + // Tag properties whose types already embed optional semantics so the + // template skips redundant IsSet state. + 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); + } + } + } + + + // Cross-model property tagging runs in postProcessAllModels, where the + // complete model index is available. + + // Tag optional-impossible properties from allOf intersections. + // These properties have an empty intersection (e.g., string ∩ integer). + // The generated decode validation rejects the property when present + // in JSON but accepts the object when the property is absent. The + // getter/setter and member are still emitted (non-empty shell). + for (ModelMap mo : result.getModels()) { + CodegenModel cm = mo.getModel(); + @SuppressWarnings("unchecked") + List optImpProps = (List) cm.vendorExtensions + .remove("x-cpp-optional-impossible-properties"); + if (optImpProps == null || optImpProps.isEmpty()) continue; + for (CodegenProperty var : allVarsOf(cm)) { + if (optImpProps.contains(var.baseName)) { + var.vendorExtensions.put("x-cpp-optional-impossible", true); + var.vendorExtensions.put("x-cpp-reject-if-present", true); + } + } + } + + // Emit complete includes for resolved alias and variant types. + 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\"")); + } + + // Phase: Emit x-cpp-composition-branches for allOf models that were + // processed by fromModel (not by processComposedModel). These models + // have descriptors but were bypassed by the oneOf/anyOf lowering loop. + for (ModelMap mo : result.getModels()) { + CodegenModel cm = mo.getModel(); + if (cm.vendorExtensions.containsKey("x-cpp-composition-branches")) { + continue; + } + CompositionDescriptor desc = compositionDescriptors.get(cm.classname); + if (desc != null && "allOf".equals(desc.getKeyword())) { + cm.vendorExtensions.put("x-cpp-composition-branches", desc.toTemplateMap()); + } + } + + // Phase: Convert allOf models with scalar-type intersection (e.g., + // allOf of two string enums, allOf of a scalar type and an object) + // to type aliases when the merged properties are empty. These models + // have an AllOfIntersection with a rootScalarType but no object + // properties, so they should emit "using Name = std::string;" rather + // than an empty class shell. + for (ModelMap mo : result.getModels()) { + CodegenModel cm = mo.getModel(); + if (cm.vendorExtensions.containsKey("x-cpp-is-alias")) { + continue; + } + AllOfIntersection intersection = allOfIntersections.get(cm.classname); + if (intersection == null) { + continue; + } + if (intersection.getRootScalarType() == null) { + continue; + } + // Only convert to alias when the merged properties are empty + // (no object properties from allOf contributors). Models with + // both a root scalar and properties need a class. + if (!intersection.getProperties().isEmpty()) { + continue; + } + if (!intersection.isSatisfiable()) { + continue; + } + // Resolve the root scalar type to its C++ type + String resolvedType = resolveOpenApiTypeName(intersection.getRootScalarType()); + // Apply intersected root-level enum values: if the allOf produces + // an enum intersection (e.g., [a,b] ∩ [b,c] = [b]), keep the type + // as std::string (not an enum class), since the intersection may + // be narrower than the full enum set. + cm.vendorExtensions.put("x-cpp-type", resolvedType); + cm.vendorExtensions.put("x-cpp-is-alias", true); + cm.dataType = resolvedType; + resolvedAliasTypes.put(cm.classname, resolvedType); + cm.vendorExtensions.put("x-cpp-composed-keyword", "allOf"); + composedKeywordsByModel.put(cm.classname, "allOf"); + // Propagate intersected enum values to vendor extensions so the + // alias fromJsonValue template can generate enum validation. + // Enum values are stored as List for Mustache iteration. + if (intersection.getRootEnumValues() != null + && !intersection.getRootEnumValues().isEmpty()) { + List intersectedEnum = new ArrayList<>(); + for (Object ev : intersection.getRootEnumValues()) { + if (ev != null) { + intersectedEnum.add(escapeCppStringContent(ev.toString())); + } + } + cm.vendorExtensions.put("x-cpp-allof-intersected-enum-values", + intersectedEnum); + cm.vendorExtensions.put("x-cpp-allof-intersected-enum", true); + } + } + + return result; + } + + private static boolean hasTaggedCompositionBranches(String resolvedType) { + // Duplicate lowering wraps every outer alternative. A nested variant may + // contain the same tag text without changing the outer storage contract. + return resolvedType != null + && resolvedType.startsWith("std::variant branches, String resolvedType) { + Object metadataObject = model.vendorExtensions.get("x-cpp-composition-branches"); + if (!(metadataObject instanceof Map)) { + return; + } + Map metadata = (Map) metadataObject; + Object branchMapsObject = metadata.get("branches"); + if (!(branchMapsObject instanceof List)) { + return; + } + List> branchMaps = (List>) branchMapsObject; + boolean wrapped = hasTaggedCompositionBranches(resolvedType); + + for (ComposedBranch branch : branches) { + int index = branch.originalBranchIndex; + if (index < 0 || index >= branchMaps.size()) { + continue; + } + Map branchMap = branchMaps.get(index); + if (wrapped) { + branchMap.put("storage-cpp-type", + "CompositionBranchValue<" + index + ", " + branch.cppType + ">"); + branchMap.put("inner-cpp-type", branch.cppType); + } else { + branchMap.put("storage-cpp-type", branch.cppType); + branchMap.remove("inner-cpp-type"); + } + } + + metadata.put("has-duplicate-types", wrapped); + if (wrapped) { + model.vendorExtensions.put("x-cpp-has-duplicate-types", true); + } else { + model.vendorExtensions.remove("x-cpp-has-duplicate-types"); + } + } + + + @Override + public Map postProcessAllModels(Map objs) { + Map processed = super.postProcessAllModels(objs); + Map allModels = getAllModels(processed); + // Resolve composed aliases to a fixed point. An acyclic dependency graph + // must converge within one pass per model. + int maxAliasResolutionPasses = processed.values().stream() + .mapToInt(models -> models.getModels().size()) + .sum() + 1; + boolean typeChanged = true; + int aliasResolutionPass = 0; + while (typeChanged && aliasResolutionPass < maxAliasResolutionPasses) { + typeChanged = false; + aliasResolutionPass++; + 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; + List branchesWithMeta = new ArrayList<>(); + 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 and lose the oneOf overlap + // detection 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). + // + // Preserve the original descriptor index after + // self-referencing branches are filtered. + @SuppressWarnings("unchecked") + List storedIsEnum = (List) cm.vendorExtensions.get("x-cpp-branch-is-enum"); + @SuppressWarnings("unchecked") + List storedOriginalIndices = (List) cm.vendorExtensions + .get("x-cpp-branch-original-index"); + for (int i = 0; i < resolved.size(); i++) { + int descIndex = (storedOriginalIndices != null && i < storedOriginalIndices.size()) + ? storedOriginalIndices.get(i) : 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, descIndex)); + } + CompositionDescriptor descriptor = + compositionDescriptors.get(cm.classname); + newType = Oas31CompositionLowering.lowerComposedTypes( + branchesWithMeta, composedKeyword, descriptor, LOGGER::warn); + } catch (RuntimeException e) { + throw new IllegalStateException( + "Failed to resolve composed aliases for '" + cm.classname + "'", e); + } + 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); + refreshCompositionStorageMetadata(cm, branchesWithMeta, newType); + // Self-reference filtering needs the final post-collapse type, + // not the value cached during the first lowering pass. + if (cm.discriminator != null) { + cm.vendorExtensions.put("x-discriminator-resolved-type", newType); + } + typeChanged = true; + } + } + } + } + if (typeChanged) { + throw new IllegalStateException("Composed alias resolution did not converge"); + } + + // Recompute alias and variant flags after transitive type resolution so + // aliases of variant types inherit variant serialization behavior. + 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); + } + } + } + + // Remove discriminator mappings that resolve to the current model type; + // retaining one would recurse indefinitely and try to construct a variant + // from itself. Update the CodegenDiscriminator consumed by templates. + 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); + } + } + + // Finalize nullable storage only after updateAllModels has identified + // cycles and removed shared_ptr from non-cyclic model references. + for (ModelsMap modelsMap : processed.values()) { + for (ModelMap modelMap : modelsMap.getModels()) { + CodegenModel cm = modelMap.getModel(); + boolean needsNullableFieldInclude = false; + for (CodegenProperty var : allVarsOf(cm)) { + if (tolerateNonNullableNulls && !var.isNullable) { + var.vendorExtensions.put(X_CPP_TOLERATE_NONNULLABLE_NULL, true); + } + if (!var.isNullable || var.dataType == null + || Boolean.TRUE.equals(var.vendorExtensions.get("x-cpp-nullable-field"))) { + continue; + } + String innerType = extractOptionalInnerType(var.dataType); + if (innerType == null && !Boolean.TRUE.equals(var.vendorExtensions + .get(Oas31RawSpecRecovery.LEGACY_NULLABLE_EXT))) { + continue; + } + if (var.isEnum) { + var.vendorExtensions.put( + "x-cpp-enum-value-type", + innerType == null ? var.dataType : innerType); + } + if (var.required) { + if (innerType == null) { + var.dataType = "std::optional<" + var.dataType + ">"; + cm.imports.add("#include "); + var.vendorExtensions.put("x-cpp-no-is-set", true); + } + continue; + } + if (innerType == null) { + innerType = var.dataType; + var.vendorExtensions.put("x-cpp-no-is-set", true); + } + if (Boolean.TRUE.equals(var.vendorExtensions.get( + "x-cpp-has-explicit-default"))) { + if (Boolean.TRUE.equals(var.vendorExtensions.get( + "x-cpp-default-is-null"))) { + var.defaultValue = "NullableField<" + innerType + + ">::makeDefaultNull()"; + } else { + var.defaultValue = "NullableField<" + innerType + + ">::makeDefaultValue(" + var.defaultValue + ")"; + } + var.vendorExtensions.put("x-cpp-member-default", true); + } else { + // DefaultCodegen seeds primitive placeholders even when the + // schema has no default; they are not NullableField values. + var.defaultValue = null; + } + var.dataType = "NullableField<" + innerType + ">"; + var.vendorExtensions.put("x-cpp-nullable-field", true); + var.vendorExtensions.put("x-cpp-nullable-field-inner-type", innerType); + needsNullableFieldInclude = true; + } + if (needsNullableFieldInclude) { + cm.imports.add("#include \"NullableField.h\""); + } + } + } + + // Tag properties that refer to variant aliases so templates use the + // keyword-aware free conversion functions rather than the generic variant + // converter, which always enforces oneOf semantics. This global pass can + // inspect every model and unwrap NullableField before alias lookup. + 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) { + // Strip NullableField wrapper when present: use inner type + // for alias lookup. + String lookupType; + if (Boolean.TRUE.equals(var.vendorExtensions.get("x-cpp-nullable-field"))) { + lookupType = (String) var.vendorExtensions.get("x-cpp-nullable-field-inner-type"); + } else { + lookupType = var.dataType; + } + if (lookupType == null) { + continue; + } + ModelsMap targetEntry = processed.get(lookupType); + 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", lookupType); + rewriteVariantAliasDefault(var, lookupType); + } + } + } + } + } + } + } + + // Include discriminator-mapped models used by generated variant dispatch. + // Without these includes, the conversion functions are undeclared. + 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; + } + + private static void rewriteVariantAliasDefault( + CodegenProperty property, String aliasName) { + Object scalarDefault = property.vendorExtensions.get( + X_CPP_EXPLICIT_DEFAULT_SCALAR); + if (!(scalarDefault instanceof String) + || Boolean.TRUE.equals(property.vendorExtensions.get( + "x-cpp-default-is-null"))) { + return; + } + + String decodedDefault = "fromJsonValue_" + aliasName + + "(boost::json::value(" + scalarDefault + "))"; + Object nullableInner = property.vendorExtensions.get( + "x-cpp-nullable-field-inner-type"); + if (nullableInner != null) { + decodedDefault = "NullableField<" + nullableInner + + ">::makeDefaultValue(" + decodedDefault + ")"; + } + property.defaultValue = decodedDefault; + property.vendorExtensions.put("x-cpp-member-default", true); + } + + /** + * 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) { + // Descriptor-complete path: when composedSchemas were consumed by + // fromModel before we could access them, use the CompositionDescriptor + // built in preprocessOpenAPI to reconstruct branch metadata and + // perform lowering. + CompositionDescriptor desc = compositionDescriptors.get(cm.classname); + if (desc == null || "allOf".equals(desc.getKeyword())) { + return; // allOf models handled separately in postProcessModels + } + processComposedModelFromDescriptor(cm, desc); + 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) { + // Fall through to descriptor path when oneOf/anyOf branches were + // consumed by the default pipeline but a composition descriptor + // still exists (e.g., all branches were self-references or the + // schema uses composedSchemas for allOf only). + CompositionDescriptor desc = compositionDescriptors.get(cm.classname); + if (desc != null && !"allOf".equals(desc.getKeyword())) { + processComposedModelFromDescriptor(cm, desc); + } + return; + } + + // Look up the composition descriptor as the semantic source for lowering. + // When available, descriptor metadata (null capability, assertions, keyword) + // is used by lowerComposedTypes instead of inferring semantics from C++ type + // strings alone. + CompositionDescriptor descriptor = compositionDescriptors.get(cm.classname); + + // 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. + // Deduplicate in lowerComposedTypes so oneOf retains branch identity when + // identical C++ types represent distinct schemas. + // + // Track originalBranchIndex (bi) for descriptor alignment after + // self-referencing branches are filtered out. + List composedBranches = new ArrayList<>(); + for (int bi = 0; bi < branches.size(); bi++) { + CodegenProperty b = branches.get(bi); + String cppType; + if (b.isBinary || b.isFile) { + cppType = "std::vector"; + } else { + String rawType = stripSharedPtr(b.dataType); + if (rawType == null || "null".equals(rawType)) { + cppType = "std::nullptr_t"; + } else { + cppType = resolveOpenApiTypeName(rawType); + } + } + if (cppType != null && 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, bi)); + } + List branchTypes = composedBranches.stream() + .map(cb -> cb.cppType) + .collect(Collectors.toList()); + + String resolvedType; + try { + resolvedType = Oas31CompositionLowering.lowerComposedTypes( + composedBranches, composedKeyword, descriptor, LOGGER::warn); + } catch (RuntimeException e) { + throw new IllegalStateException( + "Failed to lower composed model '" + cm.classname + "'", e); + } + + // Cache the resolved type for transitive alias resolution. + 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); + + // Populate each descriptor branch's storage type and expose duplicate + // alternatives so templates generate CompositionBranchValue accessors. + boolean hasDuplicateTypes = hasTaggedCompositionBranches(resolvedType); + if (descriptor != null) { + Map templateMap = descriptor.toTemplateMap(); + @SuppressWarnings("unchecked") + var templateBranches = (List>) templateMap.get("branches"); + for (int bi = 0; bi < composedBranches.size(); bi++) { + ComposedBranch cb = composedBranches.get(bi); + int descIdx = cb.originalBranchIndex; + if (descIdx >= 0 && descIdx < templateBranches.size()) { + Map tBranch = templateBranches.get(descIdx); + String storageType; + if (hasDuplicateTypes) { + storageType = "CompositionBranchValue<" + descIdx + + ", " + cb.cppType + ">"; + tBranch.put("inner-cpp-type", cb.cppType); + } else { + storageType = cb.cppType; + } + tBranch.put("storage-cpp-type", storageType); + } + } + templateMap.put("has-duplicate-types", hasDuplicateTypes); + cm.vendorExtensions.put("x-cpp-composition-branches", templateMap); + if (hasDuplicateTypes) { + cm.vendorExtensions.put("x-cpp-has-duplicate-types", true); + } + } else { + // Fallback: build branch maps from the composed branches when no + // precomputed descriptor exists (e.g., inline schemas not in the + // component schema index). + List> fallbackBranches = new ArrayList<>(); + for (int bi = 0; bi < composedBranches.size(); bi++) { + ComposedBranch cb = composedBranches.get(bi); + Map branchMap = new LinkedHashMap<>(); + branchMap.put("branch-index", bi); + branchMap.put("source-schema-ref", null); + branchMap.put("resolved-schema-name", cb.cppType); + String storageType = hasDuplicateTypes + ? "CompositionBranchValue<" + bi + ", " + cb.cppType + ">" + : cb.cppType; + branchMap.put("storage-cpp-type", storageType); + if (hasDuplicateTypes) { + branchMap.put("inner-cpp-type", cb.cppType); + } + branchMap.put("validator-id", null); + branchMap.put("null-capability", + "std::nullptr_t".equals(cb.cppType) ? "always" : "never"); + fallbackBranches.add(branchMap); + } + Map fallbackMap = new LinkedHashMap<>(); + fallbackMap.put("schema-name", cm.classname); + fallbackMap.put("schema-location", null); + fallbackMap.put("keyword", composedKeyword); + fallbackMap.put("branches", fallbackBranches); + fallbackMap.put("has-duplicate-types", hasDuplicateTypes); + cm.vendorExtensions.put("x-cpp-composition-branches", fallbackMap); + if (hasDuplicateTypes) { + cm.vendorExtensions.put("x-cpp-has-duplicate-types", true); + } + } + + // Preserve enum identity for the later alias-resolution pass: open strings + // and string enums both lower to std::string, so the C++ type alone cannot + // detect overlap. + List branchIsEnumFlags = composedBranches.stream() + .map(cb -> cb.isEnum) + .collect(Collectors.toList()); + cm.vendorExtensions.put("x-cpp-branch-is-enum", branchIsEnumFlags); + // Preserve descriptor indices when self-referential branches are filtered. + List branchOriginalIndices = composedBranches.stream() + .map(cb -> cb.originalBranchIndex) + .collect(Collectors.toList()); + cm.vendorExtensions.put("x-cpp-branch-original-index", branchOriginalIndices); + + 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()); + // Store the resolved type until all aliases are available for + // discriminator self-reference filtering. + cm.vendorExtensions.put("x-discriminator-resolved-type", resolvedType); + + // Build discriminator-value to branch-index metadata for diagnostic + // ordering. Self-referential mappings are omitted. + if (cm.discriminator != null && cm.discriminator.getMappedModels() != null + && !cm.discriminator.getMappedModels().isEmpty() + && descriptor != null) { + // Filter out self-referential MappedModel entries + Set filtered = new LinkedHashSet<>(); + for (CodegenDiscriminator.MappedModel mm : cm.discriminator.getMappedModels()) { + if (mm.getModelName() == null || !mm.getModelName().equals(cm.classname)) { + filtered.add(mm); + } + } + if (!filtered.isEmpty()) { + List> discBranchIndex = + Oas31CompositionLowering.buildDiscriminatorBranchIndex( + filtered, descriptor.getBranches()); + if (!discBranchIndex.isEmpty()) { + cm.vendorExtensions.put("x-discriminator-branch-index", discBranchIndex); + cm.vendorExtensions.put("x-has-discriminator-branch-index", true); + } + } + } else if (descriptor != null && descriptor.hasDiscriminator()) { + // Fallback: use explicit descriptor mapping when MappedModel unavailable + List> discBranchIndex = + Oas31CompositionLowering.buildDiscriminatorBranchIndex( + descriptor.getDiscriminator().getMapping(), + descriptor.getBranches()); + if (!discBranchIndex.isEmpty()) { + cm.vendorExtensions.put("x-discriminator-branch-index", discBranchIndex); + cm.vendorExtensions.put("x-has-discriminator-branch-index", true); + } + } + } + + // Update data type so templates and references use the resolved type + cm.dataType = resolvedType; + } + + /** + * Descriptor-complete path: process a composed model whose composedSchemas + * were consumed by fromModel, using only the descriptor metadata. + * Reconstructs ComposedBranch entries from descriptor branch schema names, + * resolves C++ types, then runs the same lowering/emission pipeline as + * the normal composedSchemas path. + */ + private void processComposedModelFromDescriptor(CodegenModel cm, + CompositionDescriptor desc) { + List composedBranches = new ArrayList<>(); + List descBranches = desc.getBranches(); + + for (int bi = 0; bi < descBranches.size(); bi++) { + CompositionBranchDescriptor db = descBranches.get(bi); + String resolvedSchemaName = db.getResolvedSchemaName(); + String cppType = resolveOpenApiTypeName(resolvedSchemaName); + + // Skip self-referencing branches + if (cppType != null && cppType.equals(cm.classname)) { + continue; + } + if (cppType == null) { + cppType = resolvedSchemaName; + } + // Skip self-referencing after fallback + if (cppType.equals(cm.classname)) { + continue; + } + + // Determine isEnum from descriptor assertion metadata + boolean isEnum = db.getSupportedAssertions().contains("enum"); + boolean isStringLike = "std::string".equals(cppType); + composedBranches.add(new ComposedBranch(cppType, isEnum, isStringLike, bi)); + } + + List branchTypes = composedBranches.stream() + .map(cb -> cb.cppType) + .collect(Collectors.toList()); + + String resolvedType; + try { + resolvedType = Oas31CompositionLowering.lowerComposedTypes( + composedBranches, desc.getKeyword(), desc, LOGGER::warn); + } catch (RuntimeException e) { + throw new IllegalStateException( + "Failed to lower descriptor-backed model '" + cm.classname + "'", e); + } + + // Cache the resolved type + resolvedAliasTypes.put(cm.classname, resolvedType); + variantModels.add(cm.classname); + + // Populate descriptor storage types, including duplicate-type wrappers. + boolean hasDuplicateTypes = hasTaggedCompositionBranches(resolvedType); + Map descTemplateMap = desc.toTemplateMap(); + { + @SuppressWarnings("unchecked") + var templateBranches = (List>) descTemplateMap.get("branches"); + // When hasDuplicateTypes, all branches (including null) get + // CompositionBranchValue wrapping — match shortcut behavior. + for (int bi = 0; bi < composedBranches.size(); bi++) { + ComposedBranch cb = composedBranches.get(bi); + int descIdx = cb.originalBranchIndex; + if (descIdx >= 0 && descIdx < templateBranches.size()) { + Map tBranch = templateBranches.get(descIdx); + String storageType; + if (hasDuplicateTypes) { + storageType = "CompositionBranchValue<" + descIdx + + ", " + cb.cppType + ">"; + tBranch.put("inner-cpp-type", cb.cppType); + } else { + storageType = cb.cppType; + } + tBranch.put("storage-cpp-type", storageType); + } + } + } + descTemplateMap.put("has-duplicate-types", hasDuplicateTypes); + + // Emit vendor extensions + cm.vendorExtensions.put("x-cpp-type", resolvedType); + cm.vendorExtensions.put("x-cpp-branches", branchTypes); + cm.vendorExtensions.put("x-cpp-composed-keyword", desc.getKeyword()); + composedKeywordsByModel.put(cm.classname, desc.getKeyword()); + cm.vendorExtensions.put("x-cpp-composition-branches", descTemplateMap); + if (hasDuplicateTypes) { + cm.vendorExtensions.put("x-cpp-has-duplicate-types", true); + } + + // Preserve branch metadata for transitive alias resolution. + List branchIsEnumFlags = composedBranches.stream() + .map(cb -> cb.isEnum) + .collect(Collectors.toList()); + cm.vendorExtensions.put("x-cpp-branch-is-enum", branchIsEnumFlags); + List branchOriginalIndices = composedBranches.stream() + .map(cb -> cb.originalBranchIndex) + .collect(Collectors.toList()); + cm.vendorExtensions.put("x-cpp-branch-original-index", branchOriginalIndices); + + if (desc.hasDiscriminator()) { + cm.vendorExtensions.put("x-has-discriminator", true); + cm.vendorExtensions.put("x-discriminator-property", + desc.getDiscriminator().getPropertyName()); + cm.vendorExtensions.put("x-discriminator-mapping", + desc.getDiscriminator().getMapping()); + cm.vendorExtensions.put("x-discriminator-resolved-type", resolvedType); + + // Prefer complete mapped-model metadata for discriminator ordering and + // fall back to explicit descriptor mappings. Omit self-references. + if (cm.discriminator != null && cm.discriminator.getMappedModels() != null + && !cm.discriminator.getMappedModels().isEmpty()) { + // Filter out self-referential MappedModel entries + Set filtered = new LinkedHashSet<>(); + for (CodegenDiscriminator.MappedModel mm : cm.discriminator.getMappedModels()) { + if (mm.getModelName() == null || !mm.getModelName().equals(cm.classname)) { + filtered.add(mm); + } + } + if (!filtered.isEmpty()) { + List> discBranchIndex = + Oas31CompositionLowering.buildDiscriminatorBranchIndex( + filtered, descBranches); + if (!discBranchIndex.isEmpty()) { + cm.vendorExtensions.put("x-discriminator-branch-index", discBranchIndex); + cm.vendorExtensions.put("x-has-discriminator-branch-index", true); + } + } + } else if (desc.hasDiscriminator()) { + // Fallback: use explicit descriptor mapping when MappedModel unavailable + List> discBranchIndex = + Oas31CompositionLowering.buildDiscriminatorBranchIndex( + desc.getDiscriminator().getMapping(), + descBranches); + if (!discBranchIndex.isEmpty()) { + cm.vendorExtensions.put("x-discriminator-branch-index", discBranchIndex); + cm.vendorExtensions.put("x-has-discriminator-branch-index", true); + } + } + } + + cm.dataType = resolvedType; + } + + /** Branch metadata used by ordered composition lowering. */ + static final class ComposedBranch { + final String cppType; + final boolean isEnum; + final boolean isStringLike; + /** Index into the CompositionDescriptor branch list. + * -1 means no descriptor alignment (fallback path). */ + final int originalBranchIndex; + + ComposedBranch(String cppType, boolean isEnum, boolean isStringLike, + int originalBranchIndex) { + this.cppType = cppType; + this.isEnum = isEnum; + this.isStringLike = isStringLike; + this.originalBranchIndex = originalBranchIndex; + } + } + + private List> buildTypeErasedOneOfBranches( + CodegenModel codegenModel, Map allModels) { + List> validationBranches = new ArrayList<>(); + Object branchMetadata = codegenModel.vendorExtensions.get( + "x-cpp-composition-branches"); + Object templateBranches = branchMetadata instanceof Map + ? ((Map) branchMetadata).get("branches") : null; + int branchIndex = 0; + 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<>(); + String validatorId = validatorIdAt(templateBranches, branchIndex); + if (validatorId != null) { + // Type erasure is safe only when the original branch validator + // remains available to distinguish its full assertion surface. + validationBranch.put("validator-id", validatorId); + validationBranch.put("has-validator-id", true); + } + 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); + branchIndex++; + } + return validationBranches; + } + + private static String validatorIdAt(Object templateBranches, int branchIndex) { + if (!(templateBranches instanceof List) || branchIndex < 0 + || branchIndex >= ((List) templateBranches).size()) { + return null; + } + Object branch = ((List) templateBranches).get(branchIndex); + if (!(branch instanceof Map)) { + return null; + } + Object validatorId = ((Map) branch).get("validator-id"); + return validatorId instanceof String && !((String) validatorId).isEmpty() + ? (String) validatorId : null; + } + + @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"); + } + + static String escapeCppStringContent(String value) { + if (value == null) { + return ""; + } + for (int index = 0; index < value.length(); ++index) { + char constUnit = value.charAt(index); + if (Character.isHighSurrogate(constUnit)) { + if (index + 1 >= value.length() + || !Character.isLowSurrogate(value.charAt(index + 1))) { + throw new IllegalArgumentException( + "Cannot emit an unpaired UTF-16 high surrogate"); + } + ++index; + } else if (Character.isLowSurrogate(constUnit)) { + throw new IllegalArgumentException( + "Cannot emit an unpaired UTF-16 low surrogate"); + } + } + + byte[] utf8 = value.getBytes(java.nio.charset.StandardCharsets.UTF_8); + StringBuilder escaped = new StringBuilder(utf8.length); + for (byte encoded : utf8) { + int character = Byte.toUnsignedInt(encoded); + 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 <= 0x7e) { + escaped.append((char) character); + } else { + // Three-digit octal escapes cannot absorb following + // hexadecimal characters and preserve exact UTF-8 bytes. + escaped.append('\\') + .append((char) ('0' + ((character >>> 6) & 7))) + .append((char) ('0' + ((character >>> 3) & 7))) + .append((char) ('0' + (character & 7))); + } + break; + } + } + return escaped.toString(); + } + + protected static String toPreprocessorIdentifier(String value) { + String sanitized = value.replaceAll("[^A-Za-z0-9_]", "_"); + if (!sanitized.isEmpty() && Character.isDigit(sanitized.charAt(0))) { + return "_" + sanitized; + } + return sanitized.isEmpty() ? "_" : sanitized; + } + + /** + * Converts an arbitrary schema name into a valid C++ identifier for use + * in generated validator function names. Replaces non-alphanumeric + * characters with underscores and ensures the result starts with a letter. + */ + static String toValidIdentifier(String name) { + if (name == null || name.isEmpty()) { + return "schema"; + } + StringBuilder sb = new StringBuilder(name.length()); + for (int i = 0; i < name.length(); i++) { + char c = name.charAt(i); + if (Character.isLetterOrDigit(c) || c == '_') { + sb.append(c); + } else { + sb.append('_'); + } + } + String result = sb.toString(); + if (!result.isEmpty() && !Character.isLetter(result.charAt(0)) + && result.charAt(0) != '_') { + result = "_" + result; + } + return result.isEmpty() ? "schema" : result; + } + + /** Returns unique schema IR ids for raw component schema names. */ + static Map componentSchemaIds(Collection schemaNames) { + List names = new ArrayList<>(schemaNames); + Collections.sort(names); + Map> namesByBase = new LinkedHashMap<>(); + for (String name : names) { + namesByBase.computeIfAbsent(toValidIdentifier(name), ignored -> new ArrayList<>()) + .add(name); + } + + Map ids = new LinkedHashMap<>(); + for (Map.Entry> entry : namesByBase.entrySet()) { + String base = entry.getKey() + "_component"; + List collidingNames = entry.getValue(); + if (collidingNames.size() == 1) { + ids.put(collidingNames.get(0), base); + continue; + } + for (int index = 0; index < collidingNames.size(); index++) { + ids.put(collidingNames.get(index), base + "_" + (index + 1)); + } + } + return ids; + } + + /** Returns the schema IR id for a raw component schema name. */ + static String componentSchemaId(String schemaName, Map ids) { + String id = ids.get(schemaName); + return id != null ? id : toValidIdentifier(schemaName) + "_component"; + } + + /** + * Thrown during generation when a schema branch has assertion keywords that + * can affect composition membership but no generated validator exists. + * Carries the schema location, keyword, and remediation guidance. + */ + public static final class UnsupportedSchemaAssertionException + extends RuntimeException { + private final String schemaLocation; + private final String assertionKeyword; + + public UnsupportedSchemaAssertionException( + String schemaLocation, String assertionKeyword) { + super(buildMessage(schemaLocation, assertionKeyword)); + this.schemaLocation = schemaLocation; + this.assertionKeyword = assertionKeyword; + } + + public String getSchemaLocation() { return schemaLocation; } + public String getAssertionKeyword() { return assertionKeyword; } + + private static String buildMessage( + String schemaLocation, String assertionKeyword) { + return "Unsupported schema assertion '" + assertionKeyword + + "' at " + schemaLocation + + ". This keyword can affect composition membership but " + + "no generated validator exists. Add support in a later generator " + + "version, or restructure the schema to avoid this keyword."; + } + } + + /** + * Exception thrown when an allOf intersection produces an unsatisfiable + * result on a required property, preventing model generation. + */ + public static final class AllOfRequiredUnsatisfiableException + extends RuntimeException { + private final String schemaName; + private final String reason; + + public AllOfRequiredUnsatisfiableException( + String schemaName, String reason) { + super(buildMessage(schemaName, reason)); + this.schemaName = schemaName; + this.reason = reason; + } + + public String getSchemaName() { return schemaName; } + public String getReason() { return reason; } + + private static String buildMessage( + String schemaName, String reason) { + return "Unsatisfiable allOf intersection for schema '" + + schemaName + "': " + reason; + } + } + + /** + * 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". + *

    + * Cyclic alias maps fail generation; an unmapped type is returned unchanged. + */ + protected String resolveThroughAliases(String typeName) { + if (typeName == null) { + return null; + } + Set visited = new HashSet<>(); + String current = typeName; + while (true) { + String resolved = resolvedAliasTypes.get(current); + if (resolved == null || resolved.equals(current)) { + return current; + } + if (!visited.add(current)) { + throw new IllegalStateException( + "Cyclic resolved alias chain starting at '" + typeName + "'"); + } + current = resolved; + } + } + + /** + * 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. + */ + protected 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)
    • + *
    + */ + protected 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; + } + + /** + * Extracts the inner type from a std::optional type declaration, correctly + * handling nested angle brackets. + *
      + *
    • {@code std::optional} → {@code std::string}
    • + *
    • {@code std::optional>} → {@code std::vector}
    • + *
    • {@code std::optional} → {@code MyModel}
    • + *
    • {@code std::string} → {@code null}
    • + *
    + * + * @return the inner type, or null if the input does not start with "std::optional<" + */ + private static String extractOptionalInnerType(String type) { + if (type == null || !type.startsWith("std::optional<")) { + return null; + } + // Strip prefix "std::optional<" (14 chars) and find matching '>' + int depth = 0; + int start = 14; // length of "std::optional<" + for (int i = start; i < type.length(); i++) { + char c = type.charAt(i); + if (c == '<') { + depth++; + } else if (c == '>') { + if (depth == 0) { + return type.substring(start, i); + } + depth--; + } + } + return null; + } +} diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/CppBoostBeastTemplateModelAssembler.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/CppBoostBeastTemplateModelAssembler.java new file mode 100644 index 000000000000..160891725c24 --- /dev/null +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/CppBoostBeastTemplateModelAssembler.java @@ -0,0 +1,1206 @@ +package org.openapitools.codegen.languages; + +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.PathItem; +import io.swagger.v3.oas.models.security.SecurityRequirement; +import io.swagger.v3.oas.models.security.SecurityScheme; +import io.swagger.v3.oas.models.servers.Server; +import io.swagger.v3.oas.models.servers.ServerVariable; +import org.openapitools.codegen.CodegenMediaType; +import org.openapitools.codegen.CodegenModel; +import org.openapitools.codegen.CodegenOperation; +import org.openapitools.codegen.CodegenParameter; +import org.openapitools.codegen.CodegenProperty; +import org.openapitools.codegen.CodegenResponse; +import org.openapitools.codegen.model.ModelMap; +import org.openapitools.codegen.model.OperationsMap; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +/** Assembles operation and response vendor extensions consumed by API templates. */ +final class CppBoostBeastTemplateModelAssembler { + private static final String SSE_SCHEMA_MODE_JSON_EVENT_DATA = "jsonEventData"; + private static final String X_SSE_EVENT_DATA_SCHEMA = "x-sse-event-data-schema"; + private static final String X_SSE_REQUEST_PROPERTY = "x-sse-request-property"; + private static final String X_SSE_EVENT_TYPE = "x-sse-event-type"; + private static final String X_CODEGEN_CONDITIONAL_SSE = "x-codegen-conditional-sse"; + private static final String X_CODEGEN_SSE_REQUEST_PARAM = "x-codegen-sse-request-param"; + private static final String X_CODEGEN_SSE_REQUEST_GETTER = "x-codegen-sse-request-getter"; + private static final String X_CODEGEN_SSE_REQUEST_SETTER = "x-codegen-sse-request-setter"; + private static final String X_CODEGEN_SSE_REQUEST_FALSE_VALUE = + "x-codegen-sse-request-false-value"; + private static final String X_CODEGEN_SSE_REQUEST_TRUE_VALUE = + "x-codegen-sse-request-true-value"; + private static final String X_CODEGEN_SSE_REQUEST_TYPE = "x-codegen-sse-request-type"; + private static final String X_CODEGEN_SSE_REQUEST_SHARED_PTR = + "x-codegen-sse-request-shared-ptr"; + private static final String X_CODEGEN_SSE_REQUEST_LOCAL = "x-codegen-sse-request-local"; + private static final String X_CODEGEN_SSE_EVENT_TYPE_OVERRIDE = + "x-codegen-sse-event-type-override"; + private static final String X_CODEGEN_DEFAULT_RESPONSE_IS_RETURN_COMPATIBLE = + "x-codegen-default-response-is-return-compatible"; + private static final String X_CODEGEN_EMPTY_BODY_TOLERANT = "x-codegen-empty-body-tolerant"; + private static final String X_CODEGEN_HAS_DEFAULT_RESPONSE = "x-codegen-has-default-response"; + private static final String X_CODEGEN_OP_SERVER = "x-codegen-op-server"; + private static final String X_CODEGEN_OP_SECURITY_GROUPS = "x-codegen-op-security-groups"; + private static final String X_CODEGEN_OP_HAS_SECURITY = "x-codegen-op-has-security"; + private static final String X_CODEGEN_OP_CALLBACKS = "x-codegen-op-callbacks"; + private static final String X_CODEGEN_OP_LINKS = "x-codegen-op-links"; + private static final String X_CODEGEN_WEBHOOK_METADATA = "x-codegen-webhook-metadata"; + 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 static final String X_CODEGEN_RESPONSE_UNION = "x-codegen-response-union"; + private static final String X_CODEGEN_RESPONSE_UNION_BODY_TYPE = + "x-codegen-response-union-body-type"; + private static final String X_CODEGEN_RESPONSE_UNION_MEMBERS = + "x-codegen-response-union-members"; + private static final String X_CODEGEN_MULTIPART_FILENAME_PARAM = + "x-codegen-multipart-filename-param"; + private static final String X_CODEGEN_MULTIPART_FILENAME_PARAM_NAME = + "x-codegen-multipart-filename-param-name"; + private static final String X_CODEGEN_IS_OPTIONAL_FORM_PARAMETER = + "x-codegen-is-optional-form-parameter"; + private static final String X_CODEGEN_HAS_OPTIONAL_FORM_PARAMETER = + "x-codegen-has-optional-form-parameter"; + + private final OpenAPI phaseOpenApi; + private final List webhookPreservation; + private final Map> operationCallbacks; + private final Map> operationLinks; + private final Map composedKeywordsByModel; + private final String sseSchemaMode; + private final Set sseOperationIds; + private final Map sseRequestPropertyMappings; + private final Map sseEventTypeMappings; + private final boolean inferConditionalSseOperations; + private final boolean hasExplicitRootServers; + + + CppBoostBeastTemplateModelAssembler( + OpenAPI phaseOpenApi, + List webhookPreservation, + Map> operationCallbacks, + Map> operationLinks, + Map composedKeywordsByModel, + String sseSchemaMode, + Set sseOperationIds, + Map sseRequestPropertyMappings, + Map sseEventTypeMappings, + boolean inferConditionalSseOperations, + boolean hasExplicitRootServers) { + this.phaseOpenApi = phaseOpenApi; + this.webhookPreservation = webhookPreservation; + this.operationCallbacks = operationCallbacks; + this.operationLinks = operationLinks; + this.composedKeywordsByModel = composedKeywordsByModel; + this.sseSchemaMode = sseSchemaMode; + this.sseOperationIds = Collections.unmodifiableSet(new HashSet<>(sseOperationIds)); + this.sseRequestPropertyMappings = Collections.unmodifiableMap( + new LinkedHashMap<>(sseRequestPropertyMappings)); + this.sseEventTypeMappings = Collections.unmodifiableMap( + new LinkedHashMap<>(sseEventTypeMappings)); + this.inferConditionalSseOperations = inferConditionalSseOperations; + this.hasExplicitRootServers = hasExplicitRootServers; + } + + private static String stripSharedPtr(String type) { + if (type == null) { + return null; + } + if (type.startsWith("std::shared_ptr<") && type.endsWith(">")) { + return stripSharedPtr(type.substring(16, type.length() - 1)); + } + int firstLt = type.indexOf('<'); + int lastGt = type.lastIndexOf('>'); + if (firstLt > 0 && lastGt > firstLt) { + String prefix = type.substring(0, firstLt); + List args = splitTemplateArgs(type.substring(firstLt + 1, lastGt)); + for (int index = 0; index < args.size(); ++index) { + args.set(index, stripSharedPtr(args.get(index).trim())); + } + return prefix + "<" + String.join(", ", args) + ">"; + } + return type; + } + + private static List splitTemplateArgs(String args) { + List result = new ArrayList<>(); + int depth = 0; + int start = 0; + for (int index = 0; index < args.length(); ++index) { + char character = args.charAt(index); + if (character == '<') { + ++depth; + } else if (character == '>') { + --depth; + } else if (character == ',' && depth == 0) { + result.add(args.substring(start, index)); + start = index + 1; + } + } + result.add(args.substring(start)); + return result; + } + + private static String cppString(String value) { + return CppBoostBeastClientCodegen.escapeCppStringContent( + value == null ? "" : value); + } + + private static String commentText(String value) { + return value == null ? "" : value + .replace("*/", "* /") + .replace('\r', ' ') + .replace('\n', ' ') + .replace('\u2028', ' ') + .replace('\u2029', ' '); + } + + /** True when the list is exactly swagger-parser's implicit root default + * (a single Server with url "/") and the raw source omitted `servers`. */ + private static boolean isParserDefaultServerList(List servers) { + return servers != null && servers.size() == 1 + && "/".equals(servers.get(0).getUrl()); + } + + /** The operation's effective security requirements as template-ready + * groups. Each group is an OR alternative containing AND-required scheme + * maps. An empty group is anonymous access; operation `security: []` + * clears inherited requirements. */ + private List>> effectiveSecurityGroups(CodegenOperation op) { + List>> groups = new ArrayList<>(); + List requirements = null; + io.swagger.v3.oas.models.Operation raw = operationFor(op); + if (raw != null && raw.getSecurity() != null) { + requirements = raw.getSecurity(); // includes `[]` clears + } else if (phaseOpenApi != null + && phaseOpenApi.getSecurity() != null) { + requirements = phaseOpenApi.getSecurity(); + } + if (requirements == null) { + return groups; // no security declared + } + Map schemes = phaseOpenApi != null + && phaseOpenApi.getComponents() != null + ? phaseOpenApi.getComponents().getSecuritySchemes() + : null; + for (SecurityRequirement req : requirements) { + List> ands = new ArrayList<>(); + if (req != null) { + for (Map.Entry> e : req.entrySet()) { + SecurityScheme scheme = schemes == null + ? null : schemes.get(e.getKey()); + Map use = new LinkedHashMap<>(); + use.put("name", cppString(e.getKey())); + use.put("type", cppString(scheme == null || scheme.getType() == null + ? "unknown" : scheme.getType().toString())); + if (scheme != null && scheme.getType() == SecurityScheme.Type.APIKEY) { + use.put("in", cppString(scheme.getIn() == null ? "header" + : scheme.getIn().toString())); + use.put("paramName", cppString(scheme.getName() == null + ? "" : scheme.getName())); + } else { + use.put("in", ""); + use.put("paramName", ""); + } + use.put("httpScheme", cppString(scheme != null + && scheme.getType() == SecurityScheme.Type.HTTP + && scheme.getScheme() != null + ? scheme.getScheme() : "")); + List scopes = e.getValue() == null + ? new ArrayList() : e.getValue(); + use.put("scopes", scopes); + use.put("scopesRendered", scopes.isEmpty() ? null + : scopes.stream() + .map(s -> "\"" + cppString(s) + "\"") + .collect(java.util.stream.Collectors + .joining(", "))); + ands.add(use); + } + } + groups.add(ands); // empty ands = {} + } + return groups; + } + + /** The raw Operation behind a CodegenOperation (PathItem-method lookup). */ + private io.swagger.v3.oas.models.Operation operationFor(CodegenOperation op) { + if (phaseOpenApi == null || phaseOpenApi.getPaths() == null) { + return null; + } + PathItem item = phaseOpenApi.getPaths().get(op.path); + if (item == null) { + return null; + } + if ("GET".equals(op.httpMethod)) { + return item.getGet(); + } + if ("PUT".equals(op.httpMethod)) { + return item.getPut(); + } + if ("POST".equals(op.httpMethod)) { + return item.getPost(); + } + if ("DELETE".equals(op.httpMethod)) { + return item.getDelete(); + } + if ("OPTIONS".equals(op.httpMethod)) { + return item.getOptions(); + } + if ("HEAD".equals(op.httpMethod)) { + return item.getHead(); + } + if ("PATCH".equals(op.httpMethod)) { + return item.getPatch(); + } + if ("TRACE".equals(op.httpMethod)) { + return item.getTrace(); + } + return null; + } + + /** Returns the effective operation server URL with first-level variables + * substituted by declared defaults. Precedence is operation, path item, + * then root; an empty result leaves server selection to the caller. */ + private String resolveEffectiveServerUrl(CodegenOperation op) { + List servers = null; + io.swagger.v3.oas.models.Operation raw = operationFor(op); + if (raw != null && raw.getServers() != null + && !raw.getServers().isEmpty()) { + // Operation-level lists are present only when the source declared + // them, including the meaningful explicit root URL "/". + servers = raw.getServers(); + } + if (servers == null || servers.isEmpty()) { + if (phaseOpenApi != null && phaseOpenApi.getPaths() != null + && phaseOpenApi.getPaths().get(op.path) != null) { + PathItem item = phaseOpenApi.getPaths().get(op.path); + if (item.getServers() != null && !item.getServers().isEmpty()) { + // Path-level lists likewise preserve an explicit root URL. + servers = item.getServers(); + } + if ((servers == null || servers.isEmpty()) + && phaseOpenApi.getServers() != null + && !phaseOpenApi.getServers().isEmpty()) { + servers = phaseOpenApi.getServers(); + if (!hasExplicitRootServers && isParserDefaultServerList(servers)) { + servers = null; + } + } + } + } + if (servers == null || servers.isEmpty()) { + return ""; + } + // The FIRST entry of the effective list is the default (user + // selection among multiple entries is the caller's context + // override at construction time). + Server target = servers.get(0); + String url = target.getUrl() == null ? "" : target.getUrl(); + if (target.getVariables() != null) { + for (Map.Entry e + : target.getVariables().entrySet()) { + String value = e.getValue() != null && e.getValue().getDefault() != null + ? e.getValue().getDefault() : ""; + url = url.replace("{" + e.getKey() + "}", value); + } + } + return url; + } + + private static void addMultipartParameterMetadata(CodegenOperation operation) { + Set occupiedNames = new HashSet<>(); + for (CodegenParameter parameter : operation.allParams) { + occupiedNames.add(parameter.paramName); + } + boolean hasOptionalFormParameter = false; + for (CodegenParameter parameter : operation.allParams) { + if (parameter.isFormParam && !parameter.required) { + parameter.vendorExtensions.put(X_CODEGEN_IS_OPTIONAL_FORM_PARAMETER, true); + hasOptionalFormParameter = true; + } + if (!parameter.isFormParam || (!parameter.isFile && !parameter.isBinary)) { + continue; + } + String filenameParamName = parameter.paramName + "Filename"; + while (!occupiedNames.add(filenameParamName)) { + filenameParamName += "_"; + } + parameter.vendorExtensions.put(X_CODEGEN_MULTIPART_FILENAME_PARAM, true); + parameter.vendorExtensions.put( + X_CODEGEN_MULTIPART_FILENAME_PARAM_NAME, filenameParamName); + } + if (hasOptionalFormParameter) { + operation.vendorExtensions.put(X_CODEGEN_HAS_OPTIONAL_FORM_PARAMETER, true); + } + } + + private static Map indexModels(List allModels) { + Map result = new LinkedHashMap<>(); + for (ModelMap modelMap : allModels) { + CodegenModel model = modelMap.getModel(); + if (model.name != null) result.put(model.name, model); + if (model.schemaName != null) result.put(model.schemaName, model); + if (model.classname != null) result.put(model.classname, model); + } + return result; + } + + private static String extensionString(CodegenOperation operation, String key) { + Object rawValue = operation.vendorExtensions.get(key); + if (rawValue == null) return null; + String value = rawValue.toString().trim(); + if (value.isEmpty()) { + throw new IllegalArgumentException(operation.operationId + ": " + key + + " must not be empty"); + } + return value; + } + + private static List operationKeys(CodegenOperation operation) { + List keys = new ArrayList<>(); + if (operation.operationIdOriginal != null + && !operation.operationIdOriginal.isBlank()) { + keys.add(operation.operationIdOriginal); + } + if (operation.operationId != null && !operation.operationId.isBlank() + && !keys.contains(operation.operationId)) { + keys.add(operation.operationId); + } + return keys; + } + + private static String configuredValue(CodegenOperation operation, + Map configuredMappings, String optionName) { + String resolved = null; + for (String key : operationKeys(operation)) { + String candidate = configuredMappings.get(key); + if (candidate == null) continue; + if (resolved != null && !resolved.equals(candidate)) { + throw new IllegalArgumentException(operation.operationId + ": " + + optionName + " maps the raw and generated operation names" + + " to different values"); + } + resolved = candidate; + } + return resolved; + } + + private static boolean configuredOperation(CodegenOperation operation, + Set configuredOperationIds) { + for (String key : operationKeys(operation)) { + if (configuredOperationIds.contains(key)) return true; + } + return false; + } + + private static String resolveMapping(CodegenOperation operation, String extensionKey, + Map configuredMappings, String optionName) { + String extensionValue = extensionString(operation, extensionKey); + String configured = configuredValue(operation, configuredMappings, optionName); + if (extensionValue != null && configured != null + && !extensionValue.equals(configured)) { + throw new IllegalArgumentException(operation.operationId + ": conflicting " + + extensionKey + " and " + optionName); + } + return extensionValue != null ? extensionValue : configured; + } + + private static boolean produces(CodegenOperation operation, String expectedMediaType) { + if (operation.produces == null) return false; + for (Map media : operation.produces) { + String mediaType = media.get("mediaType"); + if (mediaType != null && mediaType.equalsIgnoreCase(expectedMediaType)) { + return true; + } + } + return false; + } + + private static CodegenModel findRequestModel(CodegenOperation operation, + Map modelsByName) { + if (operation.bodyParam == null) return null; + String[] candidates = {operation.bodyParam.baseType, operation.bodyParam.dataType}; + for (String candidate : candidates) { + if (candidate == null) continue; + String type = stripSharedPtr(candidate.trim()); + CodegenModel model = modelsByName.get(type); + if (model != null) return model; + int namespace = type.lastIndexOf("::"); + if (namespace >= 0) { + model = modelsByName.get(type.substring(namespace + 2)); + if (model != null) return model; + } + } + return null; + } + + private static boolean isBooleanProperty(CodegenProperty property) { + return property.isBoolean + || "bool".equals(property.dataType) + || "boolean".equalsIgnoreCase(property.baseType); + } + + private static boolean propertyMatches(CodegenProperty property, + String propertyName) { + return propertyName.equals(property.baseName) + || propertyName.equals(property.name); + } + + private static CodegenProperty findBooleanRequestProperty(CodegenOperation operation, + Map modelsByName, String propertyName, + boolean required) { + CodegenModel requestModel = findRequestModel(operation, modelsByName); + if (requestModel == null) { + if (required) { + throw new IllegalArgumentException(operation.operationId + + ": conditional SSE requires an object request body"); + } + return null; + } + List matches = new ArrayList<>(); + for (CodegenProperty property : requestModel.allVars) { + if (propertyMatches(property, propertyName)) matches.add(property); + } + if (matches.size() > 1) { + throw new IllegalArgumentException(operation.operationId + ": request property '" + + propertyName + "' is ambiguous in model " + requestModel.classname); + } + if (matches.isEmpty() || !isBooleanProperty(matches.get(0))) { + if (required) { + throw new IllegalArgumentException(operation.operationId + ": request property '" + + propertyName + "' must exist and have type boolean in model " + + requestModel.classname); + } + return null; + } + return matches.get(0); + } + + private static CodegenProperty inferBooleanRequestProperty(CodegenOperation operation, + Map modelsByName) { + CodegenModel requestModel = findRequestModel(operation, modelsByName); + if (requestModel == null) return null; + List booleanProperties = new ArrayList<>(); + for (CodegenProperty property : requestModel.allVars) { + if (isBooleanProperty(property)) booleanProperties.add(property); + } + if (booleanProperties.size() == 1) return booleanProperties.get(0); + List conventional = new ArrayList<>(); + for (CodegenProperty property : booleanProperties) { + String normalized = normalizeIdentifier(property.baseName != null + ? property.baseName : property.name); + if ("stream".equals(normalized) || "streaming".equals(normalized) + || "sse".equals(normalized)) { + conventional.add(property); + } + } + return conventional.size() == 1 ? conventional.get(0) : null; + } + + private static String normalizeIdentifier(String value) { + return value == null ? "" : value.replaceAll("[^A-Za-z0-9]", "") + .toLowerCase(Locale.ROOT); + } + + private static CodegenModel modelForType(String type, + Map modelsByName) { + if (type == null || type.isBlank()) return null; + String unwrapped = stripSharedPtr(type.trim()); + CodegenModel model = modelsByName.get(unwrapped); + if (model != null) return model; + int namespace = unwrapped.lastIndexOf("::"); + return namespace < 0 ? null : modelsByName.get(unwrapped.substring(namespace + 2)); + } + + private static CodegenModel inferSseEventModel(CodegenOperation operation, + Map modelsByName) { + Set candidates = new LinkedHashSet<>(); + for (CodegenResponse response : operation.responses) { + if (!response.is2xx || response.getContent() == null) continue; + for (Map.Entry media : response.getContent().entrySet()) { + if (!"text/event-stream".equalsIgnoreCase(media.getKey()) + || media.getValue() == null + || media.getValue().getSchema() == null) { + continue; + } + CodegenProperty schema = media.getValue().getSchema(); + String[] types = {schema.dataType, schema.baseType, schema.complexType}; + for (String type : types) { + CodegenModel model = modelForType(type, modelsByName); + if (model != null) candidates.add(model); + } + } + } + if (candidates.size() > 1) { + throw new IllegalArgumentException(operation.operationId + + ": multiple generated models match the SSE response; configure " + + "sseEventTypeMappings explicitly"); + } + return candidates.isEmpty() ? null : candidates.iterator().next(); + } + + private void addSseOperationMetadata(CodegenOperation operation, + Map modelsByName, Set extraModelImports) { + boolean producesSse = produces(operation, "text/event-stream"); + boolean producesJson = produces(operation, "application/json"); + String requestProperty = resolveMapping(operation, X_SSE_REQUEST_PROPERTY, + sseRequestPropertyMappings, "sseRequestPropertyMappings"); + boolean explicitlyConditional = requestProperty != null + || configuredOperation(operation, sseOperationIds); + if (explicitlyConditional && requestProperty == null) requestProperty = "stream"; + + CodegenProperty selector = null; + if (requestProperty != null) { + if (!producesSse || !producesJson) { + throw new IllegalArgumentException(operation.operationId + + ": conditional SSE requires both application/json and" + + " text/event-stream responses"); + } + selector = findBooleanRequestProperty( + operation, modelsByName, requestProperty, true); + } else if (inferConditionalSseOperations && producesSse && producesJson) { + selector = inferBooleanRequestProperty(operation, modelsByName); + } + + if (selector != null) { + CodegenModel requestModel = findRequestModel(operation, modelsByName); + Set parameterNames = new HashSet<>(); + for (CodegenParameter parameter : operation.allParams) { + parameterNames.add(parameter.paramName); + } + String requestLocal = "conditionalSseRequestBody"; + while (parameterNames.contains(requestLocal)) requestLocal += "_"; + operation.vendorExtensions.put(X_CODEGEN_CONDITIONAL_SSE, true); + operation.vendorExtensions.put( + X_CODEGEN_SSE_REQUEST_PARAM, operation.bodyParam.paramName); + operation.vendorExtensions.put(X_CODEGEN_SSE_REQUEST_GETTER, selector.getter); + operation.vendorExtensions.put(X_CODEGEN_SSE_REQUEST_SETTER, selector.setter); + String selectorType = selector.dataType == null ? "bool" : selector.dataType; + String falseValue = "bool".equals(selectorType) + ? "false" : selectorType + "{false}"; + String trueValue = "bool".equals(selectorType) + ? "true" : selectorType + "{true}"; + operation.vendorExtensions.put(X_CODEGEN_SSE_REQUEST_FALSE_VALUE, falseValue); + operation.vendorExtensions.put(X_CODEGEN_SSE_REQUEST_TRUE_VALUE, trueValue); + operation.vendorExtensions.put(X_CODEGEN_SSE_REQUEST_TYPE, + requestModel.classname); + operation.vendorExtensions.put(X_CODEGEN_SSE_REQUEST_SHARED_PTR, + operation.bodyParam.dataType != null + && operation.bodyParam.dataType.startsWith("std::shared_ptr<")); + operation.vendorExtensions.put(X_CODEGEN_SSE_REQUEST_LOCAL, requestLocal); + operation.bodyParam.vendorExtensions.put(X_CODEGEN_CONDITIONAL_SSE, true); + operation.bodyParam.vendorExtensions.put(X_CODEGEN_SSE_REQUEST_LOCAL, + requestLocal); + } + + String eventTypeName = resolveMapping(operation, X_SSE_EVENT_TYPE, + sseEventTypeMappings, "sseEventTypeMappings"); + boolean explicitEventType = eventTypeName != null; + CodegenModel eventModel = eventTypeName == null ? null + : modelsByName.get(eventTypeName); + if (eventTypeName != null && eventModel == null) { + throw new IllegalArgumentException(operation.operationId + + ": SSE event type does not name a generated model: " + eventTypeName); + } + if (eventModel == null && selector != null && inferConditionalSseOperations) { + eventModel = inferSseEventModel(operation, modelsByName); + } + if (eventModel == null) return; + if (!producesSse) { + throw new IllegalArgumentException(operation.operationId + + ": an SSE event type requires a text/event-stream response"); + } + operation.vendorExtensions.put(X_CODEGEN_SSE_EVENT_TYPE_OVERRIDE, + eventModel.classname); + if (explicitEventType) { + operation.vendorExtensions.put(X_SSE_EVENT_DATA_SCHEMA, true); + } + extraModelImports.add(eventModel.classname); + } + + private void validateConfiguredOperationIds(Set seenOperationIds) { + Set configured = new HashSet<>(sseOperationIds); + configured.addAll(sseRequestPropertyMappings.keySet()); + configured.addAll(sseEventTypeMappings.keySet()); + configured.removeAll(seenOperationIds); + if (!configured.isEmpty()) { + throw new IllegalArgumentException( + "SSE configuration references unknown operationIds: " + configured); + } + } + + private static void addModelImports(OperationsMap operations, + Set modelNames) { + List> imports = operations.getImports(); + if (imports == null) { + imports = new ArrayList<>(); + operations.setImports(imports); + } + + // API headers live beside api/HttpClient.h while model headers live in + // the sibling model directory. Relative imports prevent a second + // generated client's include roots from satisfying these dependencies. + Set existing = new HashSet<>(); + for (Map item : imports) { + String include = item.get("import"); + if (include != null && include.startsWith("#include \"") + && include.endsWith(".h\"") + && include.indexOf('/') < 0) { + include = "#include \"../model/" + include.substring(10); + item.put("import", include); + } + existing.add(include); + } + for (String modelName : modelNames) { + String include = "#include \"../model/" + modelName + ".h\""; + if (existing.add(include)) { + Map item = new LinkedHashMap<>(); + item.put("import", include); + imports.add(item); + } + } + } + + @SuppressWarnings("unchecked") + OperationsMap assemble(OperationsMap objs, List allModels) { + // API templates need to know whether a model namespace exists. Upstream + // does not populate hasModels in this generator's API context. + objs.put("x-codegen-has-models", !allModels.isEmpty()); + objs.put(X_CODEGEN_WEBHOOK_METADATA, + webhookPreservation.isEmpty() ? null + : commentText(String.join("; ", webhookPreservation))); + Map operations = (Map) objs.get("operations"); + List operationList = (List) operations.get("operation"); + List newOpList = new ArrayList<>(); + Set nullDefaultModels = nullDefaultModelNames(allModels); + Map modelsByName = indexModels(allModels); + Set seenOperationIds = new HashSet<>(); + Set extraModelImports = new HashSet<>(); + + for (CodegenOperation op : operationList) { + seenOperationIds.addAll(operationKeys(op)); + addSseOperationMetadata(op, modelsByName, extraModelImports); + addMultipartParameterMetadata(op); + addApiResponseMetadata(op, nullDefaultModels); + addResponseUnionMetadata(op); + op.vendorExtensions.put(X_CODEGEN_OP_SERVER, + cppString(resolveEffectiveServerUrl(op))); + if (op.consumes != null) { + for (Map media : op.consumes) { + media.put("cppMediaType", cppString(media.get("mediaType"))); + } + } + if (op.produces != null) { + for (Map media : op.produces) { + media.put("cppMediaType", cppString(media.get("mediaType"))); + } + } + List>> securityGroups = effectiveSecurityGroups(op); + op.vendorExtensions.put(X_CODEGEN_OP_SECURITY_GROUPS, securityGroups); + op.vendorExtensions.put(X_CODEGEN_OP_HAS_SECURITY, + !securityGroups.isEmpty()); + String opKey = op.path + '\0' + op.httpMethod; + op.vendorExtensions.put(X_CODEGEN_OP_CALLBACKS, + operationCallbacks.getOrDefault(opKey, new ArrayList()) + .stream().map(CppBoostBeastTemplateModelAssembler::commentText) + .collect(java.util.stream.Collectors.toList())); + op.vendorExtensions.put(X_CODEGEN_OP_LINKS, + operationLinks.getOrDefault(opKey, new ArrayList()) + .stream().map(CppBoostBeastTemplateModelAssembler::commentText) + .collect(java.util.stream.Collectors.toList())); + String path = op.path; + + String[] items = path.split("/", -1); + String resourceNameCamelCase = ""; + for (String item : items) { + if (item.length() > 1) { + if (item.matches("^\\{(.*)\\}$")) { + String tmpResourceName = item.substring(1, item.length() - 1); + resourceNameCamelCase += Character.toUpperCase(tmpResourceName.charAt(0)) + + tmpResourceName.substring(1); + } else { + resourceNameCamelCase += Character.toUpperCase(item.charAt(0)) + + item.substring(1); + } + } else if (item.length() == 1) { + resourceNameCamelCase += Character.toUpperCase(item.charAt(0)); + } + } + op.path = path.replaceFirst("/$", ""); + op.vendorExtensions.put("x-codegen-cpp-path", cppString(op.path)); + + op.vendorExtensions.put("x-codegen-resource-name", resourceNameCamelCase); + + boolean foundInNewList = false; + for (CodegenOperation op1 : newOpList) { + if (!foundInNewList) { + if (op1.path.equals(op.path)) { + foundInNewList = true; + final String otherMethodsKey = "x-codegen-other-methods"; + List currentOtherMethodList = + (List) op1.vendorExtensions.get(otherMethodsKey); + if (currentOtherMethodList == null) { + currentOtherMethodList = new ArrayList<>(); + } + op.operationIdCamelCase = op1.operationIdCamelCase; + currentOtherMethodList.add(op); + op1.vendorExtensions.put(otherMethodsKey, currentOtherMethodList); + } + } + } + if (!foundInNewList) { + newOpList.add(op); + } + } + validateConfiguredOperationIds(seenOperationIds); + addModelImports(objs, extraModelImports); + operations.put("operation", newOpList); + return objs; + } + + @SuppressWarnings("unchecked") + private static Set nullDefaultModelNames(List allModels) { + Set result = new HashSet<>(); + Map aliases = new LinkedHashMap<>(); + for (ModelMap modelMap : allModels) { + CodegenModel model = modelMap.getModel(); + Object cppTypeValue = model.vendorExtensions.get("x-cpp-type"); + String cppType = cppTypeValue instanceof String + ? (String) cppTypeValue : model.dataType; + aliases.put(model.classname, stripSharedPtr(cppType)); + if (cppType == null) { + continue; + } + + // Preserve the prior empty-body behavior only when the default + // variant branch is null and anyOf therefore accepts that value. + Object metadataValue = model.vendorExtensions.get("x-cpp-composition-branches"); + if (!cppType.startsWith("std::variant<") + || !(metadataValue instanceof Map)) { + continue; + } + Map metadata = (Map) metadataValue; + if (!"anyOf".equals(metadata.get("keyword"))) { + continue; + } + Object branchesValue = metadata.get("branches"); + if (!(branchesValue instanceof List) || ((List) branchesValue).isEmpty()) { + continue; + } + Object firstBranch = ((List) branchesValue).get(0); + if (firstBranch instanceof Map + && "always".equals(((Map) firstBranch).get("null-capability"))) { + result.add(model.classname); + } + } + + boolean changed; + do { + changed = false; + for (Map.Entry alias : aliases.entrySet()) { + if (result.contains(alias.getValue()) && result.add(alias.getKey())) { + changed = true; + } + } + } while (changed); + return result; + } + + private void addApiResponseMetadata( + CodegenOperation operation, Set nullDefaultModels) { + boolean hasDefaultResponse = false; + for (CodegenResponse response : operation.responses) { + response.vendorExtensions.put("x-codegen-cpp-message", response.message); + response.vendorExtensions.put("x-codegen-return-compatible", + Objects.equals(operation.returnType, response.dataType)); + response.vendorExtensions.put(X_CODEGEN_RESPONSE_IS_ONE_OF, + isOneOfResponse(response)); + String responseType = stripSharedPtr(response.dataType); + response.vendorExtensions.put(X_CODEGEN_EMPTY_BODY_TOLERANT, + response.isMap || response.isFreeFormObject || response.isAnyType + || nullDefaultModels.contains(responseType)); + if (response.isRange()) { + response.vendorExtensions.put( + X_CODEGEN_RESPONSE_RANGE, response.code.substring(0, 1)); + } + + if (response.isDefault) { + hasDefaultResponse = true; + response.vendorExtensions.put(X_CODEGEN_DEFAULT_RESPONSE_IS_RETURN_COMPATIBLE, + operation.returnType != null + && Objects.equals(operation.returnType, response.dataType)); + } + + // Every oneOf/anyOf response must use the generated model decoder. + // Distinct C++ alternatives may be structurally interchangeable, + // so the generic variant converter cannot honor schema membership. + if (response.dataType != null) { + String unwrapped = stripSharedPtr(response.dataType); + String compositionKeyword = composedKeywordsByModel.get(unwrapped); + if ("oneOf".equals(compositionKeyword) + || "anyOf".equals(compositionKeyword)) { + response.vendorExtensions.put("x-cpp-use-model-from-json-value", true); + } + } + } + operation.vendorExtensions.put(X_CODEGEN_HAS_DEFAULT_RESPONSE, hasDefaultResponse); + + // Detect text/event-stream produces for SSE streaming responses. + // sseSchemaMode selects whether the response schema describes the media + // representation or each JSON event data payload. The operation vendor + // extension can opt into typed event-data decoding. + // + // representation (default) delivers structured SseEvent values without + // decoding data. jsonEventData parses each event's data as JSON against + // the response schema. A per-operation event type forces typed decoding. + // Dual-content operations expose a stream companion only when the + // operation was explicitly configured or safely inferred as conditional. + 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; + boolean isConditionalSse = isDualContent && Boolean.TRUE.equals( + operation.vendorExtensions.get(X_CODEGEN_CONDITIONAL_SSE)); + operation.vendorExtensions.put("x-codegen-streaming-response", isPureSse); + // Determine whether to apply typed event-data decoding. + // jsonEventData mode or per-operation x-sse-event-data-schema + // opt-in triggers typed JSON-per-data conversion. + boolean useJsonEventData = SSE_SCHEMA_MODE_JSON_EVENT_DATA.equals(sseSchemaMode) + || Boolean.TRUE.equals( + operation.vendorExtensions.get(X_SSE_EVENT_DATA_SCHEMA)); + String eventTypeOverride = (String) operation.vendorExtensions.get( + X_CODEGEN_SSE_EVENT_TYPE_OVERRIDE); + if (isPureSse && eventTypeOverride == null && operation.returnType == null) { + useJsonEventData = false; + } + // Representation mode forwards complete structured events directly. + if (!useJsonEventData) { + operation.vendorExtensions.put("x-codegen-sse-representation-mode", true); + } + // Mark pure SSE responses for the incremental callback path. For + // conditional operations, response metadata identifies typed SSE + // alternatives without changing the normal JSON return contract. + for (CodegenResponse response : operation.responses) { + if (isPureSse) { + response.vendorExtensions.put("x-codegen-streaming-response", true); + if (useJsonEventData) { + String eventDataType = eventTypeOverride != null + ? eventTypeOverride : stripSharedPtr(response.dataType); + if (isOneOfResponse(response) + || isOneOfMediaType(response, "text/event-stream") + || isOneOfType(eventDataType)) { + operation.vendorExtensions.put(X_CODEGEN_STREAM_IS_ONE_OF, true); + operation.vendorExtensions.put( + "x-codegen-sse-event-data-is-oneof", true); + } + if (eventDataType != null && !eventDataType.isEmpty() + && !eventDataType.startsWith("std::") + && !eventDataType.startsWith("boost::") + && Character.isUpperCase(eventDataType.charAt(0))) { + response.vendorExtensions.put("x-codegen-stream-element-type", + eventDataType); + operation.vendorExtensions.put("x-codegen-stream-element-type", + eventDataType); + response.vendorExtensions.put("x-codegen-sse-event-data-type", + eventDataType); + operation.vendorExtensions.put("x-codegen-sse-event-data-type", + eventDataType); + } + } + } else if (isConditionalSse && response.is2xx + && response.dataType != null + && !response.dataType.equals(operation.returnType)) { + response.vendorExtensions.put("x-codegen-streaming-response", true); + if (!useJsonEventData) { + response.vendorExtensions.put("x-codegen-sse-representation-mode", true); + } + String streamElementType = eventTypeOverride != null + ? eventTypeOverride : stripSharedPtr(response.dataType); + response.vendorExtensions.put("x-codegen-stream-element-type", + streamElementType); + if (useJsonEventData) { + response.vendorExtensions.put("x-codegen-sse-event-data-type", + streamElementType); + } + } + } + // Conditional dual-content operations get a dedicated stream method + // when a concrete event type can be resolved. + if (isConditionalSse) { + // 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 (useJsonEventData && isOneOfSchema(sseSchema)) { + operation.vendorExtensions.put( + X_CODEGEN_DUAL_STREAM_IS_ONE_OF, true); + operation.vendorExtensions.put( + "x-codegen-dual-sse-event-data-is-oneof", 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 (eventTypeOverride != null) { + sseReturnType = eventTypeOverride; + sseBaseModelName = eventTypeOverride; + } + if (sseReturnType != null && sseBaseModelName != null) { + if (useJsonEventData && isOneOfType(sseReturnType)) { + operation.vendorExtensions.put(X_CODEGEN_DUAL_STREAM_IS_ONE_OF, true); + operation.vendorExtensions.put( + "x-codegen-dual-sse-event-data-is-oneof", true); + } + operation.vendorExtensions.put("x-codegen-dual-content", true); + // Full C++ type for the vector element; it may contain shared_ptr. + operation.vendorExtensions.put( + "x-codegen-dual-stream-return-type", sseReturnType); + // Base name for the fromJsonValue_ converter, without shared_ptr. + 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); + if (useJsonEventData) { + operation.vendorExtensions.put( + "x-codegen-dual-sse-event-data-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); + if (useJsonEventData) { + response.vendorExtensions.put( + "x-codegen-dual-sse-event-data-type", dualStreamElementType); + } + } + } + } + } + } + + /** + * Detects operations with heterogeneous successful response shapes and tags + * them for response-union generation. A heterogeneous operation has multiple + * 2xx responses with different body types, or a mix of body/no-body responses. + * + * x-codegen-response-union: the generated union struct name + * x-codegen-response-union-members: filtered variant-member rows with + * a terminal marker for comma rendering + * Sets on each response used in the union: + * x-codegen-response-union: the union struct name (same as operation-level) + * x-codegen-response-union-body-type: the variant alternative body type + * (e.g., {@code std::shared_ptr} or {@code std::monostate}). + * Duplicate C++ body types are wrapped in + * {@code StatusTaggedValue}. + *

    Single-shape operations (one success type) are left unchanged so the + * existing simple-signature path is used. + */ + private void addResponseUnionMetadata(CodegenOperation operation) { + // Collect union-eligible responses: exact 2xx, range 2xx, or default + // responses with a body type. At least two distinct body shapes are + // required for union generation. + List unionEligible = new ArrayList<>(); + for (CodegenResponse response : operation.responses) { + boolean isSuccessWithBody = response.is2xx + || (response.isDefault && response.dataType != null); + if (isSuccessWithBody) { + unionEligible.add(response); + } + } + if (unionEligible.size() < 2) { + return; + } + + // Detect whether eligible responses have distinct body shapes. + // "Distinct" means different dataType, or mixed body/no-body. + boolean hasMixedShapes = false; + String firstDataType = unionEligible.get(0).dataType; + for (int idx = 1; idx < unionEligible.size(); ++idx) { + if (!Objects.equals(firstDataType, unionEligible.get(idx).dataType)) { + hasMixedShapes = true; + break; + } + } + if (!hasMixedShapes) { + boolean hasBody = false; + boolean hasNoBody = false; + for (CodegenResponse r : unionEligible) { + if (r.dataType != null) { + hasBody = true; + } else { + hasNoBody = true; + } + } + if (hasBody && hasNoBody) { + hasMixedShapes = true; + } + } + if (!hasMixedShapes) { + return; + } + + // Build the union struct name: capitalize the operationId + "Response" + String operationId = operation.operationIdCamelCase != null + ? operation.operationIdCamelCase + : operation.operationId; + if (operationId == null || operationId.isEmpty()) { + return; + } + String unionName = Character.toUpperCase(operationId.charAt(0)) + + operationId.substring(1) + "Response"; + + operation.vendorExtensions.put(X_CODEGEN_RESPONSE_UNION, unionName); + + // Detect duplicate raw body types and build StatusTaggedValue wrappers. + // Key = raw C++ type string, value = list of responses using it. + Map> rawTypeToResponses = new LinkedHashMap<>(); + for (CodegenResponse response : unionEligible) { + String rawType = response.dataType != null + ? response.dataType : "std::monostate"; + rawTypeToResponses.computeIfAbsent(rawType, + k -> new ArrayList<>()).add(response); + } + + // Assign the final body type to each response. + for (CodegenResponse response : unionEligible) { + // Propagate union name to per-response scope so templates can + // access x-codegen-response-union directly without parent lookup. + response.vendorExtensions.put(X_CODEGEN_RESPONSE_UNION, unionName); + + String rawType = response.dataType != null + ? response.dataType : "std::monostate"; + List sharingResponses = rawTypeToResponses.get(rawType); + String finalBodyType; + if (sharingResponses != null && sharingResponses.size() > 1) { + // Two or more statuses share the same C++ body type. + // Wrap in StatusTaggedValue to preserve + // distinct status identity in the variant. + String statusCodeStr = response.code; + int statusCodeInt; + try { + statusCodeInt = Integer.parseInt( + statusCodeStr.replaceAll("[^0-9]", "")); + } catch (NumberFormatException exception) { + // Range or default code; use 0 as placeholder. + statusCodeInt = 0; + } + finalBodyType = "StatusTaggedValue"; + } else { + finalBodyType = rawType; + } + response.vendorExtensions.put( + X_CODEGEN_RESPONSE_UNION_BODY_TYPE, finalBodyType); + } + + List> unionMembers = new ArrayList<>(); + for (CodegenResponse response : unionEligible) { + Map member = new LinkedHashMap<>(); + member.put("bodyType", response.vendorExtensions.get( + X_CODEGEN_RESPONSE_UNION_BODY_TYPE)); + unionMembers.add(member); + } + unionMembers.get(unionMembers.size() - 1).put("last", true); + operation.vendorExtensions.put(X_CODEGEN_RESPONSE_UNION_MEMBERS, unionMembers); + + } + + 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)); + } +} diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/Oas31CompositionLowering.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/Oas31CompositionLowering.java new file mode 100644 index 000000000000..b1606739f06c --- /dev/null +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/Oas31CompositionLowering.java @@ -0,0 +1,2078 @@ +package org.openapitools.codegen.languages; + + +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.media.Schema; +import org.openapitools.codegen.CodegenDiscriminator; +import org.openapitools.codegen.utils.ModelUtils; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.IdentityHashMap; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.function.Consumer; +import java.util.stream.Collectors; + +/** + * Ordered composition lowering for the cpp-boost-beast client: builds the + * per-schema {@link CompositionDescriptor}s (branch scan surfaces, validator + * ids, null capability, discriminator) before model processing, computes + * recursive allOf intersections into synthetic schemas, and lowers composed + * branch sets to C++ storage types (std::optional / std::variant / + * CompositionBranchValue / boost::json::value) per the ordered rules. + * + *

    The descriptor records ({@link CompositionDescriptor} and friends) are + * public nested types so tests and template-facing maps keep using the same + * accessor surface. The class is deliberately stateless: every method takes + * its inputs as parameters, incl. the parsed {@code openAPI} document and the + * component-schema index; the codegen keeps the compositionDescriptors / + * allOfIntersections indexes and the model-phase consumers + * (processComposedModelFromDescriptor, fromModel, template maps) — those + * mutate CodegenModel state and stay on the generator. + * + *

    Branch surfaces are scanned by + * {@link Oas31SchemaSurfaceAssertionScanner#scanSurfaceAssertions}; exceptions + * (UnsupportedSchemaAssertionException, AllOfRequiredUnsatisfiableException) + * live on the generator and are referenced through it. + */ +public final class Oas31CompositionLowering { + + private Oas31CompositionLowering() { + } + + /** + * Describes a composed schema (oneOf, anyOf, allOf) with its branches, + * preserving the original keyword and branch order after normalization. + */ + public static final class CompositionDescriptor { + private final String schemaName; + private final String schemaLocation; + private final String keyword; + private final List branches; + private final DiscriminatorDescriptor discriminator; + + public CompositionDescriptor(String schemaName, String schemaLocation, + String keyword, + List branches, + DiscriminatorDescriptor discriminator) { + this.schemaName = schemaName; + this.schemaLocation = schemaLocation; + this.keyword = keyword; + this.branches = Collections.unmodifiableList( + new ArrayList<>(branches)); + this.discriminator = discriminator; + } + + public String getSchemaName() { return schemaName; } + public String getSchemaLocation() { return schemaLocation; } + public String getKeyword() { return keyword; } + public List getBranches() { return branches; } + public DiscriminatorDescriptor getDiscriminator() { return discriminator; } + public boolean hasDiscriminator() { return discriminator != null; } + + /** Converts this descriptor to a template-safe map for Mustache. */ + public Map toTemplateMap() { + Map map = new LinkedHashMap<>(); + map.put("schema-name", schemaName); + map.put("schema-location", schemaLocation); + map.put("keyword", keyword); + List> branchMaps = new ArrayList<>(); + for (CompositionBranchDescriptor branch : branches) { + branchMaps.add(branch.toTemplateMap()); + } + map.put("branches", branchMaps); + if (discriminator != null) { + map.put("discriminator-property-name", discriminator.getPropertyName()); + map.put("discriminator-mapping", discriminator.getMapping()); + } + return map; + } + } + + /** + * Describes an optional discriminator on a composed schema. + */ + public static final class DiscriminatorDescriptor { + private final String propertyName; + private final Map mapping; + + public DiscriminatorDescriptor(String propertyName, Map mapping) { + this.propertyName = propertyName; + this.mapping = mapping != null + ? Collections.unmodifiableMap(new LinkedHashMap<>(mapping)) + : Collections.emptyMap(); + } + + public String getPropertyName() { return propertyName; } + public Map getMapping() { return mapping; } + } + + /** + * Describes a single branch within a composed schema. + * Captures branch index, resolved schema reference, C++ storage type, + * validator identity, null capability, assertion metadata, and + * validation parameter values. + * + *

    {@code storageCppType} is populated after storage selection. + * {@code validatorId} identifies the generated {@code validate_()} function. + * + *

    Validation parameters ({@code validateParams}) carry the actual + * assertion values (min, max, minLength, etc.) from the source schema + * so Mustache templates can generate per-branch validators without + * re-scanning the schema tree. + */ + public static final class CompositionBranchDescriptor { + private final int branchIndex; + private final String sourceSchemaRef; + private final String resolvedSchemaName; + /** C++ storage type selected after descriptor construction. */ + private final String storageCppType; + /** Stable generated validator identity. */ + private final String validatorId; + private final NullCapability nullCapability; + private final List supportedAssertions; + private final List unsupportedAssertions; + /** + * Validation parameter values for Mustache template consumption. + * Keys: "validation-type", "validation-enum-values", + * "validation-min", "validation-max", "validation-exclusive-min", + * "validation-exclusive-max", "validation-multiple-of", + * "validation-min-length", "validation-max-length", + * "validation-pattern", "validation-min-items", + * "validation-max-items", "validation-unique-items", + * "validation-min-properties", "validation-max-properties", + * "validation-required". + * Values are Objects (String, Number, Boolean, List). + */ + private final Map validateParams; + + public enum NullCapability { NEVER, ALWAYS, CONDITIONAL } + + public CompositionBranchDescriptor(int branchIndex, String sourceSchemaRef, + String resolvedSchemaName, String storageCppType, + String validatorId, NullCapability nullCapability, + List supportedAssertions, + List unsupportedAssertions, + Map validateParams) { + this.branchIndex = branchIndex; + this.sourceSchemaRef = sourceSchemaRef; + this.resolvedSchemaName = resolvedSchemaName; + this.storageCppType = storageCppType; + this.validatorId = validatorId; + this.nullCapability = nullCapability; + this.supportedAssertions = supportedAssertions != null + ? Collections.unmodifiableList(new ArrayList<>(supportedAssertions)) + : Collections.emptyList(); + this.unsupportedAssertions = unsupportedAssertions != null + ? Collections.unmodifiableList(new ArrayList<>(unsupportedAssertions)) + : Collections.emptyList(); + this.validateParams = validateParams != null + ? Collections.unmodifiableMap(new LinkedHashMap<>(validateParams)) + : Collections.emptyMap(); + } + + public int getBranchIndex() { return branchIndex; } + public String getSourceSchemaRef() { return sourceSchemaRef; } + public String getResolvedSchemaName() { return resolvedSchemaName; } + public String getStorageCppType() { return storageCppType; } + public String getValidatorId() { return validatorId; } + public NullCapability getNullCapability() { return nullCapability; } + public List getSupportedAssertions() { return supportedAssertions; } + public List getUnsupportedAssertions() { return unsupportedAssertions; } + public Map getValidateParams() { return validateParams; } + + /** Converts this branch descriptor to a template-safe map for Mustache. */ + public Map toTemplateMap() { + Map map = new LinkedHashMap<>(); + map.put("branch-index", branchIndex); + map.put("source-schema-ref", sourceSchemaRef); + map.put("resolved-schema-name", resolvedSchemaName); + map.put("storage-cpp-type", storageCppType); + map.put("validator-id", validatorId); + map.put("null-capability", nullCapability.name().toLowerCase(Locale.ROOT)); + map.put("has-supported-assertions", !supportedAssertions.isEmpty()); + map.put("supported-assertions", supportedAssertions); + map.put("has-unsupported-assertions", !unsupportedAssertions.isEmpty()); + map.put("unsupported-assertions", unsupportedAssertions); + // Emit validation parameters for template-driven generator functions + for (Map.Entry vp : validateParams.entrySet()) { + map.put(vp.getKey(), vp.getValue()); + } + return map; + } + } + + + /** + * Result of recursively intersecting allOf contributor schemas. + * Captures merged properties, union required, and satisfiability. + * Used to build synthetic object schemas for storage model generation. + */ + public static final class AllOfIntersection { + private final Map properties; + private final Set required; + private final boolean isSatisfiable; + private final String unsatisfiableReason; + /** Map of property names whose intersection is empty (optional impossible). */ + private final Set optionalImpossibleProperties; + /** Intersected root-level type across all branches (null if absent). */ + private final String rootScalarType; + /** Intersected root-level enum values across all branches. */ + private final List rootEnumValues; + /** Whether all contributors constrain the root with an explicit const. */ + private final boolean rootHasConst; + /** Intersected root-level const value across all branches. */ + private final Object rootConstValue; + /** Pristine JSON for an explicit const, including JSON null. */ + private final String rootConstJson; + /** Whether every root contributor permits JSON null. */ + private final boolean rootAllowsNull; + /** Minimum numeric value (intersection takes the larger). */ + private final BigDecimal rootMinimum; + /** Maximum numeric value (intersection takes the smaller). */ + private final BigDecimal rootMaximum; + /** Exclusive minimum flag. */ + private final Boolean rootExclusiveMinimum; + /** Exclusive maximum flag. */ + private final Boolean rootExclusiveMaximum; + /** Numeric exclusive minimum selected for the synthetic schema. */ + private final BigDecimal rootExclusiveMinimumValue; + /** Numeric exclusive maximum selected for the synthetic schema. */ + private final BigDecimal rootExclusiveMaximumValue; + /** Strictest additionalProperties constraint across contributors. */ + private final Object additionalProperties; + /** Minimum string length (intersection takes the larger). */ + private final Integer rootMinLength; + /** Maximum string length (intersection takes the smaller). */ + private final Integer rootMaxLength; + + public AllOfIntersection(Map properties, Set required, + boolean isSatisfiable, String unsatisfiableReason, + Set optionalImpossibleProperties) { + this(properties, required, isSatisfiable, unsatisfiableReason, + optionalImpossibleProperties, + null, null, null, null, null, null, null, + null, null, null, null, null); + } + + public AllOfIntersection(Map properties, Set required, + boolean isSatisfiable, String unsatisfiableReason, + Set optionalImpossibleProperties, + String rootScalarType, List rootEnumValues, + Object rootConstValue, + BigDecimal rootMinimum, BigDecimal rootMaximum, + Boolean rootExclusiveMinimum, Boolean rootExclusiveMaximum, + Integer rootMinLength, Integer rootMaxLength, + BigDecimal rootExclusiveMinimumValue, + BigDecimal rootExclusiveMaximumValue, + Object additionalProperties) { + this(properties, required, isSatisfiable, unsatisfiableReason, + optionalImpossibleProperties, rootScalarType, rootEnumValues, + rootConstValue != null, rootConstValue, null, + rootMinimum, rootMaximum, rootExclusiveMinimum, rootExclusiveMaximum, + rootMinLength, rootMaxLength, rootExclusiveMinimumValue, + rootExclusiveMaximumValue, additionalProperties, true); + } + + public AllOfIntersection(Map properties, Set required, + boolean isSatisfiable, String unsatisfiableReason, + Set optionalImpossibleProperties, + String rootScalarType, List rootEnumValues, + boolean rootHasConst, Object rootConstValue, + String rootConstJson, + BigDecimal rootMinimum, BigDecimal rootMaximum, + Boolean rootExclusiveMinimum, Boolean rootExclusiveMaximum, + Integer rootMinLength, Integer rootMaxLength, + BigDecimal rootExclusiveMinimumValue, + BigDecimal rootExclusiveMaximumValue, + Object additionalProperties, boolean rootAllowsNull) { + this.properties = properties != null + ? Collections.unmodifiableMap(new LinkedHashMap<>(properties)) + : Collections.emptyMap(); + this.required = required != null + ? Collections.unmodifiableSet(new LinkedHashSet<>(required)) + : Collections.emptySet(); + this.isSatisfiable = isSatisfiable; + this.unsatisfiableReason = unsatisfiableReason; + this.optionalImpossibleProperties = optionalImpossibleProperties != null + ? Collections.unmodifiableSet(new LinkedHashSet<>(optionalImpossibleProperties)) + : Collections.emptySet(); + this.rootScalarType = rootScalarType; + this.rootEnumValues = rootEnumValues != null + ? Collections.unmodifiableList(new ArrayList<>(rootEnumValues)) + : null; + this.rootHasConst = rootHasConst; + this.rootConstValue = rootConstValue; + this.rootConstJson = rootConstJson; + this.rootAllowsNull = rootAllowsNull; + this.rootMinimum = rootMinimum; + this.rootMaximum = rootMaximum; + this.rootExclusiveMinimum = rootExclusiveMinimum; + this.rootExclusiveMaximum = rootExclusiveMaximum; + this.rootMinLength = rootMinLength; + this.rootMaxLength = rootMaxLength; + this.rootExclusiveMinimumValue = rootExclusiveMinimumValue; + this.rootExclusiveMaximumValue = rootExclusiveMaximumValue; + this.additionalProperties = additionalProperties; + } + + public Map getProperties() { return properties; } + public Set getRequired() { return required; } + public boolean isSatisfiable() { return isSatisfiable; } + public String getUnsatisfiableReason() { return unsatisfiableReason; } + public Set getOptionalImpossibleProperties() { return optionalImpossibleProperties; } + public String getRootScalarType() { return rootScalarType; } + public List getRootEnumValues() { return rootEnumValues; } + public boolean hasRootConst() { return rootHasConst; } + public Object getRootConstValue() { return rootConstValue; } + public String getRootConstJson() { return rootConstJson; } + public boolean allowsRootNull() { return rootAllowsNull; } + public BigDecimal getRootMinimum() { return rootMinimum; } + public BigDecimal getRootMaximum() { return rootMaximum; } + public Boolean getRootExclusiveMinimum() { return rootExclusiveMinimum; } + public Boolean getRootExclusiveMaximum() { return rootExclusiveMaximum; } + public Integer getRootMinLength() { return rootMinLength; } + public Integer getRootMaxLength() { return rootMaxLength; } + public BigDecimal getRootExclusiveMinimumValue() { + return rootExclusiveMinimumValue; + } + public BigDecimal getRootExclusiveMaximumValue() { + return rootExclusiveMaximumValue; + } + public Object getAdditionalProperties() { return additionalProperties; } + } + + /** + * Builds a CompositionDescriptor for a schema if it has oneOf, anyOf, or + * allOf branches. Returns null for non-composed schemas. + * Records JSON Pointer locations for diagnostic use. + */ + static List buildCompositionDescriptors( + String schemaName, Schema schema, OpenAPI openAPI, + Map schemas) { + if (schema == null) return Collections.emptyList(); + + List descriptors = new ArrayList<>(); + addCompositionDescriptor(descriptors, schemaName, schema, openAPI, schemas, + "oneOf", schema.getOneOf()); + addCompositionDescriptor(descriptors, schemaName, schema, openAPI, schemas, + "anyOf", schema.getAnyOf()); + addCompositionDescriptor(descriptors, schemaName, schema, openAPI, schemas, + "allOf", schema.getAllOf()); + return descriptors; + } + + private static void addCompositionDescriptor( + List descriptors, String schemaName, Schema schema, + OpenAPI openAPI, Map schemas, String keyword, + List branchSchemas) { + if (branchSchemas != null && !branchSchemas.isEmpty()) { + descriptors.add(buildCompositionDescriptor( + schemaName, schema, openAPI, schemas, keyword, branchSchemas)); + } + } + + static CompositionDescriptor buildCompositionDescriptor( + String schemaName, Schema schema, OpenAPI openAPI, + Map schemas) { + List descriptors = + buildCompositionDescriptors(schemaName, schema, openAPI, schemas); + return descriptors.isEmpty() ? null : descriptors.get(0); + } + + private static CompositionDescriptor buildCompositionDescriptor( + String schemaName, Schema schema, OpenAPI openAPI, + Map schemas, String keyword, List branchSchemas) { + + String schemaLocation = "#/components/schemas/" + schemaName; + List branches = new ArrayList<>(); + + // Capture optional discriminator + DiscriminatorDescriptor discriminatorDescriptor = null; + if (schema.getDiscriminator() != null) { + discriminatorDescriptor = new DiscriminatorDescriptor( + schema.getDiscriminator().getPropertyName(), + schema.getDiscriminator().getMapping()); + } + + for (int index = 0; index < branchSchemas.size(); index++) { + Schema branchSchema = branchSchemas.get(index); + String sourceRef = null; + String resolvedName = null; + CompositionBranchDescriptor.NullCapability nullCap = + CompositionBranchDescriptor.NullCapability.NEVER; + List supported = new ArrayList<>(); + List unsupported = new ArrayList<>(); + Map validateParams = new LinkedHashMap<>(); + + // Resolve the branch schema for assertion scanning + Schema targetForAssertions = null; + Schema referenceSchema = referenceSchemaOf(branchSchema); + boolean normalizedRefWrapper = referenceSchema != null + && referenceSchema != branchSchema; + if (referenceSchema != null) { + sourceRef = referenceSchema.get$ref(); + String refName = ModelUtils.getSimpleRef(referenceSchema.get$ref()); + resolvedName = refName; + // Preserve the local ref target for later IR resolution. + validateParams.put("validation-ref", refName); + // Detect null type via $ref to null schema + if ("null".equals(refName)) { + nullCap = CompositionBranchDescriptor.NullCapability.ALWAYS; + } else if (schemas.containsKey(refName)) { + Schema refTarget = schemas.get(refName); + if (ModelUtils.isNullTypeSchema(openAPI, refTarget)) { + nullCap = CompositionBranchDescriptor.NullCapability.ALWAYS; + } else { + if (Boolean.TRUE.equals(refTarget.getNullable())) { + nullCap = CompositionBranchDescriptor.NullCapability.CONDITIONAL; + } + targetForAssertions = refTarget; + } + } + } else if (branchSchema != null) { + targetForAssertions = branchSchema; + // Detect OAS 3.1 boolean value schemas (true/false literals) + if (branchSchema.getBooleanSchemaValue() != null) { + resolvedName = "boolean-schema"; + } else { + resolvedName = branchSchema.getType(); + } + if (resolvedName == null) { + if (branchSchema.getEnum() != null && !branchSchema.getEnum().isEmpty()) { + resolvedName = "enum"; + } else { + resolvedName = "object"; + } + } + if (ModelUtils.isNullType(branchSchema)) { + nullCap = CompositionBranchDescriptor.NullCapability.ALWAYS; + } else if (Boolean.TRUE.equals(branchSchema.getNullable())) { + nullCap = CompositionBranchDescriptor.NullCapability.CONDITIONAL; + } + } + + // Scan the resolved target schema for assertion keywords. + // Under JSON Schema 2020-12, a $ref target and its sibling keywords + // both apply. Scan both surfaces and retain unsupported siblings for + // fail-closed diagnostics. + boolean refBranch = referenceSchema != null; + if (targetForAssertions != null) { + Oas31SchemaSurfaceAssertionScanner.scanSurfaceAssertions(targetForAssertions, openAPI, + supported, unsupported, validateParams, refBranch); + } + if (refBranch && referenceSchema != targetForAssertions) { + // $ref with siblings: BOTH the resolved target and the ref's + // own keyword set apply (2020-12). Ref-node applicator stays + // branch-driven; sibling keywords are emitted inline. + Oas31SchemaSurfaceAssertionScanner.scanSurfaceAssertions(referenceSchema, openAPI, + supported, unsupported, validateParams, false); + } + if (normalizedRefWrapper) { + // OpenAPINormalizer moves a $ref beside annotations/assertions + // into a singleton allOf. Scan the outer siblings as adjacent + // keywords, then discard only that synthetic applicator. + Oas31SchemaSurfaceAssertionScanner.scanSurfaceAssertions(branchSchema, openAPI, + supported, unsupported, validateParams, false); + validateParams.remove("validation-allof-schemas"); + } + + // Dynamic-scope markers belong to the branch's own surface, never + // the ref target, and flow into the branch's IR parameters. + if (branchSchema != null) { + java.util.Map ext = branchSchema.getExtensions(); + if (ext != null) { + Object dynres = ext.get("x-oas31-res"); + if (dynres instanceof Number) { + validateParams.put("validation-dynamic-resource", + ((Number) dynres).intValue()); + } + Object dynroot = ext.get("x-oas31-res-root"); + if (Boolean.TRUE.equals(dynroot) || (dynroot instanceof Number + && ((Number) dynroot).intValue() != 0)) { + validateParams.put("validation-resource-root", Boolean.TRUE); + } + Object vinert = ext.get("x-oas31-vocab-inert"); + if (Boolean.TRUE.equals(vinert)) { + validateParams.put("validation-vocab-inert", Boolean.TRUE); + } + Object dynref = ext.get("x-oas31-dynref"); + if (dynref != null) { + validateParams.put("validation-dynamic-ref-anchor", + String.valueOf(dynref)); + } + } + if (branchSchema.get$dynamicAnchor() != null + && !branchSchema.get$dynamicAnchor().isEmpty()) { + validateParams.put("validation-dynamic-anchor", + branchSchema.get$dynamicAnchor()); + } + } + + // Generate a deterministic base name for branch validation dispatch. + String validatorId = CppBoostBeastClientCodegen.toValidIdentifier(schemaName) + + "_branch_" + index; + + CompositionBranchDescriptor branch = new CompositionBranchDescriptor( + index, sourceRef, resolvedName, null, validatorId, + nullCap, supported, unsupported, validateParams); + branches.add(branch); + } + + return new CompositionDescriptor( + schemaName, schemaLocation, keyword, branches, + discriminatorDescriptor); + } + + /** + * Returns the direct reference schema, including the singleton-allOf shape + * produced by {@link org.openapitools.codegen.OpenAPINormalizer} for a + * {@code $ref} with sibling keywords. + */ + static Schema referenceSchemaOf(Schema branchSchema) { + if (branchSchema == null) return null; + if (branchSchema.get$ref() != null) return branchSchema; + List allOf = branchSchema.getAllOf(); + if (allOf == null || allOf.size() != 1) return null; + Schema candidate = allOf.get(0); + return candidate != null && candidate.get$ref() != null ? candidate : null; + } + + /** + * Fails generation when a composition branch has an assertion without a + * membership-preserving implementation. + */ + static void validateDescriptorAssertions(CompositionDescriptor desc) { + if (desc == null) return; + for (CompositionBranchDescriptor branch : desc.getBranches()) { + for (String unsupported : branch.getUnsupportedAssertions()) { + throw new CppBoostBeastClientCodegen.UnsupportedSchemaAssertionException( + desc.getSchemaLocation(), unsupported); + } + } + } + + /** Tracks the strictest bound at one end of a numeric allOf range. */ + private static final class NumericBound { + private BigDecimal value; + private boolean exclusive; + + private void mergeLower(BigDecimal candidate, boolean candidateExclusive) { + if (candidate == null) return; + if (value == null || candidate.compareTo(value) > 0 + || (candidate.compareTo(value) == 0 && candidateExclusive && !exclusive)) { + value = candidate; + exclusive = candidateExclusive; + } + } + + private void mergeUpper(BigDecimal candidate, boolean candidateExclusive) { + if (candidate == null) return; + if (value == null || candidate.compareTo(value) < 0 + || (candidate.compareTo(value) == 0 && candidateExclusive && !exclusive)) { + value = candidate; + exclusive = candidateExclusive; + } + } + } + + // ======================================================================== + // Recursive allOf intersection engine + // ======================================================================== + + /** + * Computes the recursive intersection of all allOf contributors. + * Resolves $ref-to-allOf chains recursively with cycle detection via the + * visited set. Merges properties, unions required, and detects + * unsatisfiable intersections. + *

    + * For each property that appears in multiple contributors, their property + * schemas are recursively intersected. If the intersection of a required + * property is empty, the model is unsatisfiable. If the intersection of + * an optional property is empty, the property is tagged as + * optional-impossible (rejected when present, but does not invalidate + * an otherwise valid object). + * + * @param schemaName the source schema name (for diagnostics) + * @param schema the allOf schema whose branches to intersect + * @param openAPI the parsed OpenAPI document + * @param schemas the component schemas index + * @param visited set of already-visited schema names (cycle guard) + * @return the computed intersection, or null if no allOf branches + * @throws AllOfRequiredUnsatisfiableException if a required intersection + * is empty and the model cannot be generated + */ + static AllOfIntersection computeAllOfIntersection( + String schemaName, Schema schema, OpenAPI openAPI, + Map schemas, Set visited) { + if (schema == null) return null; + List allOfBranches = schema.getAllOf(); + if (allOfBranches == null || allOfBranches.isEmpty()) return null; + + // Register at entry so recursive allOf references return a cycle sentinel. + if (schemaName != null && visited.contains(schemaName)) { + return new AllOfIntersection( + new LinkedHashMap<>(), new LinkedHashSet<>(), + true, null, new LinkedHashSet<>()); + } + if (schemaName != null) { + visited.add(schemaName); + } + + Map mergedProperties = new LinkedHashMap<>(); + Set mergedRequired = new LinkedHashSet<>(); + Set optionalImpossibleProperties = new LinkedHashSet<>(); + boolean satisfiable = true; + String unsatisfiableReason = null; + + // Root-level scalar intersection tracking + String rootScalarType = null; + List rootEnumValues = null; + Object rootConstValue = null; + boolean rootHasConst = false; + String rootConstJson = null; + BigDecimal rootMinimum = null; + BigDecimal rootMaximum = null; + Boolean rootExclusiveMinimumObj = null; + Boolean rootExclusiveMaximumObj = null; + BigDecimal rootExclusiveMinimumValue = null; + BigDecimal rootExclusiveMaximumValue = null; + Object additionalProperties = null; + NumericBound lowerBound = new NumericBound(); + NumericBound upperBound = new NumericBound(); + Integer rootMinLength = null; + Integer rootMaxLength = null; + boolean hasRootScalarConstraints = false; + boolean rootEnumIntersected = false; + boolean rootAllowsNull = true; + + for (int bi = 0; bi < allOfBranches.size(); bi++) { + Schema branch = allOfBranches.get(bi); + Schema resolvedBranch = resolveAllOfBranch(branch, openAPI, schemas, visited); + if (resolvedBranch == null) continue; + rootAllowsNull &= allowsNull(resolvedBranch, declaredTypes(resolvedBranch)); + + // Detect nested allOf within the resolved branch and recurse. + if (resolvedBranch.getAllOf() != null && !resolvedBranch.getAllOf().isEmpty()) { + AllOfIntersection nested = computeAllOfIntersection( + schemaName + "_nested_" + bi, resolvedBranch, openAPI, schemas, visited); + if (nested != null) { + rootAllowsNull &= nested.allowsRootNull(); + mergeIntersectionIntoResult(mergedProperties, mergedRequired, + optionalImpossibleProperties, nested, openAPI, schemas); + if (!nested.isSatisfiable()) { + satisfiable = false; + unsatisfiableReason = nested.getUnsatisfiableReason(); + } + // Propagate optional-impossible entries from nested + optionalImpossibleProperties.addAll(nested.getOptionalImpossibleProperties()); + additionalProperties = intersectAdditionalProperties( + additionalProperties, nested.getAdditionalProperties(), + openAPI, schemas); + + if (nested.getRootScalarType() != null) { + hasRootScalarConstraints = true; + String nestedRootType = nested.getRootScalarType(); + if (rootScalarType == null) { + rootScalarType = nestedRootType; + } else if (!rootScalarType.equals(nestedRootType)) { + if (("integer".equals(nestedRootType) + && "number".equals(rootScalarType)) + || ("number".equals(nestedRootType) + && "integer".equals(rootScalarType))) { + rootScalarType = "integer"; + } else { + satisfiable = false; + unsatisfiableReason = "Incompatible root types across allOf '" + + schemaName + "' contributors: '" + rootScalarType + + "' vs '" + nestedRootType + "'"; + } + } + } + if (nested.getRootEnumValues() != null) { + hasRootScalarConstraints = true; + if (rootEnumValues == null) { + rootEnumValues = new ArrayList<>(nested.getRootEnumValues()); + rootEnumIntersected = false; + } else { + rootEnumValues = intersectJsonValues( + rootEnumValues, nested.getRootEnumValues()); + rootEnumIntersected = true; + } + } + if (nested.hasRootConst()) { + hasRootScalarConstraints = true; + Object nestedConstValue = nested.getRootConstValue(); + String nestedConstJson = nested.getRootConstJson(); + if (!rootHasConst) { + rootHasConst = true; + rootConstValue = nestedConstValue; + rootConstJson = nestedConstJson; + } else if (!constValuesEqual(rootConstValue, rootConstJson, + nestedConstValue, nestedConstJson)) { + satisfiable = false; + unsatisfiableReason = "Incompatible const values across allOf '" + + schemaName + "' contributors: '" + + rootConstJson + "' vs '" + nestedConstJson + "'"; + } + } + if (nested.getRootMinimum() != null) { + lowerBound.mergeLower(nested.getRootMinimum(), + Boolean.TRUE.equals(nested.getRootExclusiveMinimum())); + hasRootScalarConstraints = true; + } + if (nested.getRootMaximum() != null) { + upperBound.mergeUpper(nested.getRootMaximum(), + Boolean.TRUE.equals(nested.getRootExclusiveMaximum())); + hasRootScalarConstraints = true; + } + if (nested.getRootMinLength() != null) { + rootMinLength = tighterMinLen(rootMinLength, nested.getRootMinLength()); + hasRootScalarConstraints = true; + } + if (nested.getRootMaxLength() != null) { + rootMaxLength = tighterMaxLen(rootMaxLength, nested.getRootMaxLength()); + hasRootScalarConstraints = true; + } + } + } + + // Merge this contributor's properties into the result. + // For properties that already exist (from a prior contributor), + // recursively intersect the property schemas. + if (resolvedBranch.getProperties() != null) { + @SuppressWarnings("rawtypes") + Map rawProps = resolvedBranch.getProperties(); + @SuppressWarnings("unchecked") + Map typedProps = rawProps; + for (Map.Entry propEntry + : typedProps.entrySet()) { + String propName = propEntry.getKey(); + Schema propSchema = propEntry.getValue(); + if (mergedProperties.containsKey(propName)) { + Schema existing = mergedProperties.get(propName); + Schema intersected = intersectPropertySchemas( + existing, propSchema, openAPI, schemas, new HashSet<>()); + mergedProperties.put(propName, intersected); + } else { + mergedProperties.put(propName, propSchema); + } + } + } + + // Union required property sets + if (resolvedBranch.getRequired() != null) { + mergedRequired.addAll(resolvedBranch.getRequired()); + } + + // A false constraint closes the object; schema constraints are + // intersected for the remaining open contributors. + additionalProperties = intersectAdditionalProperties( + additionalProperties, resolvedBranch.getAdditionalProperties(), + openAPI, schemas); + + // Accumulate root-level scalar constraints from non-object branches + // (branches that contribute no properties). + if (resolvedBranch.getProperties() == null + || resolvedBranch.getProperties().isEmpty()) { + // Intersect root-level type + String branchType = resolvedBranch.getType(); + if (branchType != null) { + hasRootScalarConstraints = true; + if (rootScalarType == null) { + rootScalarType = branchType; + } else if (!rootScalarType.equals(branchType)) { + // Compatible numeric types + if ("integer".equals(branchType) && "number".equals(rootScalarType)) { + rootScalarType = "integer"; + } else if ("number".equals(branchType) && "integer".equals(rootScalarType)) { + rootScalarType = "integer"; + } else { + satisfiable = false; + unsatisfiableReason = "Incompatible root types across allOf '" + + schemaName + "' contributors: '" + rootScalarType + + "' vs '" + branchType + "'"; + } + } + } + + // Intersect root-level enum (intersection of all branch enum sets) + List branchEnum = resolvedBranch.getEnum(); + if (branchEnum != null && !branchEnum.isEmpty()) { + hasRootScalarConstraints = true; + if (rootEnumValues == null) { + rootEnumValues = new ArrayList<>(branchEnum); + rootEnumIntersected = false; + } else { + rootEnumValues = intersectJsonValues(rootEnumValues, branchEnum); + rootEnumIntersected = true; + } + } + + // Intersect root-level const (must match). swagger-parser uses + // null both for an absent const and an explicit JSON null, so use + boolean branchHasConst = hasConstConstraint(resolvedBranch); + if (branchHasConst) { + hasRootScalarConstraints = true; + Object branchConst = resolvedBranch.getConst(); + String branchConstJson = Oas31RawSpecRecovery.constJsonOf(resolvedBranch); + if (!rootHasConst) { + rootHasConst = true; + rootConstValue = branchConst; + rootConstJson = branchConstJson; + } else if (!constValuesEqual(rootConstValue, rootConstJson, + branchConst, branchConstJson)) { + satisfiable = false; + unsatisfiableReason = "Incompatible const values across allOf '" + + schemaName + "' contributors: '" + + rootConstJson + "' vs '" + branchConstJson + "'"; + } + } + + // Merge numeric bounds as value/exclusivity pairs so a smaller + // exclusive bound cannot make a larger inclusive bound strict. + if (resolvedBranch.getMinimum() != null) { + hasRootScalarConstraints = true; + lowerBound.mergeLower(resolvedBranch.getMinimum(), + Boolean.TRUE.equals(resolvedBranch.getExclusiveMinimum())); + } + if (resolvedBranch.getExclusiveMinimumValue() != null) { + hasRootScalarConstraints = true; + lowerBound.mergeLower(resolvedBranch.getExclusiveMinimumValue(), true); + } + if (resolvedBranch.getMaximum() != null) { + hasRootScalarConstraints = true; + upperBound.mergeUpper(resolvedBranch.getMaximum(), + Boolean.TRUE.equals(resolvedBranch.getExclusiveMaximum())); + } + if (resolvedBranch.getExclusiveMaximumValue() != null) { + hasRootScalarConstraints = true; + upperBound.mergeUpper(resolvedBranch.getExclusiveMaximumValue(), true); + } + + // Intersect minLength / maxLength: tighter wins + if (resolvedBranch.getMinLength() != null) { + hasRootScalarConstraints = true; + Integer branchMinLength = resolvedBranch.getMinLength(); + if (rootMinLength == null || branchMinLength > rootMinLength) { + rootMinLength = branchMinLength; + } + } + if (resolvedBranch.getMaxLength() != null) { + hasRootScalarConstraints = true; + Integer branchMaxLength = resolvedBranch.getMaxLength(); + if (rootMaxLength == null || branchMaxLength < rootMaxLength) { + rootMaxLength = branchMaxLength; + } + } + } + } + + // Detect empty enum intersection: two or more branches both contributed + // enum lists whose intersection is empty (e.g., [a,b] ∩ [c,d] = {}). + if (rootEnumIntersected && rootEnumValues != null && rootEnumValues.isEmpty()) { + satisfiable = false; + unsatisfiableReason = "Empty enum intersection across allOf '" + + schemaName + "' contributors: no common enum values"; + } + + if (rootHasConst && !constSatisfiesConstraints( + rootConstValue, rootConstJson, + rootScalarType != null + ? Collections.singleton(rootScalarType) : Collections.emptySet(), + rootAllowsNull, rootEnumValues)) { + satisfiable = false; + unsatisfiableReason = "Root const value in allOf '" + schemaName + + "' is excluded by a sibling type or enum constraint"; + } + + // Required properties must have non-empty intersections. The property + // intersection helper records unsatisfiable schemas with an extension + // marker, which is converted to a model-level generation error below. + + // Detect unsatisfiable required properties: + // Scan merged properties for unsatisfiable markers. + for (String propName : mergedRequired) { + Schema propSchema = mergedProperties.get(propName); + if (propSchema != null && Boolean.TRUE.equals( + propSchema.getExtensions() != null + ? propSchema.getExtensions().get("x-cpp-unsatisfiable") + : null)) { + satisfiable = false; + unsatisfiableReason = "Required property '" + propName + + "' in schema '" + schemaName + + "' has an empty intersection across allOf contributors. " + + "This property is required but cannot satisfy all " + + "contributor constraints simultaneously."; + } + } + + // Tag optional impossible properties: present in merged but marked + // with the unsatisfiable flag and NOT in mergedRequired. + for (Map.Entry entry : mergedProperties.entrySet()) { + String propName = entry.getKey(); + if (mergedRequired.contains(propName)) continue; + Schema propSchema = entry.getValue(); + if (propSchema != null && Boolean.TRUE.equals( + propSchema.getExtensions() != null + ? propSchema.getExtensions().get("x-cpp-unsatisfiable") + : null)) { + optionalImpossibleProperties.add(propName); + } + } + + rootMinimum = lowerBound.value; + rootMaximum = upperBound.value; + rootExclusiveMinimumObj = lowerBound.exclusive ? Boolean.TRUE : null; + rootExclusiveMaximumObj = upperBound.exclusive ? Boolean.TRUE : null; + rootExclusiveMinimumValue = lowerBound.exclusive ? lowerBound.value : null; + rootExclusiveMaximumValue = upperBound.exclusive ? upperBound.value : null; + + // If no scalar constraints were accumulated but properties exist, null out root fields + if (!hasRootScalarConstraints) { + rootScalarType = null; + rootEnumValues = null; + rootConstValue = null; + rootHasConst = false; + rootConstJson = null; + rootMinimum = null; + rootMaximum = null; + rootExclusiveMinimumObj = null; + rootExclusiveMaximumObj = null; + rootMinLength = null; + rootMaxLength = null; + rootExclusiveMinimumValue = null; + rootExclusiveMaximumValue = null; + } + + return new AllOfIntersection( + mergedProperties, mergedRequired, satisfiable, + unsatisfiableReason, optionalImpossibleProperties, + rootScalarType, rootEnumValues, rootHasConst, rootConstValue, rootConstJson, + rootMinimum, rootMaximum, + rootExclusiveMinimumObj, rootExclusiveMaximumObj, + rootMinLength, rootMaxLength, + rootExclusiveMinimumValue, rootExclusiveMaximumValue, + additionalProperties, rootAllowsNull); + } + + /** + * Merges a nested AllOfIntersection into a running result. + * For properties that already exist, recursively intersects them. + */ + private static void mergeIntersectionIntoResult( + Map mergedProperties, Set mergedRequired, + Set optionalImpossibleProperties, + AllOfIntersection nested, + OpenAPI openAPI, Map schemas) { + for (Map.Entry nestedProp : nested.getProperties().entrySet()) { + String propName = nestedProp.getKey(); + Schema nestedSchema = nestedProp.getValue(); + if (mergedProperties.containsKey(propName)) { + mergedProperties.put(propName, + intersectPropertySchemas( + mergedProperties.get(propName), + nestedSchema, openAPI, schemas, new HashSet<>())); + } else { + mergedProperties.put(propName, nestedSchema); + } + } + mergedRequired.addAll(nested.getRequired()); + optionalImpossibleProperties.addAll(nested.getOptionalImpossibleProperties()); + } + + private static Object intersectAdditionalProperties( + Object existing, Object incoming, OpenAPI openAPI, Map schemas) { + if (Boolean.FALSE.equals(existing) || Boolean.FALSE.equals(incoming)) { + return Boolean.FALSE; + } + if (existing instanceof Schema && incoming instanceof Schema) { + return intersectPropertySchemas((Schema) existing, (Schema) incoming, + openAPI, schemas, new HashSet<>()); + } + if (existing instanceof Schema) { + return existing; + } + if (incoming instanceof Schema) { + return incoming; + } + // true and an omitted constraint both leave additional values open. + return null; + } + + /** + * Resolves an allOf branch schema, following $ref targets recursively. + * If the branch has a $ref, resolves it to a non-allOf schema. + * If the resolved target is itself allOf, returns it as-is for + * recursive handling by the caller. + * + * @param branch the allOf contributor (possibly a $ref) + * @param openAPI the parsed OpenAPI document + * @param schemas the component schemas index + * @param visited set of already-visited schema names (cycle guard) + * @return the resolved schema, or null if unresolvable + */ + private static Schema resolveAllOfBranch( + Schema branch, OpenAPI openAPI, + Map schemas, Set visited) { + if (branch == null) return null; + if (branch.get$ref() == null) return branch; + + String refName = ModelUtils.getSimpleRef(branch.get$ref()); + if (refName == null) return branch; + if (visited.contains(refName)) return branch; // cycle guard + + Schema refTarget = schemas != null ? schemas.get(refName) : null; + if (refTarget == null && openAPI != null) { + refTarget = ModelUtils.getReferencedSchema(openAPI, branch); + } + if (refTarget == null) return branch; + + visited.add(refName); + try { + // If the resolved target also has allOf, recurse + if (refTarget.getAllOf() != null && !refTarget.getAllOf().isEmpty()) { + return refTarget; // Return so caller can recurse + } + // If the resolved target has properties, return it directly + if (refTarget.getProperties() != null && !refTarget.getProperties().isEmpty()) { + return refTarget; + } + return refTarget; + } finally { + visited.remove(refName); + } + } + + /** + * Intersects two property schemas, combining their constraints. + * Returns a synthetic Schema that represents the intersection: + *
      + *
    • Types are intersected (must have a common type)
    • + *
    • Enums are intersected (common values only)
    • + *
    • Numeric bounds are tightened
    • + *
    • String bounds are tightened
    • + *
    • Patterns are retained from both
    • + *
    • Required properties are unioned
    • + *
    • Properties are recursively intersected
    • + *
    + *

    + * When the intersection is empty (e.g., string ∩ integer), the resulting + * Schema is tagged with vendor extension {@code x-cpp-unsatisfiable: true} + * and the property should either fail generation (if required) or generate + * decode-time rejection (if optional). + */ + private static Schema resolveReferenceWithSiblings( + Schema source, OpenAPI openAPI, Map schemas, Set visited, + IdentityHashMap> activePairs) { + if (source == null || source.get$ref() == null) { + return source; + } + Schema target = ModelUtils.getReferencedSchema(openAPI, source); + if (target == null || target == source || !hasReferenceSiblings(source)) { + return target == null ? source : target; + } + + String reference = source.get$ref(); + source.set$ref(null); + try { + return intersectPropertySchemas(target, source, openAPI, schemas, visited, activePairs); + } finally { + source.set$ref(reference); + } + } + + private static boolean hasReferenceSiblings(Schema schema) { + return schema.getType() != null || schema.getTypes() != null + || schema.getEnum() != null || hasConstConstraint(schema) + || schema.getMinimum() != null || schema.getMaximum() != null + || schema.getExclusiveMinimumValue() != null + || schema.getExclusiveMaximumValue() != null + || Boolean.TRUE.equals(schema.getExclusiveMinimum()) + || Boolean.TRUE.equals(schema.getExclusiveMaximum()) + || schema.getMultipleOf() != null || schema.getPattern() != null + || schema.getMinLength() != null || schema.getMaxLength() != null + || schema.getMinItems() != null || schema.getMaxItems() != null + || schema.getMinProperties() != null || schema.getMaxProperties() != null + || schema.getProperties() != null || schema.getRequired() != null + || schema.getAdditionalProperties() != null || schema.getNullable() != null; + } + + private static Set declaredTypes(Schema schema) { + Set types = new LinkedHashSet<>(); + if (schema.getType() != null) { + types.add(schema.getType()); + } + if (schema.getTypes() != null) { + for (Object type : schema.getTypes()) { + if (type != null) { + types.add(String.valueOf(type)); + } + } + } + return types; + } + + private static boolean allowsNull(Schema schema, Set types) { + if (!Boolean.TRUE.equals(schema.getNullable()) + && !Oas31RawSpecRecovery.pristineTypeHasNull(schema) + && !types.isEmpty() && !types.contains("null")) { + return false; + } + if (schema.getConst() != null) { + return false; + } + if (Oas31RawSpecRecovery.hasExplicitConst(schema)) { + return "null".equals(Oas31RawSpecRecovery.constJsonOf(schema)); + } + List enumValues = schema.getEnum(); + return enumValues == null || enumValues.contains(null); + } + + private static Set intersectDeclaredTypes( + Set existingTypes, Set incomingTypes) { + if (existingTypes.isEmpty()) { + return new LinkedHashSet<>(incomingTypes); + } + if (incomingTypes.isEmpty()) { + return new LinkedHashSet<>(existingTypes); + } + + Set intersection = new LinkedHashSet<>(); + for (String existingType : existingTypes) { + for (String incomingType : incomingTypes) { + if (existingType.equals(incomingType)) { + intersection.add(existingType); + } else if (("integer".equals(existingType) && "number".equals(incomingType)) + || ("number".equals(existingType) && "integer".equals(incomingType))) { + intersection.add("integer"); + } + } + } + return intersection; + } + + private static Schema intersectPropertySchemas( + Schema existing, Schema incoming, + OpenAPI openAPI, Map schemas, Set visited) { + return intersectPropertySchemas(existing, incoming, openAPI, schemas, visited, + new IdentityHashMap<>()); + } + + private static Schema intersectPropertySchemas( + Schema existing, Schema incoming, + OpenAPI openAPI, Map schemas, Set visited, + IdentityHashMap> activePairs) { + if (existing == null) return incoming; + if (incoming == null) return existing; + + // Track the active pair by object identity, not a collision-prone hash. + Set activeIncoming = activePairs.computeIfAbsent(existing, + ignored -> Collections.newSetFromMap(new IdentityHashMap())); + if (!activeIncoming.add(incoming)) { + return existing; + } + try { + return intersectPropertySchemaConstraints( + existing, incoming, openAPI, schemas, visited, activePairs); + } finally { + activeIncoming.remove(incoming); + if (activeIncoming.isEmpty()) { + activePairs.remove(existing); + } + } + } + + private static Schema intersectPropertySchemaConstraints( + Schema existing, Schema incoming, + OpenAPI openAPI, Map schemas, Set visited, + IdentityHashMap> activePairs) { + // Resolve $ref targets without discarding constraints attached to a + // 2020-12 reference node. + existing = resolveReferenceWithSiblings( + existing, openAPI, schemas, visited, activePairs); + incoming = resolveReferenceWithSiblings( + incoming, openAPI, schemas, visited, activePairs); + + // Both non-null: compute intersection. + Set existingTypes = declaredTypes(existing); + Set incomingTypes = declaredTypes(incoming); + Set intersectedTypes = intersectDeclaredTypes(existingTypes, incomingTypes); + boolean typeCompatible = existingTypes.isEmpty() || incomingTypes.isEmpty() + || !intersectedTypes.isEmpty(); + + // Build the intersected schema. + Schema intersected = new Schema(); + if (!intersectedTypes.isEmpty()) { + if (intersectedTypes.size() == 1 && !intersectedTypes.contains("null")) { + intersected.setType(intersectedTypes.iterator().next()); + } else { + intersected.setTypes(intersectedTypes); + } + } + boolean existingAllowsNull = allowsNull(existing, existingTypes); + boolean incomingAllowsNull = allowsNull(incoming, incomingTypes); + if (existingAllowsNull && incomingAllowsNull) { + intersected.setNullable(true); + } + + List existingEnum = existing.getEnum(); + List incomingEnum = incoming.getEnum(); + List intersectedEnum = null; + if (existingEnum != null && incomingEnum != null) { + intersectedEnum = intersectJsonValues(existingEnum, incomingEnum); + if (intersectedEnum.isEmpty()) { + typeCompatible = false; + } + } + + // Intersect enum values + if (intersectedEnum != null && !intersectedEnum.isEmpty()) { + intersected.setEnum(intersectedEnum); + } else if (existingEnum != null && incomingEnum == null) { + intersected.setEnum(new ArrayList<>(existingEnum)); + } else if (incomingEnum != null && existingEnum == null) { + intersected.setEnum(new ArrayList<>(incomingEnum)); + } + + // Intersect const values, including an explicit recovered JSON null. + boolean existingHasConst = hasConstConstraint(existing); + boolean incomingHasConst = hasConstConstraint(incoming); + if (existingHasConst && incomingHasConst) { + if (constValuesEqual(existing.getConst(), Oas31RawSpecRecovery.constJsonOf(existing), + incoming.getConst(), Oas31RawSpecRecovery.constJsonOf(incoming))) { + copyConstConstraint(existing, intersected); + } else { + typeCompatible = false; // conflicting const values + } + } else if (existingHasConst) { + copyConstConstraint(existing, intersected); + } else if (incomingHasConst) { + copyConstConstraint(incoming, intersected); + } + + if (hasConstConstraint(intersected) && !constSatisfiesConstraints( + intersected.getConst(), Oas31RawSpecRecovery.constJsonOf(intersected), + intersectedTypes, Boolean.TRUE.equals(intersected.getNullable()), + intersected.getEnum())) { + typeCompatible = false; + } + + // Numeric bounds are compared as value/exclusivity pairs. A strict + // bound matters only when it is the tightest bound at that endpoint. + NumericBound lowerBound = new NumericBound(); + NumericBound upperBound = new NumericBound(); + if (existing.getMinimum() != null) { + lowerBound.mergeLower(existing.getMinimum(), + Boolean.TRUE.equals(existing.getExclusiveMinimum())); + } + if (existing.getExclusiveMinimumValue() != null) { + lowerBound.mergeLower(existing.getExclusiveMinimumValue(), true); + } + if (incoming.getMinimum() != null) { + lowerBound.mergeLower(incoming.getMinimum(), + Boolean.TRUE.equals(incoming.getExclusiveMinimum())); + } + if (incoming.getExclusiveMinimumValue() != null) { + lowerBound.mergeLower(incoming.getExclusiveMinimumValue(), true); + } + if (lowerBound.value != null) { + intersected.setMinimum(lowerBound.value); + if (lowerBound.exclusive) { + intersected.setExclusiveMinimum(true); + intersected.setExclusiveMinimumValue(lowerBound.value); + } + } + if (existing.getMaximum() != null) { + upperBound.mergeUpper(existing.getMaximum(), + Boolean.TRUE.equals(existing.getExclusiveMaximum())); + } + if (existing.getExclusiveMaximumValue() != null) { + upperBound.mergeUpper(existing.getExclusiveMaximumValue(), true); + } + if (incoming.getMaximum() != null) { + upperBound.mergeUpper(incoming.getMaximum(), + Boolean.TRUE.equals(incoming.getExclusiveMaximum())); + } + if (incoming.getExclusiveMaximumValue() != null) { + upperBound.mergeUpper(incoming.getExclusiveMaximumValue(), true); + } + if (upperBound.value != null) { + intersected.setMaximum(upperBound.value); + if (upperBound.exclusive) { + intersected.setExclusiveMaximum(true); + intersected.setExclusiveMaximumValue(upperBound.value); + } + } + if (existing.getMultipleOf() != null || incoming.getMultipleOf() != null) { + // The synthetic schema is for C++ storage modeling; retain one + // representative constraint. The evaluator still validates every + // original allOf contributor. + if (existing.getMultipleOf() != null) { + intersected.setMultipleOf(existing.getMultipleOf()); + } else { + intersected.setMultipleOf(incoming.getMultipleOf()); + } + } + + // String bounds: take the tighter + intersected.setMinLength(tighterMinLen( + existing.getMinLength(), incoming.getMinLength())); + intersected.setMaxLength(tighterMaxLen( + existing.getMaxLength(), incoming.getMaxLength())); + + // Retain one pattern on the synthetic storage schema. Exact membership + // evaluates every original allOf contributor, so no assertion is lost. + if (existing.getPattern() != null || incoming.getPattern() != null) { + if (existing.getPattern() != null) { + intersected.setPattern(existing.getPattern()); + } else { + intersected.setPattern(incoming.getPattern()); + } + } + + // Array bounds: take the tighter + intersected.setMinItems(tighterMinLen( + existing.getMinItems(), incoming.getMinItems())); + intersected.setMaxItems(tighterMaxLen( + existing.getMaxItems(), incoming.getMaxItems())); + if (Boolean.TRUE.equals(existing.getUniqueItems()) + || Boolean.TRUE.equals(incoming.getUniqueItems())) { + intersected.setUniqueItems(true); + } + + // Object bounds: take the tighter + intersected.setMinProperties(tighterMinLen( + existing.getMinProperties(), incoming.getMinProperties())); + intersected.setMaxProperties(tighterMaxLen( + existing.getMaxProperties(), incoming.getMaxProperties())); + + if (existing.getRequired() != null || incoming.getRequired() != null) { + Set required = new LinkedHashSet<>(); + if (existing.getRequired() != null) { + required.addAll(existing.getRequired()); + } + if (incoming.getRequired() != null) { + required.addAll(incoming.getRequired()); + } + if (!required.isEmpty()) { + intersected.setRequired(new ArrayList<>(required)); + } + } + + Object additionalProperties = intersectAdditionalProperties( + existing.getAdditionalProperties(), incoming.getAdditionalProperties(), + openAPI, schemas); + if (additionalProperties != null) { + intersected.setAdditionalProperties(additionalProperties); + } + + // Recursive property intersection for nested object schemas + // (properties on properties) + Map existingProperties = existing.getProperties(); + Map incomingProperties = incoming.getProperties(); + if ((existingProperties != null && !existingProperties.isEmpty()) + || (incomingProperties != null && !incomingProperties.isEmpty())) { + if (existingProperties != null && incomingProperties != null) { + Map merged = new LinkedHashMap<>(existingProperties); + for (Map.Entry entry : incomingProperties.entrySet()) { + String key = entry.getKey(); + Schema val = entry.getValue(); + if (merged.containsKey(key)) { + merged.put(key, intersectPropertySchemas( + merged.get(key), val, openAPI, schemas, visited, activePairs)); + } else { + merged.put(key, val); + } + } + intersected.setProperties(merged); + } else if (existingProperties != null) { + intersected.setProperties(new LinkedHashMap<>(existingProperties)); + } else { + intersected.setProperties(new LinkedHashMap<>(incomingProperties)); + } + } + + // Mark unsatisfiable when types are incompatible + if (!typeCompatible) { + Map extensions = intersected.getExtensions(); + if (extensions == null) { + extensions = new LinkedHashMap<>(); + intersected.setExtensions(extensions); + } + extensions.put("x-cpp-unsatisfiable", true); + } + + return intersected; + } + + private static List intersectJsonValues( + List left, List right) { + List intersection = new ArrayList<>(); + for (Object candidate : left) { + for (Object value : right) { + if (jsonValuesEqual(candidate, value)) { + intersection.add(candidate); + break; + } + } + } + return intersection; + } + + private static boolean jsonValuesEqual(Object left, Object right) { + if (left == right) { + return true; + } + if (left == null || right == null) { + return false; + } + if (left instanceof Number && right instanceof Number) { + try { + return new BigDecimal(left.toString()) + .compareTo(new BigDecimal(right.toString())) == 0; + } catch (NumberFormatException ignored) { + return left.equals(right); + } + } + if (left instanceof List && right instanceof List) { + List leftList = (List) left; + List rightList = (List) right; + if (leftList.size() != rightList.size()) { + return false; + } + for (int index = 0; index < leftList.size(); index++) { + if (!jsonValuesEqual(leftList.get(index), rightList.get(index))) { + return false; + } + } + return true; + } + if (left instanceof Map && right instanceof Map) { + Map leftMap = (Map) left; + Map rightMap = (Map) right; + if (!leftMap.keySet().equals(rightMap.keySet())) { + return false; + } + for (Object key : leftMap.keySet()) { + if (!jsonValuesEqual(leftMap.get(key), rightMap.get(key))) { + return false; + } + } + return true; + } + return left.equals(right); + } + + private static boolean constValuesEqual(Object left, String leftJson, + Object right, String rightJson) { + if (leftJson != null && leftJson.equals(rightJson)) { + return true; + } + if ("null".equals(leftJson) || "null".equals(rightJson)) { + return false; + } + return jsonValuesEqual(left, right); + } + + private static boolean constSatisfiesConstraints( + Object constValue, String constJson, Set types, + boolean nullable, List enumValues) { + String constType = jsonSchemaTypeOfConst(constValue, constJson); + if (constType != null && !types.isEmpty()) { + boolean typeAllowed; + if ("null".equals(constType)) { + typeAllowed = nullable || types.contains("null"); + } else if ("integer".equals(constType)) { + typeAllowed = types.contains("integer") || types.contains("number"); + } else { + typeAllowed = types.contains(constType); + } + if (!typeAllowed) { + return false; + } + } + if (enumValues == null) { + return true; + } + for (Object enumValue : enumValues) { + if (jsonValuesEqual(constValue, enumValue)) { + return true; + } + } + return false; + } + + private static String jsonSchemaTypeOfConst(Object value, String json) { + if ("null".equals(json)) { + return "null"; + } + if (value instanceof Boolean) { + return "boolean"; + } + if (value instanceof Number) { + try { + return new BigDecimal(value.toString()).stripTrailingZeros().scale() <= 0 + ? "integer" : "number"; + } catch (NumberFormatException ignored) { + return "number"; + } + } + if (value instanceof String) { + return "string"; + } + if (value instanceof List) { + return "array"; + } + if (value instanceof Map) { + return "object"; + } + return null; + } + + private static boolean hasConstConstraint(Schema schema) { + return schema != null && (schema.getConst() != null + || Oas31RawSpecRecovery.hasExplicitConst(schema)); + } + + private static void copyConstConstraint(Schema source, Schema target) { + if (source.getConst() != null) { + target.setConst(source.getConst()); + } + if (Oas31RawSpecRecovery.hasExplicitConst(source)) { + Oas31RawSpecRecovery.restoreExplicitConst( + target, Oas31RawSpecRecovery.constJsonOf(source)); + } + } + + /** + * Returns the tighter (larger) of two min bounds, or whichever is non-null. + */ + private static Integer tighterMinLen(Integer first, Integer second) { + if (first == null) return second; + if (second == null) return first; + return Math.max(first, second); + } + + /** + * Returns the tighter (smaller) of two max bounds, or whichever is non-null. + */ + private static Integer tighterMaxLen(Integer first, Integer second) { + if (first == null) return second; + if (second == null) return first; + return Math.min(first, second); + } + + /** + * Builds a synthetic object Schema from an AllOfIntersection result. + * The synthetic schema is used as input to super.fromModel, replacing + * the original allOf structure with pre-computed merged properties + * and required sets. + * + * @param schemaName the model name + * @param intersection the pre-computed allOf intersection + * @return a synthetic object Schema with merged properties and required + */ + static Schema buildSyntheticAllOfSchema( + String schemaName, AllOfIntersection intersection) { + Schema synthetic = new Schema(); + + String rootType = intersection.getRootScalarType(); + if (rootType == null && intersection.hasRootConst()) { + rootType = jsonSchemaTypeOfConst( + intersection.getRootConstValue(), intersection.getRootConstJson()); + } + synthetic.setType(rootType != null ? rootType : "object"); + if (!"null".equals(rootType) && intersection.allowsRootNull()) { + synthetic.setNullable(true); + } + + // Apply intersected root-level enum values + if (intersection.getRootEnumValues() != null + && !intersection.getRootEnumValues().isEmpty()) { + synthetic.setEnum(new ArrayList<>(intersection.getRootEnumValues())); + } + + // Apply intersected root-level const value, including an explicit JSON null. + if (intersection.hasRootConst()) { + synthetic.setConst(intersection.getRootConstValue()); + Oas31RawSpecRecovery.restoreExplicitConst( + synthetic, intersection.getRootConstJson()); + } + + // Apply intersected numeric bounds + if (intersection.getRootMinimum() != null) { + synthetic.setMinimum(intersection.getRootMinimum()); + } + if (intersection.getRootMaximum() != null) { + synthetic.setMaximum(intersection.getRootMaximum()); + } + if (intersection.getRootExclusiveMinimum() != null) { + synthetic.setExclusiveMinimum(intersection.getRootExclusiveMinimum()); + } + if (intersection.getRootExclusiveMaximum() != null) { + synthetic.setExclusiveMaximum(intersection.getRootExclusiveMaximum()); + } + if (intersection.getRootExclusiveMinimumValue() != null) { + synthetic.setExclusiveMinimumValue(intersection.getRootExclusiveMinimumValue()); + } + if (intersection.getRootExclusiveMaximumValue() != null) { + synthetic.setExclusiveMaximumValue(intersection.getRootExclusiveMaximumValue()); + } + + // Apply intersected string length bounds + if (intersection.getRootMinLength() != null) { + synthetic.setMinLength(intersection.getRootMinLength()); + } + if (intersection.getRootMaxLength() != null) { + synthetic.setMaxLength(intersection.getRootMaxLength()); + } + + // Copy merged properties (skipping optional-impossible properties) + if (!intersection.getProperties().isEmpty()) { + Map syntheticProps = new LinkedHashMap<>(); + for (Map.Entry propEntry + : intersection.getProperties().entrySet()) { + String propName = propEntry.getKey(); + if (intersection.getOptionalImpossibleProperties().contains(propName)) { + // For optional-impossible properties (e.g., string ∩ int32), + // use the first contributor's schema so the property has a + // storage member (avoids empty-shell detection). Mark with + // x-cpp-optional-impossible for template-level awareness. + Schema propSchema = propEntry.getValue(); + // The intersected schema may have x-cpp-unsatisfiable set. + // Ensure it has at least one contributor type so fromModel + // produces a CodegenProperty with a real dataType. Fall back + // to the existing intersected schema as-is when it already + // has a type or if no better alternative is available. + if (propSchema.getType() == null) { + // Assign a fallback type so the property gets a member. + // Prefer the first contributor's type, otherwise use + // boost::json::value as the most generic C++ type. + propSchema.setType("string"); + } + Map ext = propSchema.getExtensions(); + if (ext == null) { + ext = new LinkedHashMap<>(); + propSchema.setExtensions(ext); + } + ext.put("x-cpp-optional-impossible", true); + syntheticProps.put(propName, propSchema); + } else { + syntheticProps.put(propName, propEntry.getValue()); + } + } + synthetic.setProperties(syntheticProps); + } + + if (intersection.getAdditionalProperties() != null) { + synthetic.setAdditionalProperties(intersection.getAdditionalProperties()); + } + + // Set required as the union of required from all contributors + if (!intersection.getRequired().isEmpty()) { + synthetic.setRequired(new ArrayList<>(intersection.getRequired())); + } + + return synthetic; + } + + /** + * Builds a list of {key, value} maps from the full set of discriminator + * mapped models (explicit URI mappings + implicit component-name mappings) + * for template-iteration. Each entry maps a C++-escaped discriminator value + * to a composition branch index so the template can reorder candidate + * validation for diagnostics. + *

    + * Unresolvable mappings (where the model name does not match any branch + * resolved schema name) fail generation with a clear diagnostic per §8. + * + * @param mappedModels the full set of discriminator mapped models + * @param branches the composition branch descriptors + * @return list of {key, value} maps; non-empty when at least one mapping + * resolves to a valid branch + * @throws RuntimeException when a mapping does not resolve to any branch + */ + public static List> buildDiscriminatorBranchIndex( + Set mappedModels, + List branches) { + List> indexList = new ArrayList<>(); + if (mappedModels == null || mappedModels.isEmpty()) return indexList; + for (CodegenDiscriminator.MappedModel mm : mappedModels) { + if (mm == null) continue; + int branchIndex = -1; + for (int bi = 0; bi < branches.size(); bi++) { + String resolvedName = branches.get(bi).getResolvedSchemaName(); + if (resolvedName == null) continue; + // Match on raw schemaName first (handles lowercase/raw names), + // then on sanitized modelName (handles normalised names). + if (resolvedName.equals(mm.getSchemaName()) + || resolvedName.equals(mm.getModelName())) { + branchIndex = bi; + break; + } + } + if (branchIndex >= 0) { + Map entry = new LinkedHashMap<>(); + entry.put("key", CppBoostBeastClientCodegen.escapeCppStringContent(mm.getMappingName())); + entry.put("value", branchIndex); + indexList.add(entry); + } else { + // §8: unresolvable → hard diagnostic + throw new RuntimeException( + "Discriminator mapping value '" + + CppBoostBeastClientCodegen.escapeCppStringContent(mm.getMappingName()) + + "' (schema: " + mm.getSchemaName() + + ", model: " + mm.getModelName() + + ") does not match any composition branch for schema '" + + (mm.getModelName() != null ? mm.getModelName() : "(unknown)") + + "'. Valid branches: " + + branches.stream() + .map(CompositionBranchDescriptor::getResolvedSchemaName) + .filter(n -> n != null) + .collect(Collectors.joining(", "))); + } + } + return indexList; + } + + /** + * Fallback variant: builds a list of {key, value} maps from explicit + * discriminator mapping entries only (used when the codegen model's + * full MappedModel set is unavailable). + * + * @param discMapping the discriminator.value → target mapping + * @param branches the composition branch descriptors + * @return list of {key, value} maps + */ + public static List> buildDiscriminatorBranchIndex( + Map discMapping, + List branches) { + List> indexList = new ArrayList<>(); + if (discMapping == null || discMapping.isEmpty()) return indexList; + for (Map.Entry entry : discMapping.entrySet()) { + String targetName = extractSimpleRef(entry.getValue()); + if (targetName == null) continue; + int branchIndex = -1; + for (int bi = 0; bi < branches.size(); bi++) { + if (targetName.equals(branches.get(bi).getResolvedSchemaName())) { + branchIndex = bi; + break; + } + } + if (branchIndex >= 0) { + Map entryMap = new LinkedHashMap<>(); + entryMap.put("key", CppBoostBeastClientCodegen.escapeCppStringContent(entry.getKey())); + entryMap.put("value", branchIndex); + indexList.add(entryMap); + } else { + throw new RuntimeException( + "Discriminator mapping target '" + entry.getValue() + + "' (resolved: " + targetName + + ") does not match any composition branch. Valid branches: " + + branches.stream() + .map(CompositionBranchDescriptor::getResolvedSchemaName) + .filter(n -> n != null) + .collect(Collectors.joining(", "))); + } + } + return indexList; + } + + /** + * Extracts a simple schema name from a discriminator mapping value. + * Handles both URI references (e.g. "#/components/schemas/Mammal") + * and plain component names (e.g. "Mammal"). + */ + private static String extractSimpleRef(String mappingValue) { + if (mappingValue == null || mappingValue.isEmpty()) return null; + String ref = mappingValue.trim(); + if (ref.startsWith("#/")) { + int lastSlash = ref.lastIndexOf('/'); + return lastSlash >= 0 ? ref.substring(lastSlash + 1) : ref; + } + return ref; + } + + /** + * 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 + *

    + * When a non-null {@code descriptor} is provided, its branch metadata + * (nullCapability, supportedAssertions) replaces C++ type-string heuristics + * for Rules 1, 3, and 6. + * Warnings are routed through {@code warningSink} so this stateless helper + * does not retain a process-wide logger. + */ + static String lowerComposedTypes(List branches, + String composedKeyword, + CompositionDescriptor descriptor, + Consumer warningSink) { + 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 + // Use descriptor nullCapability when available for semantic accuracy. + // Uses originalBranchIndex to align with descriptor after self-ref filtering. + // Tightened: non-null branch must have NullCapability.NEVER (not CONDITIONAL). + if (descriptor != null) { + int alwaysNullCount = 0; + int nonNullComposedIndex = -1; + List descBranches = descriptor.getBranches(); + for (int ci = 0; ci < branches.size(); ci++) { + int descIdx = branches.get(ci).originalBranchIndex; + if (descIdx < 0 || descIdx >= descBranches.size()) continue; + CompositionBranchDescriptor.NullCapability nc = + descBranches.get(descIdx).getNullCapability(); + if (nc == CompositionBranchDescriptor.NullCapability.ALWAYS) { + alwaysNullCount++; + } else if (nc == CompositionBranchDescriptor.NullCapability.NEVER + && nonNullComposedIndex < 0) { + nonNullComposedIndex = ci; + } + } + if (alwaysNullCount == 1 && branches.size() == 2 + && nonNullComposedIndex >= 0 + && nonNullComposedIndex < branchTypes.size()) { + String nonNullBranch = branchTypes.get(nonNullComposedIndex); + if (nonNullBranch != null) { + return "std::optional<" + nonNullBranch + ">"; + } + } + } else { + // Fallback: C++ type-string heuristic (no descriptor available) + 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 unconstrained string branches. + // Enum constraints require distinct branch validators, and oneOf cannot + // collapse without losing exclusive-match semantics. + if ("anyOf".equals(composedKeyword) && branchTypes.stream().allMatch("std::string"::equals)) { + // Check if any branch has enum assertions using descriptor metadata + // or fallback ComposedBranch isEnum flag. + boolean hasEnumString = false; + if (descriptor != null) { + List descBranches = descriptor.getBranches(); + for (CppBoostBeastClientCodegen.ComposedBranch cb : branches) { + int descIdx = cb.originalBranchIndex; + if (descIdx >= 0 && descIdx < descBranches.size() + && descBranches.get(descIdx).getSupportedAssertions().contains("enum")) { + hasEnumString = true; + break; + } + } + } else { + hasEnumString = branches.stream().anyMatch(b -> b.isEnum); + } + if (!hasEnumString) { + return "std::string"; + } + // Has enum string branches — fall through to CompositionBranchValue + // preservation (Rule 5) which keeps validators active. + } + + // Rule 3: Remove null branches for further processing, preserving all + // branches when every branch is null so oneOf cardinality remains exact. + List nonNullMeta; + if (descriptor != null) { + List descBranches = descriptor.getBranches(); + nonNullMeta = new ArrayList<>(); + boolean hasNonNull = false; + for (CppBoostBeastClientCodegen.ComposedBranch cb : branches) { + int descIdx = cb.originalBranchIndex; + if (descIdx >= 0 && descIdx < descBranches.size()) { + CompositionBranchDescriptor.NullCapability nc = + descBranches.get(descIdx).getNullCapability(); + if (nc != CompositionBranchDescriptor.NullCapability.ALWAYS) { + nonNullMeta.add(cb); + hasNonNull = true; + } + } + } + // All branches were null — keep them for identity preservation + if (!hasNonNull && !branches.isEmpty()) { + nonNullMeta = new ArrayList<>(branches); + } + } else { + List nonNullOnly = branches.stream() + .filter(b -> !"std::nullptr_t".equals(b.cppType)) + .collect(Collectors.toList()); + if (!nonNullOnly.isEmpty()) { + nonNullMeta = nonNullOnly; + } else { + // All branches are null — keep them + nonNullMeta = new ArrayList<>(branches); + } + } + List nonNullBranches = nonNullMeta.stream() + .map(b -> b.cppType) + .collect(Collectors.toList()); + + // Rule 3b: Preserve nested variants as outer alternatives. Branch + // conversion parses each descriptor branch into its declared C++ type; + // flattening that type would make the converted value unassignable and + // would erase the nested composition's branch boundary. + + // Rule 4: All-null or empty → boost::json::value + if (nonNullBranches.isEmpty()) { + return "boost::json::value"; + } + + // Rule 5: Detect duplicate branch types that would lose schema + // identity after C++ dedup. When multiple branches lower to the + // same C++ type (e.g., two double branches with different numeric + // constraints, or a string + string-enum both becoming std::string), + // wrap each in CompositionBranchValue + // to preserve distinct branch identity. + boolean hasDuplicateTypes = false; + outer: + for (int i = 0; i < nonNullBranches.size(); i++) { + for (int j = i + 1; j < nonNullBranches.size(); j++) { + if (nonNullBranches.get(i).equals(nonNullBranches.get(j))) { + hasDuplicateTypes = true; + break outer; + } + } + } + + if (hasDuplicateTypes) { + // Shortcut: wrap all branches in CompositionBranchValue to + // preserve identity. Nested variants remain intact so the wrapper + // type exactly matches the descriptor branch conversion type. + // Also skip Rule 6 (string exclusivity) since tagged branches + // already preserve distinct membership. + List tagged = new ArrayList<>(); + for (int i = 0; i < nonNullBranches.size(); i++) { + String rawType = nonNullBranches.get(i); + int origIdx = nonNullMeta.get(i).originalBranchIndex; + // For inline schemas (origIdx < 0), use flat position as tag. + int brIdx = origIdx >= 0 ? origIdx : i; + tagged.add("CompositionBranchValue<" + brIdx + + ", " + rawType + ">"); + } + // When hasDuplicateTypes, null branches must be wrapped in + // CompositionBranchValue too — never bare std::nullptr_t. + // Find null branches that were filtered by Rule 3 and wrap + // them, skipping any that Rule 3 already preserved in tagged. + boolean hasNull = branchTypes.stream().anyMatch("std::nullptr_t"::equals); + if (hasNull) { + for (int ni = 0; ni < branches.size(); ni++) { + if ("std::nullptr_t".equals(branches.get(ni).cppType)) { + int origIdx = branches.get(ni).originalBranchIndex; + int brIdx = origIdx >= 0 ? origIdx : ni; + String cbvNull = "CompositionBranchValue<" + brIdx + + ", std::nullptr_t>"; + if (!tagged.contains(cbvNull)) { + tagged.add(cbvNull); + } + } + } + } + return "std::variant<" + String.join(", ", tagged) + ">"; + } + + // Rule 6: Deduplicate identical branch types (safe when no duplicates). + List deduped = nonNullBranches.stream() + .distinct() + .collect(Collectors.toList()); + + // Rule 7: 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. + // + // When a descriptor is available, use its supportedAssertions for enum + // detection instead of the ComposedBranch isEnum flag. Descriptor + // assertions are semantically richer (captured from raw schema scanning) + // and carried from preprocessOpenAPI through all lowering passes. + 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(); + List descBranches = descriptor != null + ? descriptor.getBranches() : null; + boolean hasStringEnum = nonNullMeta.stream() + .anyMatch(b -> { + if (!b.isStringLike) return false; + // Descriptor path: consult supportedAssertions + if (descBranches != null && b.originalBranchIndex >= 0 + && b.originalBranchIndex < descBranches.size()) { + return descBranches.get(b.originalBranchIndex) + .getSupportedAssertions().contains("enum"); + } + // Fallback: use ComposedBranch.isEnum (CodegenProperty) + return b.isEnum; + }); + if (preDedupStringCount > postDedupStringCount && hasStringEnum) { + warningSink.accept( + "oneOf string branches erase to std::string; " + + "emitting boost::json::value to avoid false exclusive-union fidelity"); + return "boost::json::value"; + } + } + + // Rule 7: A single non-null type can still be nullable after the + // duplicate null branches were removed. anyOf can use optional storage; + // oneOf retains an explicit null alternative so validation can reject + // duplicate-null matches before accepting a value. + if (deduped.size() == 1) { + boolean hasNull = branchTypes.stream().anyMatch("std::nullptr_t"::equals); + if (hasNull) { + if ("anyOf".equals(composedKeyword)) { + return "std::optional<" + deduped.get(0) + ">"; + } + return "std::variant<" + deduped.get(0) + ", std::nullptr_t>"; + } + return deduped.get(0); + } + + // Rule 8: Emit std::variant + List variantBranches = new ArrayList<>(deduped); + // Re-append null for any null-containing composition not consumed + // by Rule 1 ([T, null] -> optional). Rule 1 always returns early, + // so every null surviving to this point must be restored. + boolean hasNull = branchTypes.stream().anyMatch("std::nullptr_t"::equals); + boolean nullsAlreadyPreserved = variantBranches.stream().anyMatch( + v -> v.contains("std::nullptr_t")); + if (hasNull && !nullsAlreadyPreserved) { + variantBranches.add("std::nullptr_t"); + } + return "std::variant<" + String.join(", ", variantBranches) + ">"; + } +} \ No newline at end of file diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/Oas31ExactLiteralEmitter.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/Oas31ExactLiteralEmitter.java new file mode 100644 index 000000000000..35e766d6eb1e --- /dev/null +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/Oas31ExactLiteralEmitter.java @@ -0,0 +1,91 @@ +package org.openapitools.codegen.languages; + +/** Emits exact scalar and deep-JSON literals for densified schema IR rows. */ +final class Oas31ExactLiteralEmitter { + + private Oas31ExactLiteralEmitter() { + } + + static void appendNodeLiterals( + StringBuilder sb, + Oas31SchemaIrEmitter.IrNode node) { + appendSetExact(sb, "n.minimum", "n.hasMinimum", node.minimum); + appendSetExact(sb, "n.maximum", "n.hasMaximum", node.maximum); + appendSetExact(sb, "n.exclusiveMinimum", "n.hasExclusiveMinimum", node.exclusiveMinimum); + appendSetExact(sb, "n.exclusiveMaximum", "n.hasExclusiveMaximum", node.exclusiveMaximum); + appendSetExact(sb, "n.multipleOf", "n.hasMultipleOf", node.multipleOf); + + for (String lexeme : node.enumNumbers) { + sb.append(" n.enumNumbers.push_back(ExactNumber::parseLexeme(\"") + .append(lexeme).append("\"));\n"); + } + for (String value : node.enumStrings) { + sb.append(" n.enumStrings.push_back(\"").append(value).append("\");\n"); + } + for (String value : node.enumBooleans) { + sb.append(" n.enumBooleans.push_back(").append(value).append(");\n"); + } + + if (node.constNumber != null) { + sb.append(" n.hasConst = true;\n"); + sb.append(" n.constNumber = ExactNumber::parseLexeme(\"") + .append(node.constNumber).append("\");\n"); + sb.append(" n.constIsNumber = true;\n"); + } + if (node.constString != null) { + sb.append(" n.hasConst = true;\n"); + sb.append(" n.constString = \"").append(node.constString).append("\";\n"); + sb.append(" n.constIsString = true;\n"); + } + if (node.constBool != null) { + sb.append(" n.hasConst = true;\n"); + sb.append(" n.constBool = ").append(node.constBool).append(";\n"); + sb.append(" n.constIsBool = true;\n"); + } + + if (node.constJson != null) { + sb.append(" n.hasConst = true;\n"); + sb.append(" n.constIsJson = true;\n"); + appendJsonParse(sb, "n.constJson", node.constJson); + } + if (node.enumJson != null) { + sb.append(" n.hasEnumJson = true;\n"); + sb.append(" { ExactJsonValue _exact = parseExactJson("); + appendCppRawString(sb, node.enumJson); + sb.append(");\n"); + sb.append(" n.enumJsonLexemes = std::move(_exact.lexemes);\n"); + sb.append(" for (boost::json::value& _e : _exact.value.as_array()) ") + .append("n.enumJson.push_back(std::move(_e)); }\n"); + } + } + + static void appendSetExact( + StringBuilder sb, + String field, + String hasField, + String lexeme) { + if (lexeme != null) { + sb.append(" setExact(").append(field).append(", ").append(hasField) + .append(", \"").append(lexeme).append("\");\n"); + } + } + + private static void appendJsonParse(StringBuilder sb, String field, String json) { + sb.append(" { ExactJsonValue _exact = parseExactJson("); + appendCppRawString(sb, json); + sb.append("); ").append(field).append(" = std::move(_exact.value); ") + .append(field).append("Lexemes = std::move(_exact.lexemes); }\n"); + } + + private static void appendCppRawString(StringBuilder sb, String value) { + int suffix = 0; + String delimiter; + do { + delimiter = "OAS" + Integer.toUnsignedString(suffix, 36); + suffix += 1; + } + while (value.contains(")" + delimiter + "\"")); + sb.append("R\"").append(delimiter).append("(") + .append(value).append(")").append(delimiter).append("\""); + } +} diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/Oas31KeywordScanner.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/Oas31KeywordScanner.java new file mode 100644 index 000000000000..b2957ef63eb9 --- /dev/null +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/Oas31KeywordScanner.java @@ -0,0 +1,900 @@ +package org.openapitools.codegen.languages; + +import io.swagger.v3.oas.models.Components; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.Operation; +import io.swagger.v3.oas.models.PathItem; +import io.swagger.v3.oas.models.callbacks.Callback; +import io.swagger.v3.oas.models.headers.Header; +import io.swagger.v3.oas.models.media.Content; +import io.swagger.v3.oas.models.media.Encoding; +import io.swagger.v3.oas.models.media.MediaType; +import io.swagger.v3.oas.models.media.Schema; +import io.swagger.v3.oas.models.parameters.Parameter; +import io.swagger.v3.oas.models.parameters.RequestBody; +import io.swagger.v3.oas.models.responses.ApiResponse; +import org.apache.commons.lang3.StringUtils; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.Locale; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * OAS 3.1 dialect resolution and exhaustive schema-keyword scanning. + * + *

    swagger-parser and swagger-models do not retain every OAS 3.1 schema + * surface. This class resolves the effective document dialect and records the + * schema keywords visible after parsing. Keywords are classified as emitted, + * annotation-only, or explicitly rejected; unsupported required vocabularies + * and unnormalized dynamic references fail closed. + */ +public final class Oas31KeywordScanner { + + // Bound recursion before a default JVM thread stack can be exhausted. + private static final int MAX_SCHEMA_NESTING = 256; + + private Oas31KeywordScanner() { + } + + /** + * Resolve the effective schema dialect from the top-level + * {@code jsonSchemaDialect} and/or the root {@code $schema}. Per OAS 3.1 + * the root {@code $schema} (when present at a document/schema-resource + * root) takes precedence over {@code jsonSchemaDialect} for that resource. + */ + public static CppBoostBeastClientCodegen.OasDialect resolveEffectiveDialect( + String jsonSchemaDialect, String rootSchema) { + String effective = StringUtils.isNotBlank(rootSchema) ? rootSchema : jsonSchemaDialect; + if (StringUtils.isBlank(effective)) { + return CppBoostBeastClientCodegen.OasDialect.UNSPECIFIED; + } + String trimmed = effective.trim(); + if (CppBoostBeastClientCodegen.OAS_31_DIALECT.equals(trimmed) + || CppBoostBeastClientCodegen.OAS_31_DIALECT_BASE_ALIAS.equals(trimmed)) { + return CppBoostBeastClientCodegen.OasDialect.OAS_31; + } + if (CppBoostBeastClientCodegen.DRAFT_2020_12.equals(trimmed)) { + return CppBoostBeastClientCodegen.OasDialect.DRAFT_2020_12_REC; + } + return CppBoostBeastClientCodegen.OasDialect.UNRECOGNIZED; + } + + /** Resolve the effective dialect of an OpenAPI document from its knobs. */ + public static CppBoostBeastClientCodegen.OasDialect resolveDocumentDialect( + OpenAPI api) { + if (api == null) { + return CppBoostBeastClientCodegen.OasDialect.UNSPECIFIED; + } + String jsonSchemaDialect = api.getJsonSchemaDialect(); + if (jsonSchemaDialect != null) { + return resolveEffectiveDialect(jsonSchemaDialect, null); + } + // No jsonSchemaDialect: for OAS 3.1 the pinned dialect is the default. + return isOas31(api) ? CppBoostBeastClientCodegen.OasDialect.OAS_31 + : CppBoostBeastClientCodegen.OasDialect.UNSPECIFIED; + } + + /** + * OAS 3 structural normative checks. Returns a list of human-readable + * diagnostics; an empty list means the structure is normative. The caller + * decides whether to fail generation (strict mode). + */ + public static List validateNormativeOas3Structure(OpenAPI api) { + List diagnostics = new ArrayList<>(); + if (api == null) { + diagnostics.add("document is null; cannot satisfy OAS structural requirements"); + return diagnostics; + } + String version = api.getOpenapi(); + if (StringUtils.isBlank(version)) { + diagnostics.add("missing root `openapi` version field (required for OAS 3.x)"); + } else if (!version.matches("3(\\.[0-9]+)*")) { + diagnostics.add("unsupported openapi version '" + version + + "' (program targets OAS 3.0.x/3.1.x)"); + } + io.swagger.v3.oas.models.info.Info info = api.getInfo(); + if (info == null) { + diagnostics.add("missing root `info` object (required by OAS)"); + } else { + if (StringUtils.isBlank(info.getTitle())) { + diagnostics.add("missing `info.title` (required by OAS)"); + } + if (StringUtils.isBlank(info.getVersion())) { + diagnostics.add("missing `info.version` (required by OAS)"); + } + } + boolean hasPaths = api.getPaths() != null && !api.getPaths().isEmpty(); + boolean hasComponents = api.getComponents() != null; + boolean hasWebhooks = api.getWebhooks() != null && !api.getWebhooks().isEmpty(); + if (!hasPaths && !hasComponents && !hasWebhooks) { + diagnostics.add("missing at least one of `paths`, `components`, or `webhooks`"); + } + return diagnostics; + } + + /** + * Dialect/metaschema policy gate: a dialect identifier that is not + * recognized by this program must be refused (unknown required + * vocabulary). The OAS 3.1 default applies when an OAS 3.1 document + * declares no {@code jsonSchemaDialect}. + * + *

    Required vocabularies and resource-level dialect selectors are checked + * on every schema surface visible through swagger-models. + */ + public static List validateDialectPolicy(OpenAPI api) { + List diagnostics = new ArrayList<>(); + if (api == null) { + return diagnostics; + } + CppBoostBeastClientCodegen.OasDialect dialect = resolveDocumentDialect(api); + if (dialect == CppBoostBeastClientCodegen.OasDialect.UNRECOGNIZED) { + diagnostics.add("unrecognized jsonSchemaDialect '" + api.getJsonSchemaDialect() + + "' — unknown required vocabulary/dialect, fail generation"); + } + for (KeywordOccurrence occurrence + : scanSchemaKeywordOccurrences(api).withStatus( + KeywordOccurrenceStatus.FAIL_CLOSED)) { + if ("$schema".equals(occurrence.getKeyword()) + || "$vocabulary".equals(occurrence.getKeyword()) + || "$dynamicRef".equals(occurrence.getKeyword())) { + diagnostics.add(occurrence.getDetail() + " at " + + occurrence.getLocation()); + } + } + return diagnostics; + } + + /** True when the document declares an OAS 3.1 version. */ + static boolean isOas31(OpenAPI api) { + if (api == null) { + return false; + } + String version = api.getOpenapi(); + return version != null + && version.matches("3\\.1(?:\\.[0-9]+)?(?:[-+][0-9A-Za-z.-]+)?"); + } + + // ======================================================================== + // Exhaustive schema-valued-position scanner + // ======================================================================== + + /** Core vocabulary identifier (2020-12). */ + public static final String VOCAB_CORE = "https://json-schema.org/draft/2020-12/vocab/core"; + /** Applicator vocabulary identifier (2020-12). */ + public static final String VOCAB_APPLICATOR = "https://json-schema.org/draft/2020-12/vocab/applicator"; + /** Unevaluated vocabulary identifier (2020-12). */ + public static final String VOCAB_UNEVALUATED = "https://json-schema.org/draft/2020-12/vocab/unevaluated"; + /** Validation vocabulary identifier (2020-12). */ + public static final String VOCAB_VALIDATION = "https://json-schema.org/draft/2020-12/vocab/validation"; + /** Metadata vocabulary identifier (2020-12). */ + public static final String VOCAB_METADATA = "https://json-schema.org/draft/2020-12/vocab/meta-data"; + /** Format-annotation vocabulary identifier (2020-12). */ + public static final String VOCAB_FORMAT = "https://json-schema.org/draft/2020-12/vocab/format-annotation"; + /** Content vocabulary identifier (2020-12). */ + public static final String VOCAB_CONTENT = "https://json-schema.org/draft/2020-12/vocab/content"; + /** OAS base vocabulary identifier (OAS 3.1). */ + public static final String VOCAB_OAS_BASE = "https://spec.openapis.org/oas/3.1/vocab/base"; + private static final Set SUPPORTED_REQUIRED_VOCABULARIES = Set.of( + VOCAB_CORE, + VOCAB_APPLICATOR, + VOCAB_UNEVALUATED, + VOCAB_VALIDATION, + VOCAB_METADATA, + VOCAB_FORMAT, + VOCAB_CONTENT, + VOCAB_OAS_BASE); + + /** Classification of one visible keyword occurrence. */ + public enum KeywordOccurrenceStatus { + /** A validator or structural handler is emitted for this keyword. */ + EMITTED, + /** The generator rejects this keyword rather than ignoring it. */ + FAIL_CLOSED, + /** The keyword has no validity effect in the active vocabulary. */ + ANNOTATION + } + + /** One keyword occurrence at a schema-valued position. */ + public static final class KeywordOccurrence { + private final String keyword; + private final String location; + private final String vocabularyUri; + private final KeywordOccurrenceStatus status; + private final String detail; + + /** Create an occurrence record. */ + public KeywordOccurrence(String keyword, String location, String vocabularyUri, + KeywordOccurrenceStatus status, String detail) { + this.keyword = keyword; + this.location = location; + this.vocabularyUri = vocabularyUri; + this.status = status; + this.detail = detail; + } + + /** The keyword that occurred. */ + public String getKeyword() { + return keyword; + } + + /** JSON-pointer-like location of the occurrence. */ + public String getLocation() { + return location; + } + + /** Vocabulary URI declaring the keyword. */ + public String getVocabularyUri() { + return vocabularyUri; + } + + /** Classification of the occurrence. */ + public KeywordOccurrenceStatus getStatus() { + return status; + } + + /** Human-readable detail, or null. */ + public String getDetail() { + return detail; + } + + @Override + public String toString() { + return status + "[" + keyword + "]@" + location + + (detail == null || detail.isEmpty() ? "" : " (" + detail + ")"); + } + } + + /** Ordered keyword occurrence ledger with status aggregation. */ + public static final class KeywordOccurrenceLedger { + private final List occurrences = new ArrayList<>(); + private final LinkedHashMap> byKeyword = + new LinkedHashMap<>(); + + /** Record one occurrence. */ + void add(KeywordOccurrence occurrence) { + occurrences.add(occurrence); + byKeyword.computeIfAbsent(occurrence.getKeyword(), k -> new ArrayList<>()) + .add(occurrence); + } + + /** All recorded occurrences, in discovery order. */ + public List getOccurrences() { + return Collections.unmodifiableList(new ArrayList<>(occurrences)); + } + + /** Occurrences for one keyword, or an empty list when none. */ + public List forKeyword(String keyword) { + return byKeyword.getOrDefault(keyword, Collections.emptyList()); + } + + /** Whether any occurrence was recorded for this keyword. */ + public boolean hasKeyword(String keyword) { + return byKeyword.containsKey(keyword); + } + + /** All keywords with at least one occurrence. */ + public Set getKeywords() { + return Collections.unmodifiableSet(new LinkedHashSet<>(byKeyword.keySet())); + } + + /** Occurrences filtered by status. */ + public List withStatus(KeywordOccurrenceStatus status) { + List out = new ArrayList<>(); + for (KeywordOccurrence o : occurrences) { + if (o.getStatus() == status) { + out.add(o); + } + } + return out; + } + + + /** Keywords with at least one FAIL_CLOSED occurrence. */ + public Set failClosed() { + Set out = new LinkedHashSet<>(); + for (KeywordOccurrence o : occurrences) { + if (o.getStatus() == KeywordOccurrenceStatus.FAIL_CLOSED) { + out.add(o.getKeyword()); + } + } + return Collections.unmodifiableSet(out); + } + + /** Keywords with at least one EMITTED occurrence. */ + public Set emitted() { + Set out = new LinkedHashSet<>(); + for (KeywordOccurrence o : occurrences) { + if (o.getStatus() == KeywordOccurrenceStatus.EMITTED) { + out.add(o.getKeyword()); + } + } + return Collections.unmodifiableSet(out); + } + + /** Total number of recorded occurrences. */ + public int size() { + return occurrences.size(); + } + } + + /** + * Exhaustive schema-valued-position scanner. Walks component schemas plus + * every inline schema reachable from paths, webhooks, callbacks, reusable + * parameters, headers, request bodies, responses, and path items. Each + * schema is then traversed through every schema-valued JSON Schema 2020-12 + * child keyword. Reference targets ({@code $ref}) are not followed here; + * reusable targets are scanned at their component location. + */ + public static KeywordOccurrenceLedger scanSchemaKeywordOccurrences(OpenAPI api) { + KeywordOccurrenceLedger ledger = new KeywordOccurrenceLedger(); + rootSchemaPositions(api).forEach((location, schema) -> + scanSchemaNode(schema, location, ledger, 0)); + return ledger; + } + + /** Maps every OAS-hosted root schema without following schema keywords. */ + static Map rootSchemaPositions(OpenAPI api) { + Map positions = new LinkedHashMap<>(); + if (api == null) { + return positions; + } + + Components components = api.getComponents(); + if (components != null) { + collectSchemas(components.getSchemas(), "#/components/schemas", positions); + collectParameters(components.getParameters(), "#/components/parameters", positions); + collectHeaders(components.getHeaders(), "#/components/headers", positions); + collectRequestBodies( + components.getRequestBodies(), "#/components/requestBodies", positions); + collectResponses(components.getResponses(), "#/components/responses", positions); + collectCallbacks(components.getCallbacks(), "#/components/callbacks", positions); + collectPathItems(components.getPathItems(), "#/components/pathItems", positions); + } + collectPathItems(api.getPaths(), "#/paths", positions); + collectPathItems(api.getWebhooks(), "#/webhooks", positions); + return positions; + } + + private static void collectSchemas(Map schemas, String location, + Map positions) { + if (schemas == null) { + return; + } + schemas.forEach((name, schema) -> putSchema(schema, + location + "/" + pointerSegment(name), positions)); + } + + private static void collectPathItems(Map pathItems, String location, + Map positions) { + if (pathItems == null) { + return; + } + pathItems.forEach((name, pathItem) -> collectPathItem(pathItem, + location + "/" + pointerSegment(name), positions)); + } + + private static void collectPathItem(PathItem pathItem, String location, + Map positions) { + if (pathItem == null) { + return; + } + collectParameterList(pathItem.getParameters(), location + "/parameters", positions); + if (pathItem.readOperationsMap() != null) { + pathItem.readOperationsMap().forEach((method, operation) -> collectOperation(operation, + location + "/" + method.name().toLowerCase(Locale.ROOT), positions)); + } + } + + private static void collectOperation(Operation operation, String location, + Map positions) { + if (operation == null) { + return; + } + collectParameterList(operation.getParameters(), location + "/parameters", positions); + collectRequestBody(operation.getRequestBody(), location + "/requestBody", positions); + collectResponses(operation.getResponses(), location + "/responses", positions); + collectCallbacks(operation.getCallbacks(), location + "/callbacks", positions); + } + + private static void collectParameters(Map parameters, String location, + Map positions) { + if (parameters == null) { + return; + } + parameters.forEach((name, parameter) -> collectParameter(parameter, + location + "/" + pointerSegment(name), positions)); + } + + private static void collectParameterList(List parameters, String location, + Map positions) { + if (parameters == null) { + return; + } + for (int i = 0; i < parameters.size(); i++) { + collectParameter(parameters.get(i), location + "/" + i, positions); + } + } + + private static void collectParameter(Parameter parameter, String location, + Map positions) { + if (parameter != null) { + putSchema(parameter.getSchema(), location + "/schema", positions); + collectContent(parameter.getContent(), location + "/content", positions); + } + } + + private static void collectHeaders(Map headers, String location, + Map positions) { + if (headers == null) { + return; + } + headers.forEach((name, header) -> collectHeader(header, + location + "/" + pointerSegment(name), positions)); + } + + private static void collectHeader(Header header, String location, + Map positions) { + if (header != null) { + putSchema(header.getSchema(), location + "/schema", positions); + collectContent(header.getContent(), location + "/content", positions); + } + } + + private static void collectRequestBodies(Map requestBodies, + String location, Map positions) { + if (requestBodies == null) { + return; + } + requestBodies.forEach((name, requestBody) -> collectRequestBody(requestBody, + location + "/" + pointerSegment(name), positions)); + } + + private static void collectRequestBody(RequestBody requestBody, String location, + Map positions) { + if (requestBody != null) { + collectContent(requestBody.getContent(), location + "/content", positions); + } + } + + private static void collectResponses(Map responses, String location, + Map positions) { + if (responses == null) { + return; + } + responses.forEach((name, response) -> collectResponse(response, + location + "/" + pointerSegment(name), positions)); + } + + private static void collectResponse(ApiResponse response, String location, + Map positions) { + if (response != null) { + collectContent(response.getContent(), location + "/content", positions); + collectHeaders(response.getHeaders(), location + "/headers", positions); + } + } + + private static void collectCallbacks(Map callbacks, String location, + Map positions) { + if (callbacks == null) { + return; + } + callbacks.forEach((name, callback) -> { + if (callback != null) { + collectPathItems(callback, location + "/" + pointerSegment(name), positions); + } + }); + } + + private static void collectContent(Content content, String location, + Map positions) { + if (content == null) { + return; + } + content.forEach((mediaName, mediaType) -> collectMediaType(mediaType, + location + "/" + pointerSegment(mediaName), positions)); + } + + private static void collectMediaType(MediaType mediaType, String location, + Map positions) { + if (mediaType == null) { + return; + } + putSchema(mediaType.getSchema(), location + "/schema", positions); + if (mediaType.getEncoding() == null) { + return; + } + for (Map.Entry entry : mediaType.getEncoding().entrySet()) { + Encoding encoding = entry.getValue(); + if (encoding != null) { + collectHeaders(encoding.getHeaders(), location + "/encoding/" + + pointerSegment(entry.getKey()) + "/headers", positions); + } + } + } + + private static void putSchema(Schema schema, String location, + Map positions) { + if (schema != null) { + positions.put(location, schema); + } + } + + private static String pointerSegment(String value) { + return value == null ? "null" : value.replace("~", "~0").replace("/", "~1"); + } + + private static void scanSchemaNode(Schema schema, String location, + KeywordOccurrenceLedger ledger, int depth) { + if (schema == null) { + return; + } + if (depth > MAX_SCHEMA_NESTING) { + throw new IllegalArgumentException( + "Schema nesting exceeds maximum depth of " + + MAX_SCHEMA_NESTING + " at " + location); + } + + // ---- Core / identifier keywords ---- + if (schema.get$id() != null) { + record(ledger, "$id", location, VOCAB_CORE, + KeywordOccurrenceStatus.ANNOTATION, "resource identifier"); + } + if (schema.get$schema() != null) { + boolean recognized = resolveEffectiveDialect(null, schema.get$schema()) + != CppBoostBeastClientCodegen.OasDialect.UNRECOGNIZED; + record(ledger, "$schema", location, VOCAB_CORE, + recognized ? KeywordOccurrenceStatus.ANNOTATION + : KeywordOccurrenceStatus.FAIL_CLOSED, + recognized ? "recognized resource dialect" + : "unrecognized schema resource dialect '" + + schema.get$schema() + "'"); + } + if (schema.get$ref() != null) { + record(ledger, "$ref", location, VOCAB_CORE, + KeywordOccurrenceStatus.EMITTED, "reference"); + } + if (schema.get$anchor() != null) { + record(ledger, "$anchor", location, VOCAB_CORE, + KeywordOccurrenceStatus.ANNOTATION, "plain-name fragment"); + } + if (schema.get$dynamicAnchor() != null) { + record(ledger, "$dynamicAnchor", location, VOCAB_CORE, + KeywordOccurrenceStatus.ANNOTATION, + "dynamic plain-name fragment"); + } + if (schema.get$dynamicRef() != null) { + boolean normalized = schema.getExtensions() != null + && schema.getExtensions().containsKey("x-oas31-dynref"); + record(ledger, "$dynamicRef", location, VOCAB_CORE, + normalized ? KeywordOccurrenceStatus.EMITTED + : KeywordOccurrenceStatus.FAIL_CLOSED, + normalized ? "normalized dynamic reference" + : "unsupported unnormalized $dynamicRef '" + + schema.get$dynamicRef() + "'"); + } + if (schema.get$comment() != null) { + record(ledger, "$comment", location, VOCAB_CORE, + KeywordOccurrenceStatus.ANNOTATION, "no validity effect"); + } + if (schema.get$vocabulary() != null) { + // swagger-models exposes $vocabulary as a String, so required + // vocabulary flags cannot be inspected without guessing. + record(ledger, "$vocabulary", location, VOCAB_CORE, + KeywordOccurrenceStatus.FAIL_CLOSED, + "$vocabulary is not representable by swagger-models"); + } + + // ---- Validation vocabulary ---- + if (schema.getType() != null + || (schema.getTypes() != null && !schema.getTypes().isEmpty())) { + record(ledger, "type", location, VOCAB_VALIDATION, KeywordOccurrenceStatus.EMITTED, + "validation-type / validation-type-array"); + } + if (schema.getEnum() != null) { + record(ledger, "enum", location, VOCAB_VALIDATION, KeywordOccurrenceStatus.EMITTED, + "validation-enum-values"); + } + if (schema.getConst() != null || Oas31RawSpecRecovery.hasExplicitConst(schema)) { + record(ledger, "const", location, VOCAB_VALIDATION, KeywordOccurrenceStatus.EMITTED, + "validation-const; exact-math caveat follows"); + } + boolean hasMinimum = schema.getMinimum() != null + || schema.getExclusiveMinimum() != null + || schema.getExclusiveMinimumValue() != null; + boolean hasMaximum = schema.getMaximum() != null + || schema.getExclusiveMaximum() != null + || schema.getExclusiveMaximumValue() != null; + if (hasMinimum) { + record(ledger, "minimum", location, VOCAB_VALIDATION, KeywordOccurrenceStatus.EMITTED, + "numeric-range; 3.0 boolean exclusiveMinimum preserved"); + } + if (hasMaximum) { + record(ledger, "maximum", location, VOCAB_VALIDATION, KeywordOccurrenceStatus.EMITTED, + "numeric-range; 3.0 boolean exclusiveMaximum preserved"); + } + if (schema.getMultipleOf() != null) { + record(ledger, "multipleOf", location, VOCAB_VALIDATION, + KeywordOccurrenceStatus.EMITTED, "validation-multiple-of; exact-math caveat"); + } + if (schema.getMinLength() != null) { + record(ledger, "minLength", location, VOCAB_VALIDATION, + KeywordOccurrenceStatus.EMITTED, + "validation-min-length; Unicode code points; decimal lexemes preserved"); + } + if (schema.getMaxLength() != null) { + record(ledger, "maxLength", location, VOCAB_VALIDATION, + KeywordOccurrenceStatus.EMITTED, + "validation-max-length; Unicode code points; decimal lexemes preserved"); + } + if (schema.getPattern() != null) { + record(ledger, "pattern", location, VOCAB_VALIDATION, KeywordOccurrenceStatus.EMITTED, + "ECMAScript-subset unanchored regex_search; \\p{Letter} range translation"); + } + if (schema.getMinItems() != null) { + record(ledger, "minItems", location, VOCAB_VALIDATION, KeywordOccurrenceStatus.EMITTED, + "validation-min-items"); + } + if (schema.getMaxItems() != null) { + record(ledger, "maxItems", location, VOCAB_VALIDATION, KeywordOccurrenceStatus.EMITTED, + "validation-max-items"); + } + if (Boolean.TRUE.equals(schema.getUniqueItems())) { + record(ledger, "uniqueItems", location, VOCAB_VALIDATION, + KeywordOccurrenceStatus.EMITTED, "validation-unique-items; exact-math caveat"); + } + if (schema.getRequired() != null && !schema.getRequired().isEmpty()) { + record(ledger, "required", location, VOCAB_VALIDATION, KeywordOccurrenceStatus.EMITTED, + "validation-required / object-properties"); + } + if (schema.getMinProperties() != null) { + record(ledger, "minProperties", location, VOCAB_VALIDATION, + KeywordOccurrenceStatus.EMITTED, "validation-min-properties"); + } + if (schema.getMaxProperties() != null) { + record(ledger, "maxProperties", location, VOCAB_VALIDATION, + KeywordOccurrenceStatus.EMITTED, "validation-max-properties"); + } + if (schema.getMinContains() != null) { + record(ledger, "minContains", location, VOCAB_VALIDATION, + KeywordOccurrenceStatus.EMITTED, + "validation-min-contains; exact count bound, inert without contains"); + } + if (schema.getMaxContains() != null) { + record(ledger, "maxContains", location, VOCAB_VALIDATION, + KeywordOccurrenceStatus.EMITTED, + "validation-max-contains; exact count bound, inert without contains"); + } + if (schema.getDependentRequired() != null && !schema.getDependentRequired().isEmpty()) { + record(ledger, "dependentRequired", location, VOCAB_VALIDATION, + KeywordOccurrenceStatus.EMITTED, "validation-dependent-required"); + } + + // ---- Applicator vocabulary ---- + if (schema.getProperties() != null && !schema.getProperties().isEmpty()) { + record(ledger, "properties", location, VOCAB_APPLICATOR, + KeywordOccurrenceStatus.EMITTED, "object model emission"); + for (Map.Entry p : schema.getProperties().entrySet()) { + scanSchemaNode(p.getValue(), location + "/properties/" + p.getKey(), + ledger, depth + 1); + } + } + if (schema.getPatternProperties() != null && !schema.getPatternProperties().isEmpty()) { + record(ledger, "patternProperties", location, VOCAB_APPLICATOR, + KeywordOccurrenceStatus.EMITTED, + "validation-pattern-properties; pattern engine — no longer a silent skip"); + for (Map.Entry p : schema.getPatternProperties().entrySet()) { + scanSchemaNode(p.getValue(), location + "/patternProperties/" + p.getKey(), + ledger, depth + 1); + } + } + if (schema.getAdditionalProperties() != null) { + record(ledger, "additionalProperties", location, VOCAB_APPLICATOR, + KeywordOccurrenceStatus.EMITTED, + "additionalProperties tri-state; pattern-covered keys exempt"); + Object addProp = schema.getAdditionalProperties(); + if (addProp instanceof Schema) { + scanSchemaNode((Schema) addProp, location + "/additionalProperties", + ledger, depth + 1); + } + } + if (schema.getPropertyNames() != null) { + record(ledger, "propertyNames", location, VOCAB_APPLICATOR, + KeywordOccurrenceStatus.EMITTED, "validation-property-names"); + scanSchemaNode(schema.getPropertyNames(), location + "/propertyNames", + ledger, depth + 1); + } + if (schema.getDependentSchemas() != null && !schema.getDependentSchemas().isEmpty()) { + record(ledger, "dependentSchemas", location, VOCAB_APPLICATOR, + KeywordOccurrenceStatus.EMITTED, "validation-dependent-schemas"); + for (Map.Entry d : schema.getDependentSchemas().entrySet()) { + scanSchemaNode(d.getValue(), location + "/dependentSchemas/" + d.getKey(), + ledger, depth + 1); + } + } + if (schema.getPrefixItems() != null && !schema.getPrefixItems().isEmpty()) { + record(ledger, "prefixItems", location, VOCAB_APPLICATOR, + KeywordOccurrenceStatus.EMITTED, "validation-prefix-items; tuple by index"); + for (int i = 0; i < schema.getPrefixItems().size(); i++) { + scanSchemaNode(schema.getPrefixItems().get(i), + location + "/prefixItems/" + i, ledger, depth + 1); + } + } + if (schema.getItems() != null) { + record(ledger, "items", location, VOCAB_APPLICATOR, KeywordOccurrenceStatus.EMITTED, + "element typing via array storage"); + scanSchemaNode(schema.getItems(), location + "/items", ledger, depth + 1); + } + if (schema.getContains() != null) { + record(ledger, "contains", location, VOCAB_APPLICATOR, KeywordOccurrenceStatus.EMITTED, + "validation-contains-schema; matched indices feed unevaluatedItems"); + scanSchemaNode(schema.getContains(), location + "/contains", ledger, depth + 1); + } + if (schema.getNot() != null) { + record(ledger, "not", location, VOCAB_APPLICATOR, KeywordOccurrenceStatus.EMITTED, + "validation-not-schema; shared evaluator"); + scanSchemaNode(schema.getNot(), location + "/not", ledger, depth + 1); + } + if (schema.getIf() != null || schema.getThen() != null || schema.getElse() != null) { + if (schema.getIf() != null) { + record(ledger, "if", location, VOCAB_APPLICATOR, KeywordOccurrenceStatus.EMITTED, + "validation-if; transactional guard"); + scanSchemaNode(schema.getIf(), location + "/if", ledger, depth + 1); + } + if (schema.getThen() != null) { + record(ledger, "then", location, VOCAB_APPLICATOR, KeywordOccurrenceStatus.EMITTED, + "validation-then; applied branch only"); + scanSchemaNode(schema.getThen(), location + "/then", ledger, depth + 1); + } + if (schema.getElse() != null) { + record(ledger, "else", location, VOCAB_APPLICATOR, KeywordOccurrenceStatus.EMITTED, + "validation-else; applied branch only"); + scanSchemaNode(schema.getElse(), location + "/else", ledger, depth + 1); + } + } + if (schema.getAllOf() != null && !schema.getAllOf().isEmpty()) { + record(ledger, "allOf", location, VOCAB_APPLICATOR, KeywordOccurrenceStatus.EMITTED, + "composition; getUnsupportedAssertions may carry FAIL_CLOSED branches"); + for (int i = 0; i < schema.getAllOf().size(); i++) { + scanSchemaNode(schema.getAllOf().get(i), location + "/allOf/" + i, + ledger, depth + 1); + } + } + if (schema.getAnyOf() != null && !schema.getAnyOf().isEmpty()) { + record(ledger, "anyOf", location, VOCAB_APPLICATOR, KeywordOccurrenceStatus.EMITTED, + "composition"); + for (int i = 0; i < schema.getAnyOf().size(); i++) { + scanSchemaNode(schema.getAnyOf().get(i), location + "/anyOf/" + i, + ledger, depth + 1); + } + } + if (schema.getOneOf() != null && !schema.getOneOf().isEmpty()) { + record(ledger, "oneOf", location, VOCAB_APPLICATOR, KeywordOccurrenceStatus.EMITTED, + "composition"); + for (int i = 0; i < schema.getOneOf().size(); i++) { + scanSchemaNode(schema.getOneOf().get(i), location + "/oneOf/" + i, + ledger, depth + 1); + } + } + + // ---- Unevaluated vocabulary (validity semantics live; interplay with + // if/then/else, contains, $dynamicRef handled by the evaluator) ---- + if (schema.getUnevaluatedProperties() != null) { + record(ledger, "unevaluatedProperties", location, VOCAB_UNEVALUATED, + KeywordOccurrenceStatus.EMITTED, + "validation-unevaluated-properties (reject/schema forms)"); + Schema up = schema.getUnevaluatedProperties(); + scanSchemaNode(up, location + "/unevaluatedProperties", ledger, depth + 1); + } + if (schema.getUnevaluatedItems() != null) { + record(ledger, "unevaluatedItems", location, VOCAB_UNEVALUATED, + KeywordOccurrenceStatus.EMITTED, + "validation-unevaluated-items; evaluation-path semantics"); + Schema ui = schema.getUnevaluatedItems(); + scanSchemaNode(ui, location + "/unevaluatedItems", ledger, depth + 1); + } + + // ---- Metadata vocabulary: annotation only ---- + if (schema.getTitle() != null) { + record(ledger, "title", location, VOCAB_METADATA, + KeywordOccurrenceStatus.ANNOTATION, null); + } + if (schema.getDescription() != null) { + record(ledger, "description", location, VOCAB_METADATA, + KeywordOccurrenceStatus.ANNOTATION, null); + } + if (schema.getDefault() != null) { + record(ledger, "default", location, VOCAB_METADATA, + KeywordOccurrenceStatus.ANNOTATION, "annotation only; never injected"); + } + if (Boolean.TRUE.equals(schema.getDeprecated())) { + record(ledger, "deprecated", location, VOCAB_METADATA, + KeywordOccurrenceStatus.ANNOTATION, null); + } + if (Boolean.TRUE.equals(schema.getReadOnly())) { + record(ledger, "readOnly", location, VOCAB_METADATA, + KeywordOccurrenceStatus.ANNOTATION, null); + } + if (Boolean.TRUE.equals(schema.getWriteOnly())) { + record(ledger, "writeOnly", location, VOCAB_METADATA, + KeywordOccurrenceStatus.ANNOTATION, null); + } + if (schema.getExamples() != null && !schema.getExamples().isEmpty()) { + record(ledger, "examples", location, VOCAB_METADATA, + KeywordOccurrenceStatus.ANNOTATION, null); + } + + // ---- Format-annotation vocabulary ---- + if (schema.getFormat() != null) { + record(ledger, "format", location, VOCAB_FORMAT, + KeywordOccurrenceStatus.ANNOTATION, "annotation by default; strict opt-in"); + } + + // ---- Content vocabulary ---- + if (schema.getContentEncoding() != null) { + record(ledger, "contentEncoding", location, VOCAB_CONTENT, + KeywordOccurrenceStatus.ANNOTATION, "annotation; no auto-decode"); + } + if (schema.getContentMediaType() != null) { + record(ledger, "contentMediaType", location, VOCAB_CONTENT, + KeywordOccurrenceStatus.ANNOTATION, "annotation; no auto-decode"); + } + if (schema.getContentSchema() != null) { + record(ledger, "contentSchema", location, VOCAB_CONTENT, + KeywordOccurrenceStatus.ANNOTATION, "schema-valued annotation; child indexed"); + scanSchemaNode(schema.getContentSchema(), location + "/contentSchema", + ledger, depth + 1); + } + + // ---- OAS base vocabulary: annotation only ---- + if (schema.getDiscriminator() != null) { + record(ledger, "discriminator", location, VOCAB_OAS_BASE, + KeywordOccurrenceStatus.ANNOTATION, "validation-neutral candidate-order hint"); + } + if (schema.getXml() != null) { + record(ledger, "xml", location, VOCAB_OAS_BASE, KeywordOccurrenceStatus.ANNOTATION, + null); + } + if (schema.getExternalDocs() != null) { + record(ledger, "externalDocs", location, VOCAB_OAS_BASE, + KeywordOccurrenceStatus.ANNOTATION, null); + } + if (schema.getExample() != null || schema.getExampleSetFlag()) { + record(ledger, "example", location, VOCAB_OAS_BASE, + KeywordOccurrenceStatus.ANNOTATION, "OAS singular example, 3.0 dual-path"); + } + + // ---- 3.0 dual-path compatibility keywords ---- + if (Boolean.TRUE.equals(schema.getNullable())) { + record(ledger, "nullable", location, VOCAB_OAS_BASE, KeywordOccurrenceStatus.EMITTED, + "3.0 nullable dual-path; tri-state NullableField"); + } + if (schema.getBooleanSchemaValue() != null) { + record(ledger, "boolean-schema", location, VOCAB_VALIDATION, + KeywordOccurrenceStatus.EMITTED, + "boolean value-schema; SUPPORTED in OAS 3.1; OAS 3.0 rejects a bare boolean" + + " schema (documented dual-path limitation)"); + } + } + + /** Record one occurrence in the ledger. */ + private static void record(KeywordOccurrenceLedger ledger, String keyword, String location, + String vocabularyUri, KeywordOccurrenceStatus status, + String detail) { + ledger.add(new KeywordOccurrence(keyword, location, vocabularyUri, status, detail)); + } + + + /** + * Set of fail-closed required-vocabulary keywords actually encountered for this + * document (the keywords the generator refuses rather than silently accepting). + */ + public static Set failClosedKeywords(OpenAPI api) { + return scanSchemaKeywordOccurrences(api).failClosed(); + } + +} \ No newline at end of file diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/Oas31RawSpecRecovery.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/Oas31RawSpecRecovery.java new file mode 100644 index 000000000000..e5f877d4317a --- /dev/null +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/Oas31RawSpecRecovery.java @@ -0,0 +1,779 @@ +package org.openapitools.codegen.languages; + +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.cfg.JsonNodeFeature; +import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; +import com.fasterxml.jackson.dataformat.yaml.YAMLMapper; +import io.swagger.v3.core.util.Json31; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.media.Schema; +import org.openapitools.codegen.utils.ModelUtils; + +import java.io.InputStream; +import java.math.BigDecimal; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Recovers OAS 3.1 schema facts that swagger-parser or model normalization + * cannot retain. Recovery follows the parsed YAML/JSON tree by schema position; + * it never associates constraints by textual proximity. + */ +final class Oas31RawSpecRecovery { + + private static final String EMPTY_ENUM_EXT = "x-oas31-empty-enum"; + private static final String ENUM_JSON_EXT = "x-oas31-enum-json"; + private static final String DEFAULT_PRESENT_EXT = "x-oas31-default-present"; + private static final String DEFAULT_JSON_EXT = "x-oas31-default-json"; + private static final String EXAMPLES_JSON_EXT = "x-oas31-examples-json"; + private static final String CONST_PRESENT_EXT = "x-oas31-const-present"; + private static final String CONST_JSON_EXT = "x-oas31-const-json"; + private static final String TYPE_NULL_EXT = "x-oas31-pristine-type-null"; + private static final String DEPENDENT_REQUIRED_EXT = "x-oas31-dependent-required"; + static final String LEGACY_NULLABLE_EXT = "x-oas31-legacy-nullable"; + private static final List COUNT_KEYWORDS = List.of( + "minItems", "maxItems", + "minProperties", "maxProperties", + "minLength", "maxLength", + "minContains", "maxContains"); + + private Oas31RawSpecRecovery() { + } + + /** + * Restores prefixItems from a pristine parse before the normalized graph is + * converted to generator models. + */ + static void restoreNormalizerDroppedPrefixItems(OpenAPI api, String inputSpec) { + if (!needsRawRecovery(api, inputSpec)) { + return; + } + + OpenAPI pristine; + try { + io.swagger.v3.parser.core.models.ParseOptions options = + new io.swagger.v3.parser.core.models.ParseOptions(); + options.setResolve(false); + options.setResolveResponses(false); + pristine = new io.swagger.v3.parser.OpenAPIV3Parser() + .readLocation(inputSpec, null, options).getOpenAPI(); + } catch (RuntimeException ex) { + throw new IllegalStateException( + "Unable to parse the pristine OAS 3.1 document for prefixItems recovery", + ex); + } + if (pristine == null) { + throw new IllegalStateException( + "Pristine OAS 3.1 parse did not produce an OpenAPI document"); + } + + Map mutatedSchemas = Oas31KeywordScanner.rootSchemaPositions(api); + Oas31KeywordScanner.rootSchemaPositions(pristine).forEach((location, schema) -> { + Schema mutated = mutatedSchemas.get(location); + if (mutated != null) { + mergePristineArrayStructure(schema, + recoveryTarget(api, schema.get$ref() == null, mutated), api); + } + }); + } + + private static boolean needsRawRecovery(OpenAPI api, String inputSpec) { + return api != null + && Oas31KeywordScanner.isOas31(api) + && inputSpec != null; + } + + private static void mergePristineArrayStructure( + Schema pristine, Schema mutated, OpenAPI api) { + if (pristine == null || mutated == null) { + return; + } + if (pristine.getPrefixItems() != null + && !pristine.getPrefixItems().isEmpty() + && (mutated.getPrefixItems() == null + || mutated.getPrefixItems().isEmpty())) { + mutated.setPrefixItems(pristine.getPrefixItems()); + } + + mergeSchemaLists(pristine.getAllOf(), mutated.getAllOf(), api); + mergeSchemaLists(pristine.getAnyOf(), mutated.getAnyOf(), api); + mergeSchemaLists(pristine.getOneOf(), mutated.getOneOf(), api); + mergeSchemaMaps(pristine.getProperties(), mutated.getProperties(), api); + mergeSchemaMaps(pristine.getPatternProperties(), mutated.getPatternProperties(), api); + mergeSchemaMaps(pristine.getDependentSchemas(), mutated.getDependentSchemas(), api); + mergeSchemaLists(pristine.getPrefixItems(), mutated.getPrefixItems(), api); + mergeSchemaValue(pristine.getItems(), mutated.getItems(), api); + mergeSchemaValue(pristine.getContains(), mutated.getContains(), api); + mergeSchemaValue(pristine.getNot(), mutated.getNot(), api); + mergeSchemaValue(pristine.getIf(), mutated.getIf(), api); + mergeSchemaValue(pristine.getThen(), mutated.getThen(), api); + mergeSchemaValue(pristine.getElse(), mutated.getElse(), api); + mergeSchemaValue(pristine.getPropertyNames(), mutated.getPropertyNames(), api); + mergeSchemaValue(pristine.getContentSchema(), mutated.getContentSchema(), api); + mergeSchemaValue( + pristine.getAdditionalProperties(), mutated.getAdditionalProperties(), api); + mergeSchemaValue( + pristine.getUnevaluatedProperties(), mutated.getUnevaluatedProperties(), api); + mergeSchemaValue(pristine.getUnevaluatedItems(), mutated.getUnevaluatedItems(), api); + } + + private static void mergeSchemaLists(List pristine, List mutated, OpenAPI api) { + if (pristine == null || mutated == null) { + return; + } + int count = Math.min(pristine.size(), mutated.size()); + for (int i = 0; i < count; i++) { + mergeSchemaValue(pristine.get(i), mutated.get(i), api); + } + } + + private static void mergeSchemaMaps(Map pristine, Map mutated, OpenAPI api) { + if (pristine == null || mutated == null) { + return; + } + for (Object key : pristine.keySet()) { + mergeSchemaValue(pristine.get(key), mutated.get(key), api); + } + } + + private static void mergeSchemaValue(Object pristine, Object mutated, OpenAPI api) { + if (pristine instanceof Schema && mutated instanceof Schema) { + Schema pristineSchema = (Schema) pristine; + mergePristineArrayStructure(pristineSchema, + recoveryTarget(api, pristineSchema.get$ref() == null, (Schema) mutated), api); + } + } + + + /** + * Recovers exact count bounds, enum values, dependentRequired, and null + * type members from the raw document tree. + */ + static void recoverPristineLiterals(OpenAPI api, String inputSpec) { + if (!needsRawRecovery(api, inputSpec)) { + return; + } + + JsonNode document; + try { + document = readRawDocument(inputSpec); + } catch (Exception ex) { + throw new IllegalStateException( + "Unable to read the raw OAS 3.1 document for exact schema recovery", + ex); + } + + Map parsedSchemas = Oas31KeywordScanner.rootSchemaPositions(api); + rawRootSchemaPositions(document).forEach((location, raw) -> { + Schema parsed = parsedSchemas.get(location); + if (parsed != null) { + restoreRawObjectKeywords(api, raw, parsed, location); + recoverSchema(raw, recoveryTarget(api, + raw.isObject() && !raw.has("$ref"), parsed), location, api); + } + }); + } + + /** Reads the input document with swagger-parser's configured YAML limits. */ + static JsonNode readRawDocument(String inputSpec) throws Exception { + String text = readInputSpec(inputSpec); + YAMLFactory yamlFactory = YAMLFactory.builder() + .loaderOptions(io.swagger.v3.parser.util.DeserializationUtils + .buildLoaderOptions()) + .build(); + ObjectMapper mapper = YAMLMapper.builder(yamlFactory) + .enable(DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS) + .enable(DeserializationFeature.USE_BIG_INTEGER_FOR_INTS) + .disable(JsonNodeFeature.STRIP_TRAILING_BIGDECIMAL_ZEROES) + .build(); + return mapper.readTree(text); + } + + static String readInputSpec(String inputSpec) throws Exception { + boolean windowsDrivePath = inputSpec.matches("^[A-Za-z]:[\\\\/].*"); + if (!windowsDrivePath) { + try { + URI uri = URI.create(inputSpec); + if (uri.getScheme() != null) { + try (InputStream input = uri.toURL().openStream()) { + return new String(input.readAllBytes(), StandardCharsets.UTF_8); + } + } + } catch (IllegalArgumentException ignored) { + // A local path may contain URI-reserved characters such as spaces. + } + } + return Files.readString(Paths.get(inputSpec), StandardCharsets.UTF_8); + } + + private static Map rawRootSchemaPositions(JsonNode document) { + Map positions = new LinkedHashMap<>(); + JsonNode components = document.path("components"); + collectRawSchemas(components.path("schemas"), "#/components/schemas", positions); + collectRawMap(components.path("parameters"), "#/components/parameters", positions, + (parameter, location) -> collectRawParameter(parameter, location, positions)); + collectRawMap(components.path("headers"), "#/components/headers", positions, + (header, location) -> collectRawHeader(header, location, positions)); + collectRawMap(components.path("requestBodies"), "#/components/requestBodies", positions, + (body, location) -> collectRawRequestBody(body, location, positions)); + collectRawMap(components.path("responses"), "#/components/responses", positions, + (response, location) -> collectRawResponse(response, location, positions)); + collectRawMap(components.path("callbacks"), "#/components/callbacks", positions, + (callback, location) -> collectRawCallback(callback, location, positions)); + collectRawPathItems(components.path("pathItems"), "#/components/pathItems", positions); + collectRawPathItems(document.path("paths"), "#/paths", positions); + collectRawPathItems(document.path("webhooks"), "#/webhooks", positions); + return positions; + } + + private static void collectRawSchemas(JsonNode schemas, String location, + Map positions) { + collectRawMap(schemas, location, positions, + (schema, schemaLocation) -> positions.put(schemaLocation, schema)); + } + + private static void collectRawPathItems(JsonNode pathItems, String location, + Map positions) { + collectRawMap(pathItems, location, positions, + (pathItem, pathLocation) -> collectRawPathItem(pathItem, pathLocation, positions)); + } + + private static void collectRawPathItem(JsonNode pathItem, String location, + Map positions) { + if (!pathItem.isObject()) { + return; + } + collectRawParameterArray(pathItem.path("parameters"), location + "/parameters", positions); + for (String method : List.of( + "get", "put", "post", "delete", "options", "head", "patch", "trace")) { + JsonNode operation = pathItem.get(method); + if (operation != null) { + collectRawOperation(operation, location + "/" + method, positions); + } + } + } + + private static void collectRawOperation(JsonNode operation, String location, + Map positions) { + if (!operation.isObject()) { + return; + } + collectRawParameterArray(operation.path("parameters"), location + "/parameters", positions); + collectRawRequestBody(operation.get("requestBody"), location + "/requestBody", positions); + collectRawMap(operation.path("responses"), location + "/responses", positions, + (response, responseLocation) -> + collectRawResponse(response, responseLocation, positions)); + collectRawMap(operation.path("callbacks"), location + "/callbacks", positions, + (callback, callbackLocation) -> + collectRawCallback(callback, callbackLocation, positions)); + } + + private static void collectRawParameterArray(JsonNode parameters, String location, + Map positions) { + if (!parameters.isArray()) { + return; + } + for (int i = 0; i < parameters.size(); i++) { + collectRawParameter(parameters.get(i), location + "/" + i, positions); + } + } + + private static void collectRawParameter(JsonNode parameter, String location, + Map positions) { + if (!isObject(parameter)) { + return; + } + putRawSchema(parameter.get("schema"), location + "/schema", positions); + collectRawContent(parameter.path("content"), location + "/content", positions); + } + + private static void collectRawHeader(JsonNode header, String location, + Map positions) { + if (!isObject(header)) { + return; + } + putRawSchema(header.get("schema"), location + "/schema", positions); + collectRawContent(header.path("content"), location + "/content", positions); + } + + private static void collectRawRequestBody(JsonNode requestBody, String location, + Map positions) { + if (isObject(requestBody)) { + collectRawContent(requestBody.path("content"), location + "/content", positions); + } + } + + private static void collectRawResponse(JsonNode response, String location, + Map positions) { + if (!isObject(response)) { + return; + } + collectRawContent(response.path("content"), location + "/content", positions); + collectRawMap(response.path("headers"), location + "/headers", positions, + (header, headerLocation) -> collectRawHeader(header, headerLocation, positions)); + } + + private static void collectRawCallback(JsonNode callback, String location, + Map positions) { + if (!isObject(callback)) { + return; + } + Iterator> fields = callback.fields(); + while (fields.hasNext()) { + Map.Entry field = fields.next(); + if (!"$ref".equals(field.getKey())) { + collectRawPathItem(field.getValue(), + location + "/" + pointerSegment(field.getKey()), positions); + } + } + } + + private static void collectRawContent(JsonNode content, String location, + Map positions) { + collectRawMap(content, location, positions, + (mediaType, mediaLocation) -> + collectRawMediaType(mediaType, mediaLocation, positions)); + } + + private static void collectRawMediaType(JsonNode mediaType, String location, + Map positions) { + if (!isObject(mediaType)) { + return; + } + putRawSchema(mediaType.get("schema"), location + "/schema", positions); + collectRawMap(mediaType.path("encoding"), location + "/encoding", positions, + (encoding, encodingLocation) -> { + if (isObject(encoding)) { + collectRawMap(encoding.path("headers"), encodingLocation + "/headers", + positions, (header, headerLocation) -> + collectRawHeader(header, headerLocation, positions)); + } + }); + } + + private static void collectRawMap( + JsonNode object, String location, Map positions, + java.util.function.BiConsumer collector) { + if (!object.isObject()) { + return; + } + Iterator> fields = object.fields(); + while (fields.hasNext()) { + Map.Entry field = fields.next(); + collector.accept(field.getValue(), location + "/" + pointerSegment(field.getKey())); + } + } + + private static void putRawSchema(JsonNode schema, String location, + Map positions) { + if (schema != null) { + positions.put(location, schema); + } + } + + private static boolean isObject(JsonNode node) { + return node != null && node.isObject(); + } + + private static String pointerSegment(String value) { + return value.replace("~", "~0").replace("/", "~1"); + } + + private static Schema recoveryTarget(OpenAPI api, boolean dereference, Schema parsed) { + if (!dereference || parsed == null || parsed.get$ref() == null) { + return parsed; + } + Schema referenced = ModelUtils.getReferencedSchema(api, parsed); + return referenced == null ? parsed : referenced; + } + + private static void recoverSchema( + JsonNode raw, Schema parsed, String location, OpenAPI api) { + if (raw == null || parsed == null || !raw.isObject()) { + return; + } + if (raw.has("default")) { + addExtension(parsed, DEFAULT_PRESENT_EXT, true); + addExtension(parsed, DEFAULT_JSON_EXT, raw.get("default").toString()); + } + if (raw.has("const")) { + addExtension(parsed, CONST_PRESENT_EXT, true); + addExtension(parsed, CONST_JSON_EXT, raw.get("const").toString()); + } + if (raw.has("examples")) { + addExtension(parsed, EXAMPLES_JSON_EXT, raw.get("examples").toString()); + } + + + JsonNode rawEnum = raw.get("enum"); + if (rawEnum != null && rawEnum.isArray()) { + addExtension(parsed, ENUM_JSON_EXT, rawEnum.toString()); + if (rawEnum.isEmpty()) { + addExtension(parsed, EMPTY_ENUM_EXT, true); + } + } + + JsonNode rawType = raw.get("type"); + if (rawType != null && rawType.isArray()) { + for (JsonNode type : rawType) { + if (type.isTextual() && "null".equals(type.textValue())) { + addExtension(parsed, TYPE_NULL_EXT, true); + break; + } + } + } + + for (String keyword : COUNT_KEYWORDS) { + JsonNode bound = raw.get(keyword); + if (bound != null) { + recoverCountBound(parsed, keyword, bound, location); + } + } + + JsonNode dependentRequired = raw.get("dependentRequired"); + if (dependentRequired != null) { + addExtension(parsed, DEPENDENT_REQUIRED_EXT, + parseDependentRequired(dependentRequired, location)); + } + + recoverArray(raw.get("allOf"), parsed.getAllOf(), location + "/allOf", api); + parsed.setAnyOf(restoreCollapsedAllNullBranches( + raw.get("anyOf"), parsed.getAnyOf())); + parsed.setOneOf(restoreCollapsedAllNullBranches( + raw.get("oneOf"), parsed.getOneOf())); + recoverArray(raw.get("anyOf"), parsed.getAnyOf(), location + "/anyOf", api); + recoverArray(raw.get("oneOf"), parsed.getOneOf(), location + "/oneOf", api); + recoverMap(raw.get("properties"), parsed.getProperties(), location + "/properties", api); + recoverMap(raw.get("patternProperties"), parsed.getPatternProperties(), + location + "/patternProperties", api); + recoverMap(raw.get("dependentSchemas"), parsed.getDependentSchemas(), + location + "/dependentSchemas", api); + recoverArray(raw.get("prefixItems"), parsed.getPrefixItems(), + location + "/prefixItems", api); + recoverChild(raw.get("items"), parsed.getItems(), location + "/items", api); + recoverChild(raw.get("contains"), parsed.getContains(), location + "/contains", api); + recoverChild(raw.get("not"), parsed.getNot(), location + "/not", api); + recoverChild(raw.get("if"), parsed.getIf(), location + "/if", api); + recoverChild(raw.get("then"), parsed.getThen(), location + "/then", api); + recoverChild(raw.get("else"), parsed.getElse(), location + "/else", api); + recoverChild(raw.get("propertyNames"), parsed.getPropertyNames(), + location + "/propertyNames", api); + recoverChild(raw.get("contentSchema"), parsed.getContentSchema(), + location + "/contentSchema", api); + recoverChild(raw.get("additionalProperties"), parsed.getAdditionalProperties(), + location + "/additionalProperties", api); + recoverChild(raw.get("unevaluatedProperties"), parsed.getUnevaluatedProperties(), + location + "/unevaluatedProperties", api); + recoverChild(raw.get("unevaluatedItems"), parsed.getUnevaluatedItems(), + location + "/unevaluatedItems", api); + } + + private static void restoreRawObjectKeywords( + OpenAPI api, JsonNode raw, Schema parsed, String location) { + if (raw == null || !raw.isObject() || parsed == null) { + return; + } + + // OAS 3.1 parsers may discard the legacy OAS 3.0 nullable keyword. + // Restore it on the carrier, not the referenced target, because a $ref + // sibling applies only at this schema location. + JsonNode nullable = raw.get("nullable"); + if (nullable != null && nullable.isBoolean()) { + parsed.setNullable(nullable.booleanValue()); + if (nullable.booleanValue()) { + addExtension(parsed, LEGACY_NULLABLE_EXT, true); + } + } + + // Inline-model extraction replaces an object schema with a synthetic + // ref. Reuse the normalized target's schema children so references + // rewritten by InlineModelResolver stay canonical during raw recovery. + Schema normalizedSource = parsed; + if (parsed.get$ref() != null && !raw.has("$ref")) { + normalizedSource = recoveryTarget(api, true, parsed); + } + + JsonNode additionalProperties = raw.get("additionalProperties"); + if (additionalProperties != null) { + if (additionalProperties.isBoolean()) { + parsed.setAdditionalProperties(additionalProperties.booleanValue()); + } else if (normalizedSource.getAdditionalProperties() instanceof Schema) { + parsed.setAdditionalProperties(normalizedSource.getAdditionalProperties()); + } else { + parsed.setAdditionalProperties(schemaFromRaw( + additionalProperties, location + "/additionalProperties")); + } + } + JsonNode properties = raw.get("properties"); + if (parsed.get$ref() != null && properties != null && properties.isObject()) { + if (normalizedSource.getProperties() != null) { + parsed.setProperties(new LinkedHashMap<>(normalizedSource.getProperties())); + } else { + parsed.setProperties(schemaMapFromRaw( + properties, location + "/properties")); + } + } + + JsonNode patternProperties = raw.get("patternProperties"); + if (patternProperties != null && patternProperties.isObject()) { + if (normalizedSource.getPatternProperties() != null) { + parsed.setPatternProperties( + new LinkedHashMap<>(normalizedSource.getPatternProperties())); + } else { + parsed.setPatternProperties(schemaMapFromRaw( + patternProperties, location + "/patternProperties")); + } + } + + JsonNode propertyNames = raw.get("propertyNames"); + if (propertyNames != null) { + if (normalizedSource.getPropertyNames() != null) { + parsed.setPropertyNames(normalizedSource.getPropertyNames()); + } else { + parsed.setPropertyNames(schemaFromRaw( + propertyNames, location + "/propertyNames")); + } + } + } + + private static Map schemaMapFromRaw( + JsonNode raw, String location) { + Map recovered = new LinkedHashMap<>(); + Iterator> fields = raw.fields(); + while (fields.hasNext()) { + Map.Entry field = fields.next(); + recovered.put(field.getKey(), schemaFromRaw( + field.getValue(), location + "/" + pointerSegment(field.getKey()))); + } + return recovered; + } + + private static Schema schemaFromRaw(JsonNode raw, String location) { + if (raw == null || (!raw.isObject() && !raw.isBoolean())) { + throw new IllegalArgumentException( + "Expected a schema at " + location); + } + try { + return Json31.mapper().treeToValue(raw.deepCopy(), Schema.class); + } catch (Exception ex) { + throw new IllegalStateException( + "Unable to recover raw OAS 3.1 schema at " + location, ex); + } + } + + private static List restoreCollapsedAllNullBranches( + JsonNode rawBranches, List parsedBranches) { + if (rawBranches == null || !rawBranches.isArray()) { + return parsedBranches; + } + for (JsonNode rawBranch : rawBranches) { + if (!isRawNullSchema(rawBranch)) { + return parsedBranches; + } + } + List restored = parsedBranches == null + ? new ArrayList<>() : parsedBranches; + while (restored.size() < rawBranches.size()) { + restored.add(new Schema<>().type("null")); + } + return restored; + } + + private static boolean isRawNullSchema(JsonNode rawSchema) { + if (rawSchema == null || !rawSchema.isObject()) { + return false; + } + JsonNode rawType = rawSchema.get("type"); + if (rawType == null) { + return false; + } + if (rawType.isTextual()) { + return "null".equals(rawType.textValue()); + } + return rawType.isArray() + && rawType.size() == 1 + && rawType.get(0).isTextual() + && "null".equals(rawType.get(0).textValue()); + } + + private static void recoverCountBound( + Schema parsed, String keyword, JsonNode bound, String location) { + if (!bound.isNumber()) { + throw new IllegalArgumentException( + keyword + " must be a non-negative integer at " + location); + } + BigDecimal value = bound.decimalValue().stripTrailingZeros(); + if (value.signum() < 0 || value.scale() > 0) { + throw new IllegalArgumentException( + keyword + " must be a non-negative integer at " + location); + } + addExtension(parsed, countBoundExtensionName(keyword), bound.toString()); + } + + private static Map parseDependentRequired( + JsonNode raw, String location) { + if (!raw.isObject()) { + throw new IllegalArgumentException( + "dependentRequired must be an object at " + location); + } + Map result = new LinkedHashMap<>(); + Iterator> fields = raw.fields(); + while (fields.hasNext()) { + Map.Entry field = fields.next(); + if (!field.getValue().isArray()) { + throw new IllegalArgumentException( + "dependentRequired entry '" + field.getKey() + + "' must be an array at " + location); + } + List names = new ArrayList<>(); + for (JsonNode name : field.getValue()) { + if (!name.isTextual()) { + throw new IllegalArgumentException( + "dependentRequired entry '" + field.getKey() + + "' must contain strings at " + location); + } + names.add(name.textValue()); + } + result.put(field.getKey(), names); + } + return result; + } + + private static void recoverArray( + JsonNode raw, List parsed, String location, OpenAPI api) { + if (raw == null || !raw.isArray() || parsed == null) { + return; + } + int count = Math.min(raw.size(), parsed.size()); + for (int i = 0; i < count; i++) { + recoverChild(raw.get(i), parsed.get(i), location + "/" + i, api); + } + } + + private static void recoverMap( + JsonNode raw, Map parsed, String location, OpenAPI api) { + if (raw == null || !raw.isObject() || parsed == null) { + return; + } + Iterator> fields = raw.fields(); + while (fields.hasNext()) { + Map.Entry field = fields.next(); + Object parsedChild = parsed.get(field.getKey()); + if (parsedChild != null) { + recoverChild(field.getValue(), parsedChild, + location + "/" + pointerSegment(field.getKey()), api); + } + } + } + + private static void recoverChild( + JsonNode raw, Object parsed, String location, OpenAPI api) { + if (parsed instanceof Schema) { + Schema parsedSchema = (Schema) parsed; + // Synthetic inline-model refs need the raw object surface on the + // carrier so additionalProperties can recognize declared names. + restoreRawObjectKeywords(api, raw, parsedSchema, location); + Schema target = recoveryTarget(api, + raw != null && raw.isObject() && !raw.has("$ref"), parsedSchema); + recoverSchema(raw, target, location, api); + } + } + + static boolean hasExplicitDefault(Schema schema) { + return hasTrueExtension(schema, DEFAULT_PRESENT_EXT); + } + + static String defaultJsonOf(Schema schema) { + if (schema == null || schema.getExtensions() == null) { + return null; + } + Object value = schema.getExtensions().get(DEFAULT_JSON_EXT); + return value == null ? null : String.valueOf(value); + } + + static String examplesJsonOf(Schema schema) { + if (schema == null || schema.getExtensions() == null) { + return null; + } + Object value = schema.getExtensions().get(EXAMPLES_JSON_EXT); + return value == null ? null : String.valueOf(value); + } + static boolean hasExplicitConst(Schema schema) { + return hasTrueExtension(schema, CONST_PRESENT_EXT); + } + + static String constJsonOf(Schema schema) { + if (schema == null || schema.getExtensions() == null) { + return null; + } + Object value = schema.getExtensions().get(CONST_JSON_EXT); + return value == null ? null : String.valueOf(value); + } + + static void restoreExplicitConst(Schema schema, String constJson) { + if (schema == null) { + return; + } + addExtension(schema, CONST_PRESENT_EXT, true); + if (constJson != null) { + addExtension(schema, CONST_JSON_EXT, constJson); + } + } + + static void restorePristineTypeNull(Schema schema) { + if (schema != null) { + addExtension(schema, TYPE_NULL_EXT, true); + } + } + + + private static void addExtension(Schema schema, String key, Object value) { + if (schema.getExtensions() == null) { + schema.setExtensions(new LinkedHashMap<>()); + } + schema.addExtension(key, value); + } + + static String enumJsonOf(Schema schema) { + if (schema == null || schema.getExtensions() == null) { + return null; + } + Object value = schema.getExtensions().get(ENUM_JSON_EXT); + return value == null ? null : String.valueOf(value); + } + + static boolean isEmptyEnumMarked(Schema schema) { + return hasTrueExtension(schema, EMPTY_ENUM_EXT); + } + + static boolean pristineTypeHasNull(Schema schema) { + return hasTrueExtension(schema, TYPE_NULL_EXT); + } + + private static boolean hasTrueExtension(Schema schema, String key) { + return schema != null + && schema.getExtensions() != null + && Boolean.TRUE.equals(schema.getExtensions().get(key)); + } + + private static String countBoundExtensionName(String keyword) { + return "x-oas31-" + keyword + "-lexeme"; + } + + static String countBoundLexemeOf(Schema schema, String keyword) { + if (schema == null || schema.getExtensions() == null) { + return null; + } + Object value = schema.getExtensions().get(countBoundExtensionName(keyword)); + return value == null ? null : String.valueOf(value); + } +} diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/Oas31SchemaIrEmitter.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/Oas31SchemaIrEmitter.java new file mode 100644 index 000000000000..19d1d2bd368c --- /dev/null +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/Oas31SchemaIrEmitter.java @@ -0,0 +1,1716 @@ +package org.openapitools.codegen.languages; + +import io.swagger.v3.core.util.Json; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.media.Schema; +import org.openapitools.codegen.utils.ModelUtils; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeSet; + +/** + * Emits the densified OAS 3.1 schema IR (Oas31SchemaRegistry.h, + * schema_ir.generated.cpp, and optional schema_ir.generated.chunk*.cpp files) + * for the cpp-boost-beast client: every composition branch, extracted component, + * and structural child is densified into a flat SchemaNode registry against which + * the generated C++ evaluator validates instances exactly (original numeric + * lexemes, deep JSON enum/const stores, dynamic-scope markers, annotation + * keywords). + * + *

    All facts were collected earlier into + * {@link Oas31CompositionLowering.CompositionDescriptor}s (per-branch + * validateParams) and the post-model-extraction components map; this emitter + * never re-parses the spec. Main rows (one per branch) use direct + * {@code SchemaEvaluator} lookup; child and component rows are flattened after + * them so main-node indices stay stable. The {@code x-oas31-*} recovery + * extensions of {@link Oas31RawSpecRecovery} are honoured here (count-bound + * lexemes, pristine enum JSON and type-null markers, dependentRequired maps). + */ +final class Oas31SchemaIrEmitter { + // Spread large registries across a bounded number of compiler inputs. + private static final int TARGET_SCHEMA_IR_NODES_PER_SOURCE = 512; + private static final int MAX_SCHEMA_IR_CHUNKS = 16; + + + /** OpenAPI document this emit pass densifies. */ + private final OpenAPI openAPI; + /** Branch descriptors built by preprocessOpenAPI (template-facing maps + * live on the codegen, not here). */ + private final Map compositionDescriptors; + /** $dynamicAnchor registrations collected and consumed within this emit pass. */ + private final Map dynamicAnchorRegs = new LinkedHashMap<>(); + /** Real component names collected within this emit pass. */ + private final Set oasComponentNames = new HashSet<>(); + /** Codegen model access (reads the oas31BaseUri option). */ + private final Map additionalProperties; + /** Component validator IDs finalized after inline-model extraction. */ + private final Map componentSchemaIds; + + + // Component composition snapshot captured after model extraction to select + // the correct ref-target row form. + private final Map irComponentComposed = new HashMap<>(); + // Synthetic resource IDs whose dialect omits the validation vocabulary. + private final Set vocabInertResources = new TreeSet<>(); + + Oas31SchemaIrEmitter( + OpenAPI openAPI, + Map compositionDescriptors, + Map additionalProperties, + Map componentSchemaIds) { + this.openAPI = openAPI; + this.compositionDescriptors = compositionDescriptors; + this.additionalProperties = additionalProperties; + this.componentSchemaIds = componentSchemaIds == null + ? Collections.emptyMap() : componentSchemaIds; + } + + /** Materializes annotation fields from branch validation parameters. */ + private void readAnnotationVp( + Map vp, IrNode n) { + if (vp == null) return; + n.annTitle = strOf(vp.get("validation-ann-title")); + n.annDescription = strOf(vp.get("validation-ann-description")); + n.annDefaultJson = strOf(vp.get("validation-ann-default")); + n.annExamplesJson = strOf(vp.get("validation-ann-examples")); + n.annDeprecatedJson = strOf(vp.get("validation-ann-deprecated")); + n.annReadOnlyJson = strOf(vp.get("validation-ann-readOnly")); + n.annWriteOnlyJson = strOf(vp.get("validation-ann-writeOnly")); + n.annFormat = strOf(vp.get("validation-ann-format")); + n.annContentEncoding = strOf(vp.get("validation-ann-contentEncoding")); + n.annContentMediaType = strOf(vp.get("validation-ann-contentMediaType")); + n.annContentSchemaJson = strOf(vp.get("validation-ann-contentSchema")); + n.annComment = strOf(vp.get("validation-ann-comment")); + if ("TRUE".equals(String.valueOf(vp.get( + "validation-ann-comment-shape-violation")))) { + n.annCommentShapeViolation = true; + } + for (Map.Entry e : vp.entrySet()) { + if (String.valueOf(e.getKey()).startsWith("validation-ann-extra:")) { + n.annExtras.add(new java.util.AbstractMap.SimpleImmutableEntry<>( + String.valueOf(e.getKey()) + .substring("validation-ann-extra:".length()), + String.valueOf(e.getValue()))); + } + } + } + + private static String strOf(Object o) { + return o == null ? "" : String.valueOf(o); + } + + /** Reads annotations directly from a structural or component schema row. */ + private void readAnnotationRaw( + io.swagger.v3.oas.models.media.Schema schema, IrNode n) { + if (schema == null) return; + final IrNode node = n; + Oas31SchemaSurfaceAssertionScanner.readAnnotationKeywords(schema, (key, value) -> { + switch (key) { + case "title": + node.annTitle = String.valueOf(value); + break; + case "description": + node.annDescription = String.valueOf(value); + break; + case "default": + node.annDefaultJson = String.valueOf(value); + break; + case "examples": + node.annExamplesJson = String.valueOf(value); + break; + case "deprecated": + node.annDeprecatedJson = String.valueOf(value); + break; + case "readOnly": + node.annReadOnlyJson = String.valueOf(value); + break; + case "writeOnly": + node.annWriteOnlyJson = String.valueOf(value); + break; + case "format": + node.annFormat = String.valueOf(value); + break; + case "contentEncoding": + node.annContentEncoding = String.valueOf(value); + break; + case "contentMediaType": + node.annContentMediaType = String.valueOf(value); + break; + case "contentSchema": + node.annContentSchemaJson = String.valueOf(value); + break; + case "comment": + node.annComment = String.valueOf(value); + break; + case "comment-shape-violation": + node.annCommentShapeViolation = true; + break; + default: + if (key.startsWith("extra:")) { + node.annExtras.add( + new java.util.AbstractMap.SimpleImmutableEntry<>( + key.substring("extra:".length()), + String.valueOf(value))); + } + } + }); + } + + /** + * Densify the current components map + branch descriptors into the combined + * SchemaNode registry and its generated header, coordinator, optional source + * chunks, and validation dispatch. + */ + Map produce(Map objs) { + // Snapshot post-extraction components so raw-schema rows can distinguish + // composed targets from plain extracted targets. InlineModelResolver may + // replace a schema subtree with a component ref; densifying the current + // component content restores the moved semantics. + irComponentComposed.clear(); + if (openAPI != null && openAPI.getComponents() != null + && openAPI.getComponents().getSchemas() != null) { + for (String name : openAPI.getComponents().getSchemas().keySet()) { + Schema compSchema = openAPI.getComponents().getSchemas().get(name); + if (compSchema == null) continue; + boolean composed = (compSchema.getOneOf() != null && !compSchema.getOneOf().isEmpty()) + || (compSchema.getAnyOf() != null && !compSchema.getAnyOf().isEmpty()) + || (compSchema.getAllOf() != null && !compSchema.getAllOf().isEmpty()); + irComponentComposed.put(name, composed); + } + // Dynamic-ref decoding must recognize real specification component + // names while main rows are built. Otherwise wrapper refs are decoded + // against an empty component set and degrade to static refs. + oasComponentNames.addAll( + openAPI.getComponents().getSchemas().keySet()); + } + + List mainNodes = new ArrayList<>(); + for (Oas31CompositionLowering.CompositionDescriptor desc : compositionDescriptors.values()) { + if (desc == null || desc.getBranches() == null) { + continue; + } + for (Oas31CompositionLowering.CompositionBranchDescriptor branch : desc.getBranches()) { + IrNode node = irNodeFromBranch(branch); + if (node != null) { + assignSchemaPaths(node, "#/components/schemas/" + + jsonPointerToken(desc.getSchemaName()) + "/" + + desc.getKeyword() + "/" + branch.getBranchIndex()); + mainNodes.add(node); + } + } + } + // Deterministic ordering by validate_ so output is stable across runs. + mainNodes.sort(Comparator.comparing(n -> n.validatorId)); + + // Flatten every structural child into extra registry rows after the main + // validator rows, keeping main indices stable. Child rows have no direct + // validate_ dispatch and are reached only through SchemaNode fields. + // Breadth-first identity deduplication keeps row ordering deterministic. + List extraNodes = new ArrayList<>(); + java.util.ArrayDeque queue = new java.util.ArrayDeque<>(); + java.util.Set visitedChildren = java.util.Collections.newSetFromMap( + new java.util.IdentityHashMap()); + + // Inline-model extraction moves schema subtrees into components and leaves + // refs at their original sites. Densify those components after the main + // and structural-child rows so every ref resolves without disturbing main + // indices. Composed components receive wrapper rows that retain the full + // applicator rather than aliasing the first branch. + List componentRows = new ArrayList<>(); + if (openAPI != null && openAPI.getComponents() != null + && openAPI.getComponents().getSchemas() != null) { + java.util.List names = new ArrayList<>( + openAPI.getComponents().getSchemas().keySet()); + java.util.Collections.sort(names); + for (String name : names) { + // Only authored component names can carry the normalized + // dynamic-reference wrapper convention. + oasComponentNames.add(name); + Schema compSchema = openAPI.getComponents().getSchemas().get(name); + if (compSchema == null) continue; + IrNode row = irNodeFromRawSchema( + compSchema, CppBoostBeastModelCodegen.componentSchemaId( + name, componentSchemaIds)); + if (row != null) { + assignSchemaPaths(row, "#/components/schemas/" + jsonPointerToken(name)); + componentRows.add(row); + } + } + } + + // Seed flattening from main nodes and component rows so all structural + // children are present before references are resolved. + java.util.List seeds = new ArrayList<>(mainNodes); + seeds.addAll(componentRows); + for (IrNode seed : seeds) { + for (IrNode c : structuralChildren(seed)) { + if (visitedChildren.add(c)) queue.add(c); + } + } + while (!queue.isEmpty()) { + IrNode c = queue.poll(); + extraNodes.add(c); + for (IrNode g : structuralChildren(c)) { + if (visitedChildren.add(g)) queue.add(g); + } + } + visitedChildren.addAll(componentRows); + visitedChildren.addAll(mainNodes); + + List allRows = new ArrayList<>(mainNodes); + allRows.addAll(extraNodes); + allRows.addAll(componentRows); + + // Identity-keyed index map over the COMBINED registry rows. + java.util.Map indexOf = new java.util.IdentityHashMap<>(); + for (int i = 0; i < allRows.size(); i++) { + indexOf.put(allRows.get(i), i); + } + // Resolve refs against main, structural-child, and component rows. + java.util.Map idIndex = new java.util.HashMap<>(); + for (int i = 0; i < allRows.size(); i++) { + String vid = allRows.get(i).validatorId; + if (vid != null) idIndex.putIfAbsent(vid, i); + } + + // Resolve child and reference indices after every row is numbered. + for (IrNode n : allRows) { + if (n.notChild != null) { + Integer idx = indexOf.get(n.notChild); + if (idx != null) n.notSchemaIndex = idx; + } + if (n.additionalSchemaChild != null) { + Integer idx = indexOf.get(n.additionalSchemaChild); + if (idx != null) n.additionalSchemaIndex = idx; + } + if (n.itemsChild != null) { + Integer idx = indexOf.get(n.itemsChild); + if (idx != null) n.itemsIndex = idx; + } + if (n.unevaluatedSchemaChild != null) { + Integer idx = indexOf.get(n.unevaluatedSchemaChild); + if (idx != null) n.unevaluatedSchemaIndex = idx; + } + if (n.propertyNamesChild != null) { + Integer idx = indexOf.get(n.propertyNamesChild); + if (idx != null) n.propertyNamesIndex = idx; + } + for (IrNode.PatternSchema pb : n.patternProperties) { + if (pb.child != null) { + Integer idx = indexOf.get(pb.child); + if (idx != null) pb.index = idx; + } + } + for (IrNode.PropertySchema pb : n.properties) { + if (pb.child != null) { + Integer idx = indexOf.get(pb.child); + if (idx != null) pb.index = idx; + } + } + for (int i = 0; i < n.prefixItems.size(); i++) { + Integer idx = indexOf.get(n.prefixItems.get(i)); + if (idx != null) n.prefixItemIndices.add(idx); + else n.prefixItemIndices.add(-1); + } + for (int i = 0; i < n.applicatorChildren.size(); i++) { + Integer idx = indexOf.get(n.applicatorChildren.get(i)); + if (idx != null) n.applicatorChildIndices.add(idx); + else n.applicatorChildIndices.add(-1); + } + resolveChildList(n.allOfChildren, n.allOfChildIndices, indexOf); + resolveChildList(n.anyOfChildren, n.anyOfChildIndices, indexOf); + resolveChildList(n.oneOfChildren, n.oneOfChildIndices, indexOf); + if (n.unevaluatedItemsSchemaChild != null) { + Integer idx = indexOf.get(n.unevaluatedItemsSchemaChild); + if (idx != null) n.unevaluatedItemsSchemaIndex = idx; + } + if (n.containsChild != null) { + Integer idx = indexOf.get(n.containsChild); + if (idx != null) n.containsIndex = idx; + } + if (n.ifChild != null) { + Integer idx = indexOf.get(n.ifChild); + if (idx != null) n.ifIndex = idx; + } + if (n.thenChild != null) { + Integer idx = indexOf.get(n.thenChild); + if (idx != null) n.thenIndex = idx; + } + if (n.elseChild != null) { + Integer idx = indexOf.get(n.elseChild); + if (idx != null) n.elseIndex = idx; + } + for (IrNode.DependentSchema d : n.dependentSchemas) { + if (d.child != null) { + Integer idx = indexOf.get(d.child); + if (idx != null) d.index = idx; + } + } + if (n.isRef && n.refTargetId != null) { + Integer idx = idIndex.get(n.refTargetId); + if (idx != null) { + n.refTargetIndex = idx; + } else if (n.selfRef) { + n.refTargetIndex = indexOf.get(n).intValue(); + } else { + throw new CppBoostBeastClientCodegen.UnsupportedSchemaAssertionException( + n.validatorId, "unresolved $ref target '" + n.refTargetId + "'"); + } + } + } + // Resolve dynamic-anchor registrations after row numbering is final. + // Wrapper registrations target their first composed child. + for (DynamicAnchorReg reg : dynamicAnchorRegs.values()) { + if (reg.self) { + Integer idx = indexOf.get(reg.node); + reg.row = idx != null ? idx.intValue() : -1; + } else { + reg.row = (reg.node.oneOfChildIndices != null + && reg.node.oneOfChildIndices.size() > 0) + ? reg.node.oneOfChildIndices.get(0).intValue() : -1; + } + } + + vocabInertResources.clear(); + for (IrNode node : allRows) { + if (node.dialectValidationInert) { + vocabInertResources.add(node.dynamicResource); + } + } + + Oas31SchemaIrRenderer renderer = new Oas31SchemaIrRenderer( + openAPI, additionalProperties, dynamicAnchorRegs, vocabInertResources); + + int chunkCount = schemaIrChunkCount(allRows.size()); + java.util.List> chunkFiles = new ArrayList<>(); + if (chunkCount == 0) { + objs.put("oas31SchemaIrSource", + renderer.buildSchemaIrSource(allRows, mainNodes.size(), 0, allRows.size(), -1)); + } else { + int chunkSize = (allRows.size() + chunkCount - 1) / chunkCount; + for (int chunk = 0; chunk < chunkCount; chunk++) { + int start = chunk * chunkSize; + int end = Math.min(allRows.size(), start + chunkSize); + objs.put("oas31SchemaIrChunk" + chunk + "Source", + renderer.buildSchemaIrSource(allRows, mainNodes.size(), start, end, chunk)); + java.util.Map chunkFile = new LinkedHashMap<>(); + chunkFile.put("filename", schemaIrChunkFilename(chunk)); + chunkFiles.add(chunkFile); + } + objs.put("oas31SchemaIrSource", + renderer.buildSchemaIrCoordinatorSource(allRows, mainNodes.size(), chunkCount)); + } + objs.put("oas31SchemaIrChunkCount", chunkCount); + objs.put("oas31SchemaIrChunkFiles", chunkFiles); + objs.put("oas31SchemaIrHeader", renderer.buildSchemaIrHeader(allRows)); + return objs; + } + + private static int schemaIrChunkCount(int nodeCount) { + if (nodeCount <= TARGET_SCHEMA_IR_NODES_PER_SOURCE) { + return 0; + } + int required = (nodeCount + TARGET_SCHEMA_IR_NODES_PER_SOURCE - 1) + / TARGET_SCHEMA_IR_NODES_PER_SOURCE; + return Math.min(required, MAX_SCHEMA_IR_CHUNKS); + } + + static String schemaIrChunkFilename(int chunk) { + return "schema_ir.generated.chunk" + chunk + ".cpp"; + } + + static String schemaIrChunkTemplate(int chunk) { + return "oas31_schema_ir_chunk" + chunk + ".mustache"; + } + + /** Ordered structural children of a node (BFS source, no duplicates). */ + private static java.util.List structuralChildren(IrNode n) { + java.util.List out = new ArrayList<>(); + if (n.notChild != null) out.add(n.notChild); + if (n.additionalSchemaChild != null) out.add(n.additionalSchemaChild); + if (n.itemsChild != null) out.add(n.itemsChild); + if (n.unevaluatedSchemaChild != null) out.add(n.unevaluatedSchemaChild); + if (n.unevaluatedItemsSchemaChild != null) out.add(n.unevaluatedItemsSchemaChild); + if (n.containsChild != null) out.add(n.containsChild); + if (n.ifChild != null) out.add(n.ifChild); + if (n.thenChild != null) out.add(n.thenChild); + if (n.elseChild != null) out.add(n.elseChild); + for (IrNode.DependentSchema d : n.dependentSchemas) { + if (d.child != null) out.add(d.child); + } + if (n.propertyNamesChild != null) out.add(n.propertyNamesChild); + for (IrNode.PatternSchema pb : n.patternProperties) { + if (pb.child != null) out.add(pb.child); + } + out.addAll(n.prefixItems); + out.addAll(n.applicatorChildren); + out.addAll(n.allOfChildren); + out.addAll(n.anyOfChildren); + out.addAll(n.oneOfChildren); + for (IrNode.PropertySchema pb : n.properties) { + if (pb.child != null) out.add(pb.child); + } + return out; + } + + /** Stamps each structural row with its authored JSON Pointer location. */ + private static void assignSchemaPaths(IrNode root, String rootPath) { + java.util.Set visited = java.util.Collections.newSetFromMap( + new java.util.IdentityHashMap()); + assignSchemaPaths(root, rootPath, visited); + } + + private static void assignSchemaPaths( + IrNode node, String path, java.util.Set visited) { + if (node == null || !visited.add(node)) { + return; + } + node.schemaPath = path; + assignSchemaPaths(node.notChild, path + "/not", visited); + assignSchemaPaths(node.additionalSchemaChild, + path + "/additionalProperties", visited); + assignSchemaPaths(node.itemsChild, path + "/items", visited); + assignSchemaPaths(node.unevaluatedSchemaChild, + path + "/unevaluatedProperties", visited); + assignSchemaPaths(node.unevaluatedItemsSchemaChild, + path + "/unevaluatedItems", visited); + assignSchemaPaths(node.containsChild, path + "/contains", visited); + assignSchemaPaths(node.propertyNamesChild, path + "/propertyNames", visited); + assignSchemaPaths(node.ifChild, path + "/if", visited); + assignSchemaPaths(node.thenChild, path + "/then", visited); + assignSchemaPaths(node.elseChild, path + "/else", visited); + for (IrNode.PropertySchema child : node.properties) { + assignSchemaPaths(child.child, + path + "/properties/" + jsonPointerToken(child.name), visited); + } + for (IrNode.PatternSchema child : node.patternProperties) { + assignSchemaPaths(child.child, + path + "/patternProperties/" + jsonPointerToken(child.regex), visited); + } + for (int i = 0; i < node.prefixItems.size(); ++i) { + assignSchemaPaths(node.prefixItems.get(i), path + "/prefixItems/" + i, visited); + } + assignIndexedSchemaPaths(node.allOfChildren, path + "/allOf", visited); + assignIndexedSchemaPaths(node.anyOfChildren, path + "/anyOf", visited); + assignIndexedSchemaPaths(node.oneOfChildren, path + "/oneOf", visited); + for (IrNode.DependentSchema child : node.dependentSchemas) { + assignSchemaPaths(child.child, + path + "/dependentSchemas/" + jsonPointerToken(child.name), visited); + } + } + + private static void assignIndexedSchemaPaths( + java.util.List children, + String path, + java.util.Set visited) { + for (int i = 0; i < children.size(); ++i) { + assignSchemaPaths(children.get(i), path + "/" + i, visited); + } + } + + private static String jsonPointerToken(String value) { + return value.replace("~", "~0").replace("/", "~1"); + } + + /** Resolve an IrNode child list to its combined-registry row indices. */ + private static void resolveChildList(java.util.List children, + java.util.List indices, + java.util.Map indexOf) { + for (IrNode c : children) { + Integer idx = indexOf.get(c); + indices.add(idx != null ? idx : -1); + } + } + + /** + * A row of the $dynamicAnchor registration map. Entries are filled during + * IR node building and resolved to concrete registry rows once numbering + * is final (self=false entries target the wrapper's FIRST composed child + * — the runner single-branch oneOf content). + */ + static final class DynamicAnchorReg { + final IrNode node; + final int resource; + final String name; + final boolean self; + int row = -1; + DynamicAnchorReg(IrNode node, int resource, String name, boolean self) { + this.node = node; this.resource = resource; this.name = name; this.self = self; + } + } + + /** A single densified SchemaNode to emit, from one composition branch. */ + static final class IrNode { + String validatorId; + String resolvedName; + int typeFlags = 0; + boolean hasType = false; + BooleanValueKind booleanValue = BooleanValueKind.NOT_BOOLEAN; + String minimum = null; + String maximum = null; + String exclusiveMinimum = null; + String exclusiveMaximum = null; + String multipleOf = null; + java.util.List enumNumbers = new ArrayList<>(); + java.util.List enumStrings = new ArrayList<>(); + java.util.List enumBooleans = new ArrayList<>(); + String constNumber = null; + String constString = null; + Boolean constBool = null; + boolean hasConst = false; + + // -- Exact equality, not, uniqueItems, and reference state -- + String constJson = null; // serialized JSON literal for the FULL const value + String enumJson = null; // serialized JSON array literal for ALL enum members + boolean hasUniqueItems = false; + boolean uniqueItemsSeen = false; // keyword PRESENT (true OR false) — false is a no-op + IrNode notChild = null; // child row for a `not` subschema + int notSchemaIndex = -1; // resolved combined-registry index of notChild + boolean isRef = false; // this row references another component + String refTargetId = null; // validatorId of the ref target + int refTargetIndex = -1; // resolved combined-registry index; -1 => unresolved (inline) + // -- Dynamic scope ($dynamicRef / $dynamicAnchor) -- + // Normalized resource markers assign scope identities and static fallbacks. + // A resource-root row pushes a scope frame during validation. + int dynamicResource = 0; + boolean resourceRoot = false; + String dynamicAnchorName = null; + String dynamicRefAnchor = null; + // Validation keywords are inert in resources whose dialect omits the + // validation vocabulary. + boolean dialectValidationInert = false; + + // -- Object structure -- + static final class PropertySchema { + String name; + IrNode child; + int index = -1; // resolved registry row of child + } + boolean hasObjectSchema = false; + java.util.List properties = new ArrayList<>(); + java.util.List required = new ArrayList<>(); + String additionalPropertiesKind = "absent"; // absent|allowed|reject|schema + IrNode additionalSchemaChild = null; + int additionalSchemaIndex = -1; + String minPropertiesLexeme = null; boolean minPropertiesPresent = false; + String maxPropertiesLexeme = null; boolean maxPropertiesPresent = false; + + // -- String constraints -- + String minLengthLexeme = null; boolean minLengthPresent = false; + String maxLengthLexeme = null; boolean maxLengthPresent = false; + String patternLexeme = null; boolean patternPresent = false; + + // -- patternProperties / propertyNames -- + static final class PatternSchema { + String regex; + IrNode child; + int index = -1; + } + java.util.List patternProperties = new ArrayList<>(); + IrNode propertyNamesChild = null; + int propertyNamesIndex = -1; + + // -- Array structure -- + java.util.List prefixItems = new ArrayList<>(); + java.util.List prefixItemIndices = new ArrayList<>(); + IrNode itemsChild = null; + int itemsIndex = -1; + String minItemsLexeme = null; boolean minItemsPresent = false; + String maxItemsLexeme = null; boolean maxItemsPresent = false; + + // -- Composition applicators -- + String applicatorKind = null; // legacy single-keyword hint + java.util.List applicatorChildren = new ArrayList<>(); + java.util.List applicatorChildIndices = new ArrayList<>(); + // allOf, anyOf, and oneOf may coexist on one schema. + java.util.List allOfChildren = new ArrayList<>(); + java.util.List allOfChildIndices = new ArrayList<>(); + java.util.List anyOfChildren = new ArrayList<>(); + java.util.List anyOfChildIndices = new ArrayList<>(); + java.util.List oneOfChildren = new ArrayList<>(); + java.util.List oneOfChildIndices = new ArrayList<>(); + + // -- unevaluatedProperties -- + boolean unevaluatedPropertiesPresent = false; + boolean unevaluatedPropertiesRejects = false; + IrNode unevaluatedSchemaChild = null; + int unevaluatedSchemaIndex = -1; + + // -- unevaluatedItems -- + boolean unevaluatedItemsPresent = false; + boolean unevaluatedItemsRejects = false; + IrNode unevaluatedItemsSchemaChild = null; + int unevaluatedItemsSchemaIndex = -1; + + // -- contains family -- + IrNode containsChild = null; // `contains` subschema row + int containsIndex = -1; + String minContainsLexeme = null; boolean minContainsPresent = false; + String maxContainsLexeme = null; boolean maxContainsPresent = false; + + // -- if / then / else -- + IrNode ifChild = null; + int ifIndex = -1; + IrNode thenChild = null; + int thenIndex = -1; + IrNode elseChild = null; + int elseIndex = -1; + + // -- dependentSchemas -- + static final class DependentSchema { + String name; + IrNode child; + int index = -1; // resolved registry row of child + } + java.util.List dependentSchemas = new ArrayList<>(); + // -- dependentRequired property prerequisites -- + static final class DependentRequiredEntry { + String name; + java.util.List required = new ArrayList<>(); + } + java.util.List dependentRequired = new ArrayList<>(); + boolean selfRef = false; // $ref resolves to THIS node (self/root ref) + + // -- Annotation keywords (JSON-text values; empty means absent) -- + // $comment is shape-checked but never produces annotation output. + String schemaPath = ""; + String annTitle = ""; + String annDescription = ""; + String annDefaultJson = ""; + String annExamplesJson = ""; + String annDeprecatedJson = ""; + String annReadOnlyJson = ""; + String annWriteOnlyJson = ""; + String annFormat = ""; + String annContentEncoding = ""; + String annContentMediaType = ""; + String annContentSchemaJson = ""; + String annComment = ""; + boolean annCommentShapeViolation = false; + java.util.List> annExtras = + new ArrayList<>(); + + /** Deterministic child-row id suffix counter (per node). */ + private int childCounter = 0; + + /** Build a deterministic child validatorId under this node. */ + String childId(String tag) { + childCounter += 1; + return validatorId + "_" + tag + childCounter; + } + } + + enum BooleanValueKind { + NOT_BOOLEAN, TRUE, FALSE + } + + /** + * Builds an IR node from one branch's validateParams; null when nothing + * to emit. + */ + private IrNode irNodeFromBranch(Oas31CompositionLowering.CompositionBranchDescriptor branch) { + IrNode n = new IrNode(); + n.validatorId = branch.getValidatorId(); + n.resolvedName = branch.getResolvedSchemaName() != null + ? branch.getResolvedSchemaName() : "schema"; + if (n.validatorId == null || n.validatorId.isEmpty()) { + return null; + } + Map vp = branch.getValidateParams(); + if (vp == null) { + vp = Collections.emptyMap(); + } + + // type / type-array -> typeFlags + Object otype = vp.get("validation-type"); + if (otype != null) { + n.hasType = true; + if ("type-array".equals(otype)) { + Object arr = vp.get("validation-type-array"); + if (arr instanceof java.util.List) { + for (Object t : (java.util.List) arr) { + n.typeFlags |= jsonTypeBit(String.valueOf(t)); + } + } + } else { + n.typeFlags |= jsonTypeBit(String.valueOf(otype)); + } + } + + // boolean value-schema + Object obool = vp.get("validation-boolean-value"); + if (obool != null) { + n.booleanValue = Boolean.TRUE.equals(obool) + ? BooleanValueKind.TRUE : BooleanValueKind.FALSE; + } + + n.minimum = lexemeOf(vp.get("validation-min")); + n.maximum = lexemeOf(vp.get("validation-max")); + n.exclusiveMinimum = lexemeOf(vp.get("validation-exclusive-min")); + n.exclusiveMaximum = lexemeOf(vp.get("validation-exclusive-max")); + n.multipleOf = lexemeOf(vp.get("validation-multiple-of")); + + // enum (partitioned by predominant kind, mirroring the hand template) + if (vp.containsKey("has-validation-enum")) { + Object kind = vp.get("validation-enum-kind"); + Object vals = vp.get("validation-enum-values"); + if (vals instanceof java.util.List) { + for (Object v : (java.util.List) vals) { + String sv = String.valueOf(v); // already escaped for strings + if ("integer".equals(kind) || "number".equals(kind)) { + n.enumNumbers.add(sv); + } else if ("bool".equals(kind)) { + n.enumBooleans.add(sv); + } else { + n.enumStrings.add(sv); + } + } + } + } + + // const (partitioned by kind) + if (vp.containsKey("has-validation-const")) { + String ctype = String.valueOf(vp.get("validation-const-type")); + Object cval = vp.get("validation-const-value"); + if ("number".equals(ctype)) { + n.constNumber = lexemeOf(cval); + } else if ("boolean".equals(ctype)) { + n.constBool = Boolean.valueOf(String.valueOf(cval)); + } else { + n.constString = cval != null ? String.valueOf(cval) : null; + } + } + + // Preserve full JSON const and enum values captured by the assertion scan + // so deep equality works across every JSON kind, including explicit null. + Object pristineConstJson = vp.get("validation-const-json"); + if (pristineConstJson instanceof String) { + n.hasConst = true; + n.constJson = (String) pristineConstJson; + } else { + Object constRaw = vp.get("validation-const-raw"); + // Non-number consts use the deep JSON store. Numbers stay on the exact + // scalar path so values beyond uint64/double never lose precision. + if (constRaw != null && !(constRaw instanceof Number)) { + n.hasConst = true; + n.constJson = toJsonLiteral(constRaw); + } + } + Object enumRaw = vp.get("validation-enum-raw"); + Object pristineEnumJson = vp.get("validation-enum-json"); + if (pristineEnumJson instanceof String) { + n.enumJson = (String) pristineEnumJson; + // The pristine deep store is authoritative and preserves every kind + // and numeric lexeme, so discard parser-inferred scalar buckets. + n.enumNumbers = new ArrayList<>(); + n.enumStrings = new ArrayList<>(); + n.enumBooleans = new ArrayList<>(); + } else if (enumRaw instanceof java.util.List) { + java.util.List list = (java.util.List) enumRaw; + if (!list.isEmpty()) { + n.enumJson = toJsonLiteral(list); + } else { + // An empty enum is a reject-all constraint, so keep an explicit + // empty deep store rather than treating the keyword as absent. + n.enumJson = "[]"; + } + // Rebuild every scalar bucket from each member's actual JSON kind. + // The descriptor's predominant-kind view exists for legacy template + // metadata and cannot represent legal mixed-kind JSON Schema enums. + n.enumNumbers = new ArrayList<>(); + n.enumStrings = new ArrayList<>(); + n.enumBooleans = new ArrayList<>(); + for (Object m : list) { + if (m instanceof Number) { + n.enumNumbers.add(m.toString()); + } else if (m instanceof Boolean) { + n.enumBooleans.add(m.toString()); + } else if (m instanceof String) { + n.enumStrings.add(CppBoostBeastClientCodegen + .escapeCppStringContent((String) m)); + } else if (m instanceof com.fasterxml.jackson.databind.JsonNode) { + com.fasterxml.jackson.databind.JsonNode jsonMember = + (com.fasterxml.jackson.databind.JsonNode) m; + if (jsonMember.isNumber()) { + n.enumNumbers.add(jsonMember.asText()); + } else if (jsonMember.isBoolean()) { + n.enumBooleans.add(Boolean.toString(jsonMember.asBoolean())); + } else if (jsonMember.isTextual()) { + n.enumStrings.add(CppBoostBeastClientCodegen + .escapeCppStringContent(jsonMember.asText())); + } + } + } + } + + // uniqueItems is presence-sensitive; false is an explicit no-op. + if (vp.containsKey("validation-unique-items")) { + n.uniqueItemsSeen = true; + n.hasUniqueItems = Boolean.TRUE.equals(vp.get("validation-unique-items")); + } + + // Build the `not` child now and resolve its row index later. + Object notSchemaObj = vp.get("validation-not-schema"); + if (notSchemaObj instanceof Schema) { + n.notChild = irNodeFromRawSchema((Schema) notSchemaObj, n.validatorId + "_not"); + } + + // Resolve $ref targets after all component rows exist. + Object refObj = vp.get("validation-ref"); + if (refObj != null) { + n.isRef = true; + n.refTargetId = refTargetIdOf(String.valueOf(refObj)); + // Dynamic-ref anchor identity can ride in the rewritten target name; + // parser normalization may drop sibling extension markers. + String dynAnchor = dynamicRefAnchorOf(String.valueOf(refObj)); + if (dynAnchor != null && n.dynamicRefAnchor == null) { + n.dynamicRefAnchor = dynAnchor; + } + } + + // Dynamic-scope fields collected during branch scanning. + Object dynRes = vp.get("validation-dynamic-resource"); + if (dynRes instanceof Number) { + n.dynamicResource = ((Number) dynRes).intValue(); + } + if (Boolean.TRUE.equals(vp.get("validation-resource-root"))) { + n.resourceRoot = true; + } + if (Boolean.TRUE.equals(vp.get("validation-vocab-inert"))) { + n.dialectValidationInert = true; + } + Object dynRef = vp.get("validation-dynamic-ref-anchor"); + if (dynRef != null) { + n.dynamicRefAnchor = String.valueOf(dynRef); + if (!n.isRef && n.refTargetId == null) { + // a $dynamicRef whose static target failed to rewrite stays a + // pure marker node (still materialised + fail-closed later). + n.isRef = true; + n.refTargetId = "__unresolved_dynamic_ref"; + } + } + Object dynAnchor = vp.get("validation-dynamic-anchor"); + if (dynAnchor != null) { + n.dynamicAnchorName = String.valueOf(dynAnchor); + // Self-registration: the anchor decl sits ON this row. Later + // wrapper-level registrations (x-oas31-dyanchor) override it. + dynamicAnchorRegs.put(n.dynamicResource + "\u0000" + n.dynamicAnchorName, + new DynamicAnchorReg(n, n.dynamicResource, n.dynamicAnchorName, true)); + } + + // Structural assertions from the restricted branch scan are densified + // into child registry rows through irNodeFromRawSchema. + Object propsObj = vp.get("validation-properties"); + if (propsObj instanceof java.util.Map && !((java.util.Map) propsObj).isEmpty()) { + n.hasObjectSchema = true; + java.util.Map pm = (java.util.Map) propsObj; + java.util.List names = new ArrayList<>(); + for (Object k : pm.keySet()) names.add(String.valueOf(k)); + java.util.Collections.sort(names); // deterministic emission order + for (String name : names) { + Object ps = pm.get(name); + if (ps instanceof Schema) { + IrNode.PropertySchema pb = new IrNode.PropertySchema(); + pb.name = name; + pb.child = irNodeFromRawSchema((Schema) ps, n.childId("prop")); + n.properties.add(pb); + } + } + } + Object reqObj = vp.get("validation-required"); + if (reqObj instanceof java.util.List) { + for (Object r : (java.util.List) reqObj) { + n.required.add(String.valueOf(r)); + } + if (!n.required.isEmpty()) n.hasObjectSchema = true; + } + String apKind = (String) vp.get("validation-additional-properties-kind"); + if (apKind != null && !"absent".equals(apKind)) { + n.additionalPropertiesKind = apKind; + n.hasObjectSchema = true; + if ("schema".equals(apKind)) { + Object s = vp.get("validation-additional-properties-schema"); + if (s instanceof Schema) { + n.additionalSchemaChild = irNodeFromRawSchema( + (Schema) s, n.childId("addprops")); + } + } + } + if (vp.containsKey("validation-min-properties")) { + n.minPropertiesLexeme = lexemeOf(vp.get("validation-min-properties")); + n.minPropertiesPresent = n.minPropertiesLexeme != null; + n.hasObjectSchema = true; + } + if (vp.containsKey("validation-max-properties")) { + n.maxPropertiesLexeme = lexemeOf(vp.get("validation-max-properties")); + n.maxPropertiesPresent = n.maxPropertiesLexeme != null; + n.hasObjectSchema = true; + } + Object piObj = vp.get("validation-prefix-items"); + if (piObj instanceof java.util.List) { + for (Object s : (java.util.List) piObj) { + if (s instanceof Schema) { + n.prefixItems.add(irNodeFromRawSchema((Schema) s, n.childId("pi"))); + } else if (s instanceof Boolean) { + n.prefixItems.add(booleanValueSchema((Boolean) s, n.childId("pib"))); + } + } + } + Object itemsObj = vp.get("validation-items"); + if (itemsObj instanceof Schema) { + n.itemsChild = irNodeFromRawSchema((Schema) itemsObj, n.childId("items")); + } else if (itemsObj instanceof Boolean) { + n.itemsChild = booleanValueSchema((Boolean) itemsObj, n.childId("items")); + } + if (vp.containsKey("validation-min-items")) { + n.minItemsLexeme = lexemeOf(vp.get("validation-min-items")); + n.minItemsPresent = n.minItemsLexeme != null; + } + if (vp.containsKey("validation-max-items")) { + n.maxItemsLexeme = lexemeOf(vp.get("validation-max-items")); + n.maxItemsPresent = n.maxItemsLexeme != null; + } + // String constraints; branch scanning already escaped the pattern. + if (vp.containsKey("validation-min-length")) { + n.minLengthLexeme = lexemeOf(vp.get("validation-min-length")); + n.minLengthPresent = n.minLengthLexeme != null; + } + if (vp.containsKey("validation-max-length")) { + n.maxLengthLexeme = lexemeOf(vp.get("validation-max-length")); + n.maxLengthPresent = n.maxLengthLexeme != null; + } + if (vp.containsKey("validation-pattern")) { + n.patternLexeme = String.valueOf(vp.get("validation-pattern")); + n.patternPresent = n.patternLexeme != null && !n.patternLexeme.isEmpty(); + } + Object ppObj = vp.get("validation-pattern-properties"); + if (ppObj instanceof java.util.Map) { + java.util.Map ppm = (java.util.Map) ppObj; + java.util.List ppNames = new ArrayList<>(); + for (Object k : ppm.keySet()) ppNames.add(String.valueOf(k)); + java.util.Collections.sort(ppNames); + for (String ppName : ppNames) { + Object ps = ppm.get(ppName); + if (ps instanceof Schema) { + IrNode.PatternSchema pb = new IrNode.PatternSchema(); + pb.regex = ppName; + pb.child = irNodeFromRawSchema((Schema) ps, n.childId("pp")); + n.patternProperties.add(pb); + } + } + } + Object pnObj = vp.get("validation-property-names"); + if (pnObj instanceof Schema) { + n.propertyNamesChild = irNodeFromRawSchema((Schema) pnObj, n.childId("pn")); + } else if (pnObj instanceof Boolean) { + n.propertyNamesChild = booleanValueSchema((Boolean) pnObj, n.childId("pn")); + } + // allOf, anyOf, and oneOf applicators may coexist. + Object allOfList = vp.get("validation-allof-schemas"); + if (allOfList instanceof java.util.List) { + n.applicatorKind = "allOf"; + for (Object s : (java.util.List) allOfList) { + if (s instanceof Schema) { + n.allOfChildren.add( + irNodeFromRawSchema((Schema) s, n.childId("app"))); + } else if (s instanceof Boolean) { + n.allOfChildren.add( + booleanValueSchema((Boolean) s, n.childId("app"))); + } + } + } + Object anyOfList = vp.get("validation-anyof-schemas"); + if (anyOfList instanceof java.util.List) { + if (n.applicatorKind == null) n.applicatorKind = "anyOf"; + for (Object s : (java.util.List) anyOfList) { + if (s instanceof Schema) { + n.anyOfChildren.add( + irNodeFromRawSchema((Schema) s, n.childId("app"))); + } else if (s instanceof Boolean) { + n.anyOfChildren.add( + booleanValueSchema((Boolean) s, n.childId("app"))); + } + } + } + Object oneOfList = vp.get("validation-oneof-schemas"); + if (oneOfList instanceof java.util.List) { + if (n.applicatorKind == null) n.applicatorKind = "oneOf"; + for (Object s : (java.util.List) oneOfList) { + if (s instanceof Schema) { + n.oneOfChildren.add( + irNodeFromRawSchema((Schema) s, n.childId("app"))); + } else if (s instanceof Boolean) { + n.oneOfChildren.add( + booleanValueSchema((Boolean) s, n.childId("app"))); + } + } + } + Object unevalItemsObj = vp.get("validation-unevaluated-items"); + if (unevalItemsObj != null) { + n.unevaluatedItemsPresent = true; + if (unevalItemsObj instanceof Schema) { + Schema us = (Schema) unevalItemsObj; + Boolean bv = us.getBooleanSchemaValue(); + if (bv != null) { + n.unevaluatedItemsRejects = !Boolean.TRUE.equals(bv); + } else { + n.unevaluatedItemsSchemaChild = + irNodeFromRawSchema(us, n.childId("uneval")); + } + } else if (unevalItemsObj instanceof Boolean) { + n.unevaluatedItemsRejects = + !Boolean.TRUE.equals(unevalItemsObj); + } + } + Object ifObj = vp.get("validation-if"); + if (ifObj instanceof Schema) { + n.ifChild = irNodeFromRawSchema((Schema) ifObj, n.childId("if")); + } + Object thenObj = vp.get("validation-then"); + if (thenObj instanceof Schema) { + n.thenChild = irNodeFromRawSchema((Schema) thenObj, n.childId("then")); + } + Object elseObj = vp.get("validation-else"); + if (elseObj instanceof Schema) { + n.elseChild = irNodeFromRawSchema((Schema) elseObj, n.childId("else")); + } + Object depObj = vp.get("validation-dependent-schemas"); + if (depObj instanceof java.util.Map) { + for (java.util.Map.Entry e + : ((java.util.Map) depObj).entrySet()) { + if (!(e.getValue() instanceof Schema)) continue; + IrNode.DependentSchema d = new IrNode.DependentSchema(); + d.name = String.valueOf(e.getKey()); + d.child = irNodeFromRawSchema((Schema) e.getValue(), + n.childId("dep_" + n.dependentSchemas.size())); + n.dependentSchemas.add(d); + } + } + Object unevalObj = vp.get("validation-unevaluated-properties"); + if (unevalObj != null) { + n.unevaluatedPropertiesPresent = true; + if (unevalObj instanceof Schema) { + Schema us = (Schema) unevalObj; + Boolean bv = us.getBooleanSchemaValue(); + if (bv != null) { + n.unevaluatedPropertiesRejects = !Boolean.TRUE.equals(bv); + } else { + n.unevaluatedSchemaChild = + irNodeFromRawSchema(us, n.childId("uneval")); + } + } else if (unevalObj instanceof Boolean) { + n.unevaluatedPropertiesRejects = !Boolean.TRUE.equals(unevalObj); + } + } + + // contains and its exact count bounds. + Object containsObj = vp.get("validation-contains-schema"); + if (containsObj instanceof Schema) { + n.containsChild = irNodeFromRawSchema((Schema) containsObj, + n.childId("contains")); + } + n.minContainsLexeme = lexemeOf(vp.get("validation-min-contains")); + n.maxContainsLexeme = lexemeOf(vp.get("validation-max-contains")); + if (n.minContainsLexeme != null) n.minContainsPresent = true; + if (n.maxContainsLexeme != null) n.maxContainsPresent = true; + + // dependentRequired maps a present property to its prerequisites. Raw + // recovery corrects swagger-parser's merged multi-entry representation. + Object depReqObj = vp.get("validation-dependent-required"); + if (depReqObj instanceof java.util.Map) { + for (java.util.Map.Entry e + : ((java.util.Map) depReqObj).entrySet()) { + if (!(e.getValue() instanceof java.util.List)) continue; + IrNode.DependentRequiredEntry de = new IrNode.DependentRequiredEntry(); + de.name = String.valueOf(e.getKey()); + for (Object r : (java.util.List) e.getValue()) { + de.required.add(String.valueOf(r)); + } + n.dependentRequired.add(de); + } + } + + // Annotation keywords collected by scanSurfaceAssertions. + readAnnotationVp(vp, n); + + boolean hasKeyword = n.hasType + || n.booleanValue != BooleanValueKind.NOT_BOOLEAN + || n.minimum != null || n.maximum != null + || n.exclusiveMinimum != null || n.exclusiveMaximum != null + || n.multipleOf != null + || !n.enumNumbers.isEmpty() || !n.enumStrings.isEmpty() + || !n.enumBooleans.isEmpty() + || n.constNumber != null || n.constString != null || n.constBool != null + || n.constJson != null || n.enumJson != null + || n.hasUniqueItems || n.uniqueItemsSeen + || n.notChild != null || n.isRef + || n.hasObjectSchema || n.required != null && !n.required.isEmpty() + || "absent" != n.additionalPropertiesKind && !"absent".equals(n.additionalPropertiesKind) + || n.minPropertiesPresent || n.maxPropertiesPresent + || !n.prefixItems.isEmpty() || n.itemsChild != null + || n.minItemsPresent || n.maxItemsPresent + || n.minLengthPresent || n.maxLengthPresent || n.patternPresent + || !n.patternProperties.isEmpty() || n.propertyNamesChild != null + || n.applicatorKind != null + || !n.allOfChildren.isEmpty() || !n.anyOfChildren.isEmpty() + || !n.oneOfChildren.isEmpty() + || n.unevaluatedPropertiesPresent + || n.unevaluatedItemsPresent + || n.containsChild != null || n.minContainsPresent || n.maxContainsPresent + || n.ifChild != null || n.thenChild != null || n.elseChild != null + || !n.dependentSchemas.isEmpty() + || !n.dependentRequired.isEmpty() + || n.resourceRoot || n.dynamicRefAnchor != null + || n.dynamicAnchorName != null; + return hasKeyword ? n : null; + } + + /** + * Builds an IR node directly from a raw schema. This path handles `not`, + * properties, array items, applicators, and unevaluated schemas that branch + * lowering does not visit. + */ + private IrNode irNodeFromRawSchema(Schema schema, String validatorId) { + IrNode n = new IrNode(); + n.validatorId = validatorId; + n.resolvedName = validatorId; + if (schema == null) { + return n; + } + if (schema.get$ref() != null) { + // Local ref: resolve against the combined registry later. Siblings + // are still densified (2020-12: $ref and siblings BOTH apply). + n.isRef = true; + n.refTargetId = refTargetIdOf(schema.get$ref()); + // A normalized dynamic-reference wrapper encodes its anchor in the + // target component name because parser normalization can discard + // sibling extensions on $ref schemas. + String dynAnchor = dynamicRefAnchorOf(schema.get$ref()); + if (dynAnchor != null && n.dynamicRefAnchor == null) { + n.dynamicRefAnchor = dynAnchor; + } + } + + // Dynamic-scope facts carried by the normalization extensions. + { + java.util.Map ext = schema.getExtensions(); + if (ext != null) { + Object dynres = ext.get("x-oas31-res"); + if (dynres instanceof Number) { + n.dynamicResource = ((Number) dynres).intValue(); + } + Object dynroot = ext.get("x-oas31-res-root"); + if (Boolean.TRUE.equals(dynroot) || (dynroot instanceof Number + && ((Number) dynroot).intValue() != 0)) { + n.resourceRoot = true; + } + Object vinert = ext.get("x-oas31-vocab-inert"); + if (Boolean.TRUE.equals(vinert)) { + n.dialectValidationInert = true; + } + Object dynref = ext.get("x-oas31-dynref"); + if (dynref != null) { + n.dynamicRefAnchor = String.valueOf(dynref); + if (!n.isRef && n.refTargetId == null) { + n.isRef = true; + n.refTargetId = "__unresolved_dynamic_ref"; + } + } + Object dynanch = ext.get("x-oas31-dyanchor"); + if (dynanch != null) { + // Hoisted anchor component wrapper: the anchor target row is + // this wrapper's FIRST composed child (the runner single- + // branch oneOf). Overrides any earlier self-registration of + // the same (resource, name) pair. + n.dynamicAnchorName = String.valueOf(dynanch); + dynamicAnchorRegs.put( + n.dynamicResource + "\u0000" + n.dynamicAnchorName, + new DynamicAnchorReg(n, n.dynamicResource, + n.dynamicAnchorName, false)); + } + } + if (n.dynamicAnchorName == null && schema.get$dynamicAnchor() != null + && !schema.get$dynamicAnchor().isEmpty()) { + n.dynamicAnchorName = schema.get$dynamicAnchor(); + dynamicAnchorRegs.put(n.dynamicResource + "\u0000" + n.dynamicAnchorName, + new DynamicAnchorReg(n, n.dynamicResource, + n.dynamicAnchorName, true)); + } + } + if (schema.getType() != null) { + n.hasType = true; + n.typeFlags |= jsonTypeBit(String.valueOf(schema.getType())); + } + if (schema.getTypes() != null && !schema.getTypes().isEmpty()) { + n.hasType = true; + for (Object t : schema.getTypes()) { + n.typeFlags |= jsonTypeBit(String.valueOf(t)); + } + } + if (schema.getBooleanSchemaValue() != null) { + n.booleanValue = Boolean.TRUE.equals(schema.getBooleanSchemaValue()) + ? BooleanValueKind.TRUE : BooleanValueKind.FALSE; + } + String pristineConstJson = Oas31RawSpecRecovery.constJsonOf(schema); + if (pristineConstJson != null) { + n.hasConst = true; + n.constJson = pristineConstJson; + } else if (schema.getConst() != null) { + n.hasConst = true; + n.constJson = toJsonLiteral(schema.getConst()); + } + String pristineEnumJson = Oas31RawSpecRecovery.enumJsonOf(schema); + if (pristineEnumJson != null) { + n.enumJson = pristineEnumJson; + } else if (schema.getEnum() != null) { + // An EMPTY enum (enum: []) is a valid reject-all schema. + n.enumJson = toJsonLiteral(schema.getEnum()); + } + if (schema.getMinimum() != null) { + n.minimum = String.valueOf(schema.getMinimum()); + } + if (schema.getMaximum() != null) { + n.maximum = String.valueOf(schema.getMaximum()); + } + // Use the *Value* accessor (Number); getExclusiveMinimum() is a Boolean + // presence marker in OAS 3.0 and not a numeric bound. + if (schema.getExclusiveMinimumValue() != null) { + n.exclusiveMinimum = String.valueOf(schema.getExclusiveMinimumValue()); + } + if (schema.getExclusiveMaximumValue() != null) { + n.exclusiveMaximum = String.valueOf(schema.getExclusiveMaximumValue()); + } + if (schema.getMultipleOf() != null) { + n.multipleOf = String.valueOf(schema.getMultipleOf()); + } + if (schema.getUniqueItems() != null) { + n.uniqueItemsSeen = true; + n.hasUniqueItems = Boolean.TRUE.equals(schema.getUniqueItems()); + } + if (schema.getNot() != null) { + n.notChild = irNodeFromRawSchema(schema.getNot(), n.childId("not")); + } + + // ---- Object structure ---- + if (schema.getProperties() != null && !schema.getProperties().isEmpty()) { + n.hasObjectSchema = true; + java.util.List names = new ArrayList<>(schema.getProperties().keySet()); + java.util.Collections.sort(names); + for (String name : names) { + Schema ps = (Schema) schema.getProperties().get(name); + if (ps == null) continue; + IrNode.PropertySchema pb = new IrNode.PropertySchema(); + pb.name = name; + pb.child = irNodeFromRawSchema(ps, n.childId("prop")); + n.properties.add(pb); + } + } + if (schema.getRequired() != null && !schema.getRequired().isEmpty()) { + n.required.addAll(schema.getRequired()); + n.hasObjectSchema = true; + } + Object addProps = schema.getAdditionalProperties(); + if (addProps != null) { + if (addProps instanceof Boolean) { + n.additionalPropertiesKind = + Boolean.TRUE.equals(addProps) ? "allowed" : "reject"; + n.hasObjectSchema = true; + } else if (addProps instanceof Schema) { + Schema as = (Schema) addProps; + Boolean bv = as.getBooleanSchemaValue(); + if (bv != null) { + n.additionalPropertiesKind = + Boolean.TRUE.equals(bv) ? "allowed" : "reject"; + n.hasObjectSchema = true; + } else if (as.getProperties() == null && as.getType() == null + && as.getEnum() == null && as.getItems() == null + && as.getPrefixItems() == null && as.getConst() == null + && as.getNot() == null && as.get$ref() == null) { + // additionalProperties: {} — unrestricted (allowed). + n.additionalPropertiesKind = "allowed"; + n.hasObjectSchema = true; + } else { + n.additionalPropertiesKind = "schema"; + n.hasObjectSchema = true; + n.additionalSchemaChild = + irNodeFromRawSchema(as, n.childId("addprops")); + } + } + } + String minPropertiesLexeme = Oas31RawSpecRecovery.countBoundLexemeOf( + schema, "minProperties"); + if (minPropertiesLexeme != null || schema.getMinProperties() != null) { + n.minPropertiesLexeme = minPropertiesLexeme != null + ? minPropertiesLexeme : String.valueOf(schema.getMinProperties()); + n.minPropertiesPresent = true; + n.hasObjectSchema = true; + } + String maxPropertiesLexeme = Oas31RawSpecRecovery.countBoundLexemeOf( + schema, "maxProperties"); + if (maxPropertiesLexeme != null || schema.getMaxProperties() != null) { + n.maxPropertiesLexeme = maxPropertiesLexeme != null + ? maxPropertiesLexeme : String.valueOf(schema.getMaxProperties()); + n.maxPropertiesPresent = true; + n.hasObjectSchema = true; + } + + // ---- Array structure ---- + if (schema.getPrefixItems() != null) { + for (Object o : schema.getPrefixItems()) { + Schema s = (Schema) o; + if (s == null) continue; + if (s.getBooleanSchemaValue() != null) { + n.prefixItems.add(booleanValueSchema( + s.getBooleanSchemaValue(), n.childId("pi"))); + } else { + n.prefixItems.add(irNodeFromRawSchema(s, n.childId("pi"))); + } + } + } + if (schema.getItems() != null) { + Schema its = schema.getItems(); + if (its.getBooleanSchemaValue() != null) { + n.itemsChild = booleanValueSchema( + its.getBooleanSchemaValue(), n.childId("items")); + } else { + n.itemsChild = irNodeFromRawSchema(its, n.childId("items")); + } + } + String mibl = Oas31RawSpecRecovery.countBoundLexemeOf(schema, "minItems"); + if (mibl != null || schema.getMinItems() != null) { + n.minItemsLexeme = mibl != null + ? mibl : String.valueOf(schema.getMinItems()); + n.minItemsPresent = true; + } + String maxbl = Oas31RawSpecRecovery.countBoundLexemeOf(schema, "maxItems"); + if (maxbl != null || schema.getMaxItems() != null) { + n.maxItemsLexeme = maxbl != null + ? maxbl : String.valueOf(schema.getMaxItems()); + n.maxItemsPresent = true; + } + + // ---- String constraints ---- + String minll = Oas31RawSpecRecovery.countBoundLexemeOf(schema, "minLength"); + if (minll != null || schema.getMinLength() != null) { + n.minLengthLexeme = minll != null + ? minll : String.valueOf(schema.getMinLength()); + n.minLengthPresent = true; + } + String maxll = Oas31RawSpecRecovery.countBoundLexemeOf(schema, "maxLength"); + if (maxll != null || schema.getMaxLength() != null) { + n.maxLengthLexeme = maxll != null + ? maxll : String.valueOf(schema.getMaxLength()); + n.maxLengthPresent = true; + } + if (schema.getPattern() != null) { + n.patternLexeme = CppBoostBeastClientCodegen.escapeCppStringContent(schema.getPattern()); + n.patternPresent = true; + } + + // ---- patternProperties / propertyNames ---- + if (schema.getPatternProperties() != null + && !schema.getPatternProperties().isEmpty()) { + java.util.List ppNames = new ArrayList<>( + schema.getPatternProperties().keySet()); + java.util.Collections.sort(ppNames); + for (String ppName : ppNames) { + Schema ps = (Schema) schema.getPatternProperties().get(ppName); + if (ps == null) continue; + IrNode.PatternSchema pb = new IrNode.PatternSchema(); + pb.regex = ppName; + pb.child = irNodeFromRawSchema(ps, n.childId("pp")); + n.patternProperties.add(pb); + } + } + if (schema.getPropertyNames() != null) { + Schema pns = schema.getPropertyNames(); + if (pns.getBooleanSchemaValue() != null) { + n.propertyNamesChild = booleanValueSchema( + pns.getBooleanSchemaValue(), n.childId("pn")); + } else { + n.propertyNamesChild = irNodeFromRawSchema(pns, n.childId("pn")); + } + } + + // ---- Coexisting allOf / anyOf / oneOf applicators ---- + { + java.util.List allMembers = schema.getAllOf(); + if (allMembers != null && !allMembers.isEmpty()) { + n.applicatorKind = "allOf"; + for (Object mo : allMembers) { + Schema s = (Schema) mo; + if (s == null) continue; + if (s.getBooleanSchemaValue() != null) { + n.allOfChildren.add(booleanValueSchema( + s.getBooleanSchemaValue(), n.childId("app"))); + } else { + n.allOfChildren.add(irNodeFromRawSchema(s, n.childId("app"))); + } + } + } + java.util.List anyMembers = schema.getAnyOf(); + if (anyMembers != null && !anyMembers.isEmpty()) { + if (n.applicatorKind == null) n.applicatorKind = "anyOf"; + for (Object mo : anyMembers) { + Schema s = (Schema) mo; + if (s == null) continue; + if (s.getBooleanSchemaValue() != null) { + n.anyOfChildren.add(booleanValueSchema( + s.getBooleanSchemaValue(), n.childId("app"))); + } else { + n.anyOfChildren.add(irNodeFromRawSchema(s, n.childId("app"))); + } + } + } + java.util.List oneMembers = schema.getOneOf(); + if (oneMembers != null && !oneMembers.isEmpty()) { + if (n.applicatorKind == null) n.applicatorKind = "oneOf"; + for (Object mo : oneMembers) { + Schema s = (Schema) mo; + if (s == null) continue; + if (s.getBooleanSchemaValue() != null) { + n.oneOfChildren.add(booleanValueSchema( + s.getBooleanSchemaValue(), n.childId("app"))); + } else { + n.oneOfChildren.add(irNodeFromRawSchema(s, n.childId("app"))); + } + } + } + } + + // ---- unevaluatedProperties ---- + if (schema.getUnevaluatedProperties() != null) { + n.unevaluatedPropertiesPresent = true; + Schema us = schema.getUnevaluatedProperties(); + Boolean bv = us.getBooleanSchemaValue(); + if (bv != null) { + n.unevaluatedPropertiesRejects = !Boolean.TRUE.equals(bv); + } else { + n.unevaluatedSchemaChild = irNodeFromRawSchema(us, n.childId("uneval")); + } + } + + // ---- unevaluatedItems ---- + if (schema.getUnevaluatedItems() != null) { + n.unevaluatedItemsPresent = true; + Schema us = schema.getUnevaluatedItems(); + Boolean bv = us.getBooleanSchemaValue(); + if (bv != null) { + n.unevaluatedItemsRejects = !Boolean.TRUE.equals(bv); + } else { + n.unevaluatedItemsSchemaChild = + irNodeFromRawSchema(us, n.childId("uneval")); + } + } + + // ---- if / then / else ---- + if (schema.getIf() != null) { + n.ifChild = irNodeFromRawSchema(schema.getIf(), n.childId("if")); + } + if (schema.getThen() != null) { + n.thenChild = irNodeFromRawSchema(schema.getThen(), n.childId("then")); + } + if (schema.getElse() != null) { + n.elseChild = irNodeFromRawSchema(schema.getElse(), n.childId("else")); + } + + // ---- dependentSchemas ---- + java.util.Map depMap = schema.getDependentSchemas(); + if (depMap != null && !depMap.isEmpty()) { + for (java.util.Map.Entry e : depMap.entrySet()) { + if (e.getValue() == null) continue; + IrNode.DependentSchema d = new IrNode.DependentSchema(); + d.name = e.getKey(); + d.child = irNodeFromRawSchema(e.getValue(), + n.childId("dep_" + n.dependentSchemas.size())); + n.dependentSchemas.add(d); + } + } + + // ---- contains family ---- + if (schema.getContains() != null) { + n.containsChild = irNodeFromRawSchema(schema.getContains(), + n.childId("contains")); + } + String minCLex = Oas31RawSpecRecovery.countBoundLexemeOf( + schema, "minContains"); + if (minCLex == null && schema.getMinContains() != null) { + minCLex = String.valueOf(schema.getMinContains()); + } + if (minCLex != null) { + n.minContainsLexeme = minCLex; + n.minContainsPresent = true; + } + String maxCLex = Oas31RawSpecRecovery.countBoundLexemeOf( + schema, "maxContains"); + if (maxCLex == null && schema.getMaxContains() != null) { + maxCLex = String.valueOf(schema.getMaxContains()); + } + if (maxCLex != null) { + n.maxContainsLexeme = maxCLex; + n.maxContainsPresent = true; + } + + // ---- dependentRequired ---- + // The parser merges multi-entry maps (see (c)); the recovered literal + // extension is authoritative when present. + java.util.Map> depReqMap = + schema.getDependentRequired(); + if (depReqMap != null && schema.getExtensions() != null + && schema.getExtensions().containsKey( + "x-oas31-dependent-required")) { + Object ext = schema.getExtensions() + .get("x-oas31-dependent-required"); + if (ext instanceof java.util.Map) { + depReqMap = (java.util.Map>) ext; + } + } + if (depReqMap != null && !depReqMap.isEmpty()) { + for (java.util.Map.Entry> e + : depReqMap.entrySet()) { + if (e.getValue() == null || e.getValue().isEmpty()) continue; + IrNode.DependentRequiredEntry de = new IrNode.DependentRequiredEntry(); + de.name = e.getKey(); + de.required.addAll(e.getValue()); + n.dependentRequired.add(de); + } + } + // Annotation keywords read directly from the raw schema. + readAnnotationRaw(schema, n); + return n; + } + + /** The applicator keyword of a schema, null when it has none. */ + private static String applicatorOf(Schema schema) { + if (schema.getOneOf() != null && !schema.getOneOf().isEmpty()) return "oneOf"; + if (schema.getAnyOf() != null && !schema.getAnyOf().isEmpty()) return "anyOf"; + if (schema.getAllOf() != null && !schema.getAllOf().isEmpty()) return "allOf"; + return null; + } + + /** The applicator member list for the schema's (single) applicator. */ + private static java.util.List applicatorMembers(Schema schema) { + if (schema.getOneOf() != null && !schema.getOneOf().isEmpty()) return schema.getOneOf(); + if (schema.getAnyOf() != null && !schema.getAnyOf().isEmpty()) return schema.getAnyOf(); + if (schema.getAllOf() != null && !schema.getAllOf().isEmpty()) return schema.getAllOf(); + return java.util.Collections.emptyList(); + } + + + /** Builds a boolean value-schema node (OAS 3.1 true/false literal). */ + private IrNode booleanValueSchema(Boolean b, String validatorId) { + IrNode n = new IrNode(); + n.validatorId = validatorId; + n.resolvedName = validatorId; + n.booleanValue = Boolean.TRUE.equals(b) ? BooleanValueKind.TRUE : BooleanValueKind.FALSE; + return n; + } + + /** + * Maps a component reference to its complete densified wrapper row. + * Unsupported targets intentionally remain unresolved and fail generation. + */ + private String refTargetIdOf(String refStr) { + String name = refSimpleName(refStr); + // Every component has a wrapper row, so refs resolve to the complete + // composition rather than an accidental first-branch alias. + return CppBoostBeastModelCodegen.componentSchemaId(name, componentSchemaIds); + } + + /** Extracts the referenced component or final URI-path name. */ + private static String refSimpleName(String ref) { + if (ref == null) return ""; + String r = ref.trim(); + if (r.startsWith("#/components/schemas/")) { + return r.substring("#/components/schemas/".length()); + } + if (r.startsWith("#/$defs/")) { + return r.substring("#/$defs/".length()); + } + int hash = r.indexOf('#'); + String base = hash >= 0 ? r.substring(0, hash) : r; + int slash = base.lastIndexOf('/'); + String tail = slash >= 0 ? base.substring(slash + 1) : base; + return tail.isEmpty() ? base : tail; + } + + /** + * Decodes the anchor from a normalized {@code __dynref__} + * component wrapper. Plain references return null. + */ + private String dynamicRefAnchorOf(String refStr) { + String name = refSimpleName(refStr); + if (name == null || !name.startsWith("__dynref_")) { + return null; + } + // Ignore synthetic model-layer names that were not authored normalized + // wrapper components. + if (!oasComponentNames.contains(name)) { + return null; + } + String rest = name.substring("__dynref_".length()); + int cut = rest.indexOf('_'); + if (cut <= 0) { + return null; + } + String resDigits = rest.substring(0, cut); + for (int i = 0; i < resDigits.length(); i++) { + if (!Character.isDigit(resDigits.charAt(i))) { + return null; + } + } + String anchor = rest.substring(cut + 1); + return anchor.isEmpty() ? null : anchor; + } + + /** Serialize one arbitrary Swagger/Jackson value as strict JSON. */ + private static String toJsonLiteral(Object value) { + try { + return Json.mapper().writeValueAsString(value); + } catch (com.fasterxml.jackson.core.JsonProcessingException ex) { + throw new IllegalArgumentException("Unable to serialize a schema JSON value", ex); + } + } + + + /** Original numeric lexeme, or null when absent (BigDecimal.toString()). */ + private static String lexemeOf(Object value) { + if (value == null) { + return null; + } + String s = String.valueOf(value); + return s.isEmpty() ? null : s; + } + + // JsonType bit positions must match JsonType in Oas31SchemaIr.h. + private static final int JSONTYPE_BIT_NULL = 1 << 0; + private static final int JSONTYPE_BIT_BOOLEAN = 1 << 1; + private static final int JSONTYPE_BIT_NUMBER = 1 << 2; + private static final int JSONTYPE_BIT_STRING = 1 << 3; + private static final int JSONTYPE_BIT_ARRAY = 1 << 4; + private static final int JSONTYPE_BIT_OBJECT = 1 << 5; + private static final int JSONTYPE_BIT_INTEGER = 1 << 6; // schema-level only + + /** Maps an OAS 3.1 type name to a JsonType bit. */ + private static int jsonTypeBit(String type) { + switch (type) { + case "null": return JSONTYPE_BIT_NULL; + case "boolean": return JSONTYPE_BIT_BOOLEAN; + case "number": return JSONTYPE_BIT_NUMBER; + case "string": return JSONTYPE_BIT_STRING; + case "array": return JSONTYPE_BIT_ARRAY; + case "object": return JSONTYPE_BIT_OBJECT; + case "integer": return JSONTYPE_BIT_INTEGER; + default: return 0; + } + } + +} \ No newline at end of file diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/Oas31SchemaIrRenderer.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/Oas31SchemaIrRenderer.java new file mode 100644 index 000000000000..105b27ab0706 --- /dev/null +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/Oas31SchemaIrRenderer.java @@ -0,0 +1,615 @@ +/* + * Copyright 2026 OpenAPI-Generator Contributors (https://openapi-generator.tech) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.openapitools.codegen.languages; + +import io.swagger.v3.oas.models.OpenAPI; + +import org.openapitools.codegen.languages.Oas31SchemaIrEmitter.DynamicAnchorReg; +import org.openapitools.codegen.languages.Oas31SchemaIrEmitter.IrNode; + +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** Renders a completed densified schema registry into generated C++ sources. */ +final class Oas31SchemaIrRenderer { + private final OpenAPI openAPI; + private final Map additionalProperties; + private final Map dynamicAnchorRegs; + private final Set vocabInertResources; + + Oas31SchemaIrRenderer( + OpenAPI openAPI, + Map additionalProperties, + Map dynamicAnchorRegs, + Set vocabInertResources) { + this.openAPI = openAPI; + this.additionalProperties = additionalProperties; + this.dynamicAnchorRegs = dynamicAnchorRegs; + this.vocabInertResources = vocabInertResources; + } + + /** + * Retrieval/base URI of the single schema resource emitted this pass. + * Derived from the emitter's model access: a user-supplied {@code oas31BaseUri} + * option (the OAS 3.1 document retrieval URI) or a stable document-local urn + * when the invocation carries no explicit base URI. Honest: external-file + * resources cannot be emitted this pass, so there is exactly one resource. + */ + private String documentBaseUri() { + Object opt = additionalProperties.get("oas31BaseUri"); + if (opt != null && !String.valueOf(opt).isEmpty()) { + return String.valueOf(opt); + } + return "urn:openapi-generator:cpp-boost-beast:schema"; + } + + /** + * Dialect URI for the single emitted schema resource, classed from the + * document knobs ({@code jsonSchemaDialect} / OAS 3.1 pinning) that the + * emitter can observe via its model access. Falls back to the OAS 3.1 base + * alias when the document is unavailable or declares nothing recognizable. + */ + private String documentDialectUri() { + CppBoostBeastClientCodegen.OasDialect d = Oas31KeywordScanner.resolveDocumentDialect(openAPI); + switch (d) { + case OAS_31: + return CppBoostBeastClientCodegen.OAS_31_DIALECT; + case DRAFT_2020_12_REC: + return CppBoostBeastClientCodegen.DRAFT_2020_12; + case UNRECOGNIZED: + if (openAPI != null && openAPI.getJsonSchemaDialect() != null + && !openAPI.getJsonSchemaDialect().isEmpty()) { + return openAPI.getJsonSchemaDialect().trim(); + } + return CppBoostBeastClientCodegen.OAS_31_DIALECT_BASE_ALIAS; + case UNSPECIFIED: + default: + return Oas31KeywordScanner.isOas31(openAPI) + ? CppBoostBeastClientCodegen.OAS_31_DIALECT + : CppBoostBeastClientCodegen.OAS_31_DIALECT_BASE_ALIAS; + } + } + private String schemaValidationNamespace() { + Object value = additionalProperties.get("schemaValidationNamespace"); + if (value == null || value.toString().isEmpty()) { + throw new IllegalStateException("schemaValidationNamespace must be configured"); + } + return value.toString(); + } + + private String schemaValidationHeaderGuardPrefix() { + Object value = additionalProperties.get("schemaValidationHeaderGuardPrefix"); + if (value == null || value.toString().isEmpty()) { + throw new IllegalStateException("schemaValidationHeaderGuardPrefix must be configured"); + } + return value.toString(); + } + + /** Oas31SchemaRegistry.h — declarations only (registry defined in .cpp). */ + String buildSchemaIrHeader(List nodes) { + final String namespaceName = schemaValidationNamespace(); + String guard = schemaValidationHeaderGuardPrefix() + "_OAS31_SCHEMA_REGISTRY_H_"; + String exportMacro = additionalProperties.getOrDefault( + CppBoostBeastClientCodegen.EXPORT_MACRO, "").toString(); + final String exportPrefix = exportMacro.isEmpty() ? "" : exportMacro + " "; + StringBuilder sb = new StringBuilder(); + sb.append("// Generated by CppBoostBeastClientCodegen (densified OAS 3.1 schema IR).\n"); + sb.append("// Do not edit by hand. SchemaNode/SchemaResource layout frozen in " + + "Oas31SchemaIr.h.\n"); + sb.append("#ifndef ").append(guard).append("\n"); + sb.append("#define ").append(guard).append("\n\n"); + sb.append("#include \"Oas31SchemaIr.h\"\n"); + if (!exportMacro.isEmpty()) { + sb.append("#include \"ApiExport.h\"\n"); + } + sb.append("#include \n\n"); + sb.append("namespace ").append(namespaceName).append(" {\n\n"); + sb.append("// Densified SchemaResourceRegistry for the generated schema IR.\n"); + sb.append("// Numeric constraints carry ORIGINAL lexemes (ExactNumber::parseLexeme).\n"); + sb.append(exportPrefix).append("SchemaResourceRegistry const& schemaRegistry();\n\n"); + sb.append("class ").append(exportPrefix).append("SchemaEvaluator;\n"); + sb.append(exportPrefix).append("SchemaEvaluator const& sharedSchemaEvaluator();\n\n"); + sb.append("// Resolve a validate_ identifier to its SchemaIndex " + + "(kNoSchema if unknown).\n"); + sb.append(exportPrefix).append("SchemaIndex schemaNodeFor(std::string const& id);\n\n"); + sb.append("} // namespace ").append(namespaceName).append("\n\n"); + sb.append("#endif // ").append(guard).append("\n"); + return sb.toString(); + } + + /** Emit `n..push_back();` for every resolved index. */ + private static void emitChildVector(StringBuilder sb, String field, + List indices) { + if (indices == null) return; + for (Integer cidx : indices) { + if (cidx >= 0) { + sb.append(" n.").append(field).append(".push_back(").append(cidx).append(");\n"); + } + } + } + + /** schema_ir.generated.cpp or one partitioned registry source. */ + String buildSchemaIrSource( + List nodes, int mainNodeCount, + int start, int end, int chunk) { + boolean isChunk = chunk >= 0; + String namespaceName = schemaValidationNamespace(); + StringBuilder sb = new StringBuilder(); + sb.append("// Generated by CppBoostBeastClientCodegen (densified OAS 3.1 schema IR).\n"); + sb.append("// Numeric constraints are exact lexemes parsed by ExactNumber::parseLexeme.\n"); + sb.append("#include \"Oas31SchemaRegistry.h\"\n"); + sb.append("#include \"Oas31ExactJson.h\"\n"); + if (!isChunk) { + sb.append("#include \"Oas31Validator.h\"\n"); + } + sb.append("#include \n"); + sb.append("#include \n\n"); + sb.append("namespace ").append(namespaceName).append(" {\n"); + if (isChunk) { + sb.append("namespace detail {\n"); + } + sb.append("namespace {\n\n"); + sb.append("[[maybe_unused]] void setExact(ExactNumber& out, bool& hasOut, std::string const& lexeme) {\n"); + sb.append(" if (!lexeme.empty()) { out = ExactNumber::parseLexeme(lexeme); hasOut = true; }\n"); + sb.append("}\n\n"); + if (isChunk) { + sb.append("} // namespace\n\n"); + sb.append("void appendSchemaRegistryChunk").append(chunk) + .append("(SchemaResourceRegistry& reg) {\n"); + } else { + sb.append("SchemaResourceRegistry buildRegistry() {\n"); + sb.append(" SchemaResourceRegistry reg;\n"); + sb.append(" reg.nodes.reserve(").append(nodes.size()).append(");\n"); + // One generated document resource owns the main validator rows. + sb.append(" SchemaResource res;\n"); + sb.append(" res.baseUri = \"").append(CppBoostBeastClientCodegen.escapeCppStringContent(documentBaseUri())).append("\";\n"); + sb.append(" res.dialect = \"").append(CppBoostBeastClientCodegen.escapeCppStringContent(documentDialectUri())).append("\";\n"); + sb.append(" // No document-root anchor is declared.\n"); + for (int i = 0; i < mainNodeCount; i++) { + sb.append(" res.rootNodes.push_back(").append(i).append(");\n"); + } + sb.append(" reg.resources.push_back(std::move(res));\n"); + } + + for (int index = start; index < end; index++) { + IrNode node = nodes.get(index); + boolean resolvedRef = node.isRef && node.refTargetIndex >= 0; + sb.append("\n { // node ").append(index).append("\n"); + sb.append(" SchemaNode n;\n"); + sb.append(" n.resourceIdentity = 0;\n"); + // Dynamic resource roots push scope frames; anchor and dynamic-ref + // names identify declarations and lookups within those frames. + if (node.dynamicResource != 0) { + sb.append(" n.dynamicResource = ").append(node.dynamicResource).append(";\n"); + } + if (node.resourceRoot) { + sb.append(" n.resourceRoot = true;\n"); + } + if (node.dynamicAnchorName != null) { + sb.append(" n.dynamicAnchorName = \""); + sb.append(CppBoostBeastClientCodegen.escapeCppStringContent(node.dynamicAnchorName)); + sb.append("\";\n"); + } + if (node.dynamicRefAnchor != null && resolvedRef) { + sb.append(" n.dynamicRefAnchor = \""); + sb.append(CppBoostBeastClientCodegen.escapeCppStringContent(node.dynamicRefAnchor)); + sb.append("\";\n"); + } + // A local $ref applies its target alongside sibling keywords. The + // target row retains its complete constraint surface, including all + // composition applicators. + if (resolvedRef) { + sb.append(" n.applicator = ApplicatorKind::ref;\n"); + sb.append(" n.children.push_back(").append(node.refTargetIndex).append(");\n"); + } + // A deep enum present => the enum alone is the complete constraint + // (the instance must deep-equal a member, which subsumes any type). + // The lowered model's type flag is unreliable here (type-less enums + // are inferred as `string`; `type: array` becomes an ArraySchema that + // DROPS the enum), so we omit it and rely on the exact enum. + if (node.hasType && node.enumJson == null) { + sb.append(" n.typeFlags = ").append(node.typeFlags).append("u;\n"); + } + switch (node.booleanValue) { + case TRUE: + sb.append(" n.booleanValue = BooleanValue::true_;\n"); + break; + case FALSE: + sb.append(" n.booleanValue = BooleanValue::false_;\n"); + break; + default: + break; + } + Oas31ExactLiteralEmitter.appendNodeLiterals(sb, node); + if (node.hasUniqueItems) { + sb.append(" n.hasUniqueItems = true;\n"); + } + if (node.notSchemaIndex >= 0) { + sb.append(" n.notSchema = ").append(node.notSchemaIndex).append(";\n"); + } + // -- Object structure -- + if (node.hasObjectSchema) { + sb.append(" n.hasObjectSchema = true;\n"); + } + for (IrNode.PropertySchema pb : node.properties) { + if (pb.index < 0) continue; + sb.append(" { PropertyBinding b; b.name = \"") + .append(CppBoostBeastClientCodegen.escapeCppStringContent(pb.name)) + .append("\"; b.node = ").append(pb.index) + .append("; n.properties.push_back(std::move(b)); }\n"); + } + for (String rn : node.required) { + sb.append(" n.required.push_back(\"") + .append(CppBoostBeastClientCodegen.escapeCppStringContent(rn)).append("\");\n"); + } + if (!"absent".equals(node.additionalPropertiesKind)) { + switch (node.additionalPropertiesKind) { + case "allowed": + sb.append(" n.additionalProperties = AdditionalPropertiesKind::allowed;\n"); + break; + case "reject": + sb.append(" n.additionalProperties = AdditionalPropertiesKind::reject;\n"); + break; + case "schema": + sb.append(" n.additionalProperties = AdditionalPropertiesKind::schema;\n"); + if (node.additionalSchemaIndex >= 0) { + sb.append(" n.additionalSchema = ").append(node.additionalSchemaIndex).append(";\n"); + } + break; + default: + break; + } + } + Oas31ExactLiteralEmitter.appendSetExact( + sb, "n.minProperties", "n.hasMinProperties", node.minPropertiesLexeme); + Oas31ExactLiteralEmitter.appendSetExact( + sb, "n.maxProperties", "n.hasMaxProperties", node.maxPropertiesLexeme); + // -- String constraints -- + Oas31ExactLiteralEmitter.appendSetExact( + sb, "n.minLength", "n.hasMinLength", node.minLengthLexeme); + Oas31ExactLiteralEmitter.appendSetExact( + sb, "n.maxLength", "n.hasMaxLength", node.maxLengthLexeme); + if (node.patternPresent) { + sb.append(" n.pattern = \"").append(node.patternLexeme).append("\";\n"); + sb.append(" n.hasPattern = true;\n"); + } + for (int i = 0; i < node.patternProperties.size(); i++) { + IrNode.PatternSchema pb = node.patternProperties.get(i); + if (pb.index < 0) continue; + sb.append(" n.patternProperties.push_back({") + .append("\"").append(CppBoostBeastClientCodegen.escapeCppStringContent(pb.regex)).append("\", ") + .append(pb.index).append("});\n"); + } + if (node.propertyNamesIndex >= 0) { + sb.append(" n.propertyNames = ").append(node.propertyNamesIndex).append(";\n"); + } + // -- Array structure -- + for (int i = 0; i < node.prefixItems.size(); i++) { + int cidx = node.prefixItemIndices.isEmpty() ? -1 : node.prefixItemIndices.get(i); + if (cidx < 0) continue; + sb.append(" n.prefixItems.push_back(").append(cidx).append(");\n"); + } + if (node.itemsIndex >= 0) { + sb.append(" n.items = ").append(node.itemsIndex).append(";\n"); + } + Oas31ExactLiteralEmitter.appendSetExact( + sb, "n.minItems", "n.hasMinItems", node.minItemsLexeme); + Oas31ExactLiteralEmitter.appendSetExact( + sb, "n.maxItems", "n.hasMaxItems", node.maxItemsLexeme); + // -- Coexisting composition applicators -- + emitChildVector(sb, "allOfChildren", node.allOfChildIndices); + emitChildVector(sb, "anyOfChildren", node.anyOfChildIndices); + emitChildVector(sb, "oneOfChildren", node.oneOfChildIndices); + // -- unevaluatedProperties -- + if (node.unevaluatedPropertiesPresent) { + sb.append(" n.hasUnevaluatedProperties = true;\n"); + if (node.unevaluatedPropertiesRejects) { + sb.append(" n.unevaluatedPropertiesRejects = true;\n"); + } + if (node.unevaluatedSchemaIndex >= 0) { + sb.append(" n.unevaluatedSchema = ").append(node.unevaluatedSchemaIndex).append(";\n"); + } + } + // -- unevaluatedItems -- + if (node.unevaluatedItemsPresent) { + sb.append(" n.hasUnevaluatedItems = true;\n"); + if (node.unevaluatedItemsRejects) { + sb.append(" n.unevaluatedItemsRejects = true;\n"); + } + if (node.unevaluatedItemsSchemaIndex >= 0) { + sb.append(" n.unevaluatedItemsSchema = ").append(node.unevaluatedItemsSchemaIndex).append(";\n"); + } + } + // -- if / then / else -- + if (node.ifIndex >= 0) { + sb.append(" n.hasIf = true;\n"); + sb.append(" n.ifSchema = ").append(node.ifIndex).append(";\n"); + } + if (node.thenIndex >= 0) { + sb.append(" n.hasThen = true;\n"); + sb.append(" n.thenSchema = ").append(node.thenIndex).append(";\n"); + } + if (node.elseIndex >= 0) { + sb.append(" n.hasElse = true;\n"); + sb.append(" n.elseSchema = ").append(node.elseIndex).append(";\n"); + } + // -- dependentSchemas -- + for (IrNode.DependentSchema d : node.dependentSchemas) { + if (d.index < 0) continue; + sb.append(" n.dependentSchemas.push_back({\"") + .append(CppBoostBeastClientCodegen.escapeCppStringContent(d.name)) + .append("\", ").append(d.index).append("});\n"); + } + // -- contains family -- + if (node.containsIndex >= 0) { + sb.append(" n.hasContains = true;\n"); + sb.append(" n.containsSchema = ").append(node.containsIndex).append(";\n"); + } + Oas31ExactLiteralEmitter.appendSetExact(sb, "n.minContains", "n.hasMinContains", + node.minContainsLexeme); + Oas31ExactLiteralEmitter.appendSetExact(sb, "n.maxContains", "n.hasMaxContains", + node.maxContainsLexeme); + // -- dependentRequired -- + for (IrNode.DependentRequiredEntry de : node.dependentRequired) { + if (de.required.isEmpty()) continue; + sb.append(" n.dependentRequired.push_back({\"") + .append(CppBoostBeastClientCodegen.escapeCppStringContent(de.name)).append("\", {"); + for (int ri = 0; ri < de.required.size(); ri++) { + if (ri > 0) sb.append(", "); + sb.append("\"").append(CppBoostBeastClientCodegen.escapeCppStringContent(de.required.get(ri))) + .append("\""); + } + sb.append("}});\n"); + } + // -- Annotation payloads (each field contains one complete JSON value) -- + if (!node.annTitle.isEmpty()) { + sb.append(" n.annTitle = \"").append(CppBoostBeastClientCodegen.escapeCppStringContent(node.annTitle)).append("\";\n"); + } + if (!node.annDescription.isEmpty()) { + sb.append(" n.annDescription = \"") + .append(CppBoostBeastClientCodegen.escapeCppStringContent(node.annDescription)) + .append("\";\n"); + } + if (!node.annDefaultJson.isEmpty()) { + sb.append(" n.annDefaultJson = \"") + .append(CppBoostBeastClientCodegen.escapeCppStringContent(node.annDefaultJson)) + .append("\";\n"); + } + if (!node.annExamplesJson.isEmpty()) { + sb.append(" n.annExamplesJson = \"") + .append(CppBoostBeastClientCodegen.escapeCppStringContent( + node.annExamplesJson)) + .append("\";\n"); + } + if (!node.annDeprecatedJson.isEmpty()) { + sb.append(" n.annDeprecatedJson = \"") + .append(CppBoostBeastClientCodegen.escapeCppStringContent( + node.annDeprecatedJson)) + .append("\";\n"); + } + if (!node.annReadOnlyJson.isEmpty()) { + sb.append(" n.annReadOnlyJson = \"") + .append(CppBoostBeastClientCodegen.escapeCppStringContent( + node.annReadOnlyJson)) + .append("\";\n"); + } + if (!node.annWriteOnlyJson.isEmpty()) { + sb.append(" n.annWriteOnlyJson = \"") + .append(CppBoostBeastClientCodegen.escapeCppStringContent( + node.annWriteOnlyJson)) + .append("\";\n"); + } + if (!node.annFormat.isEmpty()) { + sb.append(" n.annFormat = \"").append(CppBoostBeastClientCodegen.escapeCppStringContent(node.annFormat)).append("\";\n"); + } + if (!node.annContentEncoding.isEmpty()) { + sb.append(" n.annContentEncoding = \"") + .append(CppBoostBeastClientCodegen.escapeCppStringContent(node.annContentEncoding)) + .append("\";\n"); + } + if (!node.annContentMediaType.isEmpty()) { + sb.append(" n.annContentMediaType = \"") + .append(CppBoostBeastClientCodegen.escapeCppStringContent(node.annContentMediaType)) + .append("\";\n"); + } + if (!node.annContentSchemaJson.isEmpty()) { + sb.append(" n.annContentSchemaJson = \"") + .append(CppBoostBeastClientCodegen.escapeCppStringContent( + node.annContentSchemaJson)) + .append("\";\n"); + } + for (java.util.Map.Entry ex : node.annExtras) { + sb.append(" n.annExtras.push_back({\"") + .append(CppBoostBeastClientCodegen.escapeCppStringContent(ex.getKey())) + .append("\", \"").append(CppBoostBeastClientCodegen.escapeCppStringContent(ex.getValue())) + .append("\"});\n"); + } + if (node.validatorId != null && !node.validatorId.isEmpty()) { + sb.append(" n.sourceName = \"") + .append(CppBoostBeastClientCodegen.escapeCppStringContent(node.validatorId)) + .append("\";\n"); + } + sb.append(" n.schemaPath = \"") + .append(CppBoostBeastClientCodegen.escapeCppStringContent(node.schemaPath)) + .append("\";\n"); + String baseUri = documentBaseUri(); + int fragment = baseUri.indexOf('#'); + if (fragment >= 0) { + baseUri = baseUri.substring(0, fragment); + } + sb.append(" n.absSchemaUri = \"") + .append(CppBoostBeastClientCodegen.escapeCppStringContent( + baseUri + node.schemaPath)) + .append("\";\n"); + sb.append(" reg.nodes.push_back(std::move(n));\n"); + sb.append(" }\n"); + } + + if (isChunk) { + sb.append("}\n\n"); + sb.append("SchemaIndex schemaNodeForChunk").append(chunk) + .append("(std::string const& id) {\n"); + for (int index = start; index < end; index++) { + sb.append(" if (id == \"") + .append(CppBoostBeastClientCodegen.escapeCppStringContent( + nodes.get(index).validatorId)) + .append("\") return ").append(index).append(";\n"); + } + sb.append(" return kNoSchema;\n"); + sb.append("}\n\n"); + sb.append("} // namespace detail\n"); + sb.append("} // namespace ").append(namespaceName).append("\n"); + return sb.toString(); + } + + // Resources whose dialect omits the validation vocabulary treat those + // keywords as inert annotations. Row identity follows dynamic scope so + // unmarked rows inherit the enclosing resource. + for (int rid : vocabInertResources) { + sb.append(" reg.vocabInertResources.insert(") + .append(rid).append(");\n"); + } + + // Emit resolved dynamic-anchor tables per synthetic resource; unresolved + // registrations fall back to static resolution. + { + int maxRes = 0; + for (DynamicAnchorReg reg : dynamicAnchorRegs.values()) { + if (reg.row >= 0 && reg.resource > maxRes) maxRes = reg.resource; + } + sb.append(" reg.dynamicAnchorTables.resize(").append(maxRes + 1).append(");\n"); + for (DynamicAnchorReg reg : dynamicAnchorRegs.values()) { + if (reg.row < 0) continue; + sb.append(" reg.dynamicAnchorTables[").append(reg.resource) + .append("].push_back({\""); + sb.append(CppBoostBeastClientCodegen.escapeCppStringContent(reg.name)); + sb.append("\", ").append(reg.row).append("});\n"); + } + } + + sb.append("\n return reg;\n"); + sb.append("}\n\n"); + sb.append("} // namespace\n\n"); + sb.append("SchemaResourceRegistry const& schemaRegistry() {\n"); + sb.append(" static SchemaResourceRegistry const r = buildRegistry();\n"); + sb.append(" return r;\n"); + sb.append("}\n\n"); + sb.append("SchemaEvaluator const& sharedSchemaEvaluator() {\n"); + sb.append(" static SchemaEvaluator const evaluator(schemaRegistry());\n"); + sb.append(" return evaluator;\n"); + sb.append("}\n\n"); + sb.append("SchemaIndex schemaNodeFor(std::string const& id) {\n"); + for (int index = 0; index < nodes.size(); index++) { + sb.append(" if (id == \"") + .append(CppBoostBeastClientCodegen.escapeCppStringContent( + nodes.get(index).validatorId)) + .append("\") return ").append(index).append(";\n"); + } + sb.append(" (void)id;\n"); + sb.append(" return kNoSchema;\n"); + sb.append("}\n\n"); + sb.append("} // namespace ").append(namespaceName).append("\n"); + return sb.toString(); + } + + /** Small coordinator linking independently compiled registry partitions. */ + String buildSchemaIrCoordinatorSource( + List nodes, int mainNodeCount, int chunkCount) { + String namespaceName = schemaValidationNamespace(); + StringBuilder sb = new StringBuilder(); + sb.append("// Generated by CppBoostBeastClientCodegen (partitioned OAS 3.1 schema IR).\n"); + sb.append("#include \"Oas31SchemaRegistry.h\"\n"); + sb.append("#include \"Oas31Validator.h\"\n"); + sb.append("#include \n"); + sb.append("#include \n\n"); + sb.append("namespace ").append(namespaceName).append(" {\n"); + sb.append("namespace detail {\n"); + for (int chunk = 0; chunk < chunkCount; chunk++) { + sb.append("void appendSchemaRegistryChunk").append(chunk) + .append("(SchemaResourceRegistry& reg);\n"); + sb.append("SchemaIndex schemaNodeForChunk").append(chunk) + .append("(std::string const& id);\n"); + } + sb.append("} // namespace detail\n\n"); + sb.append("namespace {\n\n"); + sb.append("SchemaResourceRegistry buildRegistry() {\n"); + sb.append(" SchemaResourceRegistry reg;\n"); + sb.append(" reg.nodes.reserve(").append(nodes.size()).append(");\n"); + sb.append(" SchemaResource res;\n"); + sb.append(" res.baseUri = \"") + .append(CppBoostBeastClientCodegen.escapeCppStringContent(documentBaseUri())) + .append("\";\n"); + sb.append(" res.dialect = \"") + .append(CppBoostBeastClientCodegen.escapeCppStringContent(documentDialectUri())) + .append("\";\n"); + sb.append(" // No document-root anchor is declared.\n"); + for (int i = 0; i < mainNodeCount; i++) { + sb.append(" res.rootNodes.push_back(").append(i).append(");\n"); + } + sb.append(" reg.resources.push_back(std::move(res));\n"); + for (int chunk = 0; chunk < chunkCount; chunk++) { + sb.append(" detail::appendSchemaRegistryChunk").append(chunk) + .append("(reg);\n"); + } + for (int rid : vocabInertResources) { + sb.append(" reg.vocabInertResources.insert(") + .append(rid).append(");\n"); + } + + int maxRes = 0; + for (DynamicAnchorReg reg : dynamicAnchorRegs.values()) { + if (reg.row >= 0 && reg.resource > maxRes) { + maxRes = reg.resource; + } + } + sb.append(" reg.dynamicAnchorTables.resize(").append(maxRes + 1).append(");\n"); + for (DynamicAnchorReg reg : dynamicAnchorRegs.values()) { + if (reg.row < 0) { + continue; + } + sb.append(" reg.dynamicAnchorTables[").append(reg.resource) + .append("].push_back({\"") + .append(CppBoostBeastClientCodegen.escapeCppStringContent(reg.name)) + .append("\", ").append(reg.row).append("});\n"); + } + sb.append("\n return reg;\n"); + sb.append("}\n\n"); + sb.append("} // namespace\n\n"); + sb.append("SchemaResourceRegistry const& schemaRegistry() {\n"); + sb.append(" static SchemaResourceRegistry const r = buildRegistry();\n"); + sb.append(" return r;\n"); + sb.append("}\n\n"); + sb.append("SchemaEvaluator const& sharedSchemaEvaluator() {\n"); + sb.append(" static SchemaEvaluator const evaluator(schemaRegistry());\n"); + sb.append(" return evaluator;\n"); + sb.append("}\n\n"); + sb.append("SchemaIndex schemaNodeFor(std::string const& id) {\n"); + sb.append(" SchemaIndex index = kNoSchema;\n"); + for (int chunk = 0; chunk < chunkCount; chunk++) { + sb.append(" index = detail::schemaNodeForChunk").append(chunk) + .append("(id);\n"); + sb.append(" if (index != kNoSchema) return index;\n"); + } + sb.append(" return kNoSchema;\n"); + sb.append("}\n\n"); + sb.append("} // namespace ").append(namespaceName).append("\n"); + return sb.toString(); + } + + +} diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/Oas31SchemaSurfaceAssertionScanner.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/Oas31SchemaSurfaceAssertionScanner.java new file mode 100644 index 000000000000..790dc31bb697 --- /dev/null +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/Oas31SchemaSurfaceAssertionScanner.java @@ -0,0 +1,519 @@ +package org.openapitools.codegen.languages; + +import io.swagger.v3.core.util.Json; +import io.swagger.v3.oas.models.media.Schema; +import org.openapitools.codegen.utils.ModelUtils; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.function.BiConsumer; + +/** + * Scans one OAS 3.1 schema surface into the validation facts consumed by + * composition lowering and schema-IR emission. + */ +final class Oas31SchemaSurfaceAssertionScanner { + private Oas31SchemaSurfaceAssertionScanner() { + } + + /** + * Static assertion scan of one schema surface (one composition branch). + * Records every supported keyword into {@code supported}, every known + * unsupported one into {@code unsupported}, and materialises the scan into + * the schema-IR output map. + * Shared with the composition lowering (branch construction) so both the + * descriptor scan and the IR emission see the identical surface. + */ + static void scanSurfaceAssertions( + io.swagger.v3.oas.models.media.Schema surface, + io.swagger.v3.oas.models.OpenAPI openAPI, + java.util.List supported, + java.util.List unsupported, + java.util.Map validateParams, + boolean refBranchExcluded) { + // Validation type — use the resolved type name or "type-array" for type arrays + if (surface.getType() != null) { + supported.add("type"); + validateParams.put("validation-type", surface.getType()); + } + if (surface.getTypes() != null && !surface.getTypes().isEmpty()) { + supported.add("type"); + validateParams.put("validation-type", "type-array"); + java.util.List loweredTypes = + new ArrayList<>(surface.getTypes()); + // OAS-3.1: the normalizer strips a literal "null" type + // member (nullable:true) before this scan; restore it from + // the raw text when it was present (validity is decided by + // the pristine spec, not the model-layer rewrite). + if (Oas31RawSpecRecovery.pristineTypeHasNull(surface) + && !loweredTypes.contains("null")) { + loweredTypes.add("null"); + } + validateParams.put("validation-type-array", loweredTypes); + validateParams.put("has-validation-type-array", true); + } else if ((surface.getTypes() == null + || surface.getTypes().isEmpty()) + && Oas31RawSpecRecovery.pristineTypeHasNull(surface)) { + // Restore a sole null type dropped by the normalizer. + supported.add("type"); + validateParams.put("validation-type", "type-array"); + java.util.List loweredTypes = + new ArrayList<>(); + loweredTypes.add("null"); + validateParams.put("validation-type-array", loweredTypes); + validateParams.put("has-validation-type-array", true); + } + // enum — an EMPTY enum (enum: []) is a reject-all schema handled + // by the deep JSON store (hasEnumJson with zero members). The + // swagger-parser models `enum: []` as enum=null + types=[string] + // (information lost), so preprocessOpenAPI recovers the original + // keyword from the raw spec and marks the branch via the + // x-oas31-empty-enum vendor extension; the marker is treated as + // an empty enum here (a real, non-empty enum takes precedence). + String pristineEnumJson = Oas31RawSpecRecovery.enumJsonOf(surface); + if (surface.getEnum() != null + || Oas31RawSpecRecovery.isEmptyEnumMarked(surface) + || pristineEnumJson != null) { + supported.add("enum"); + // For a recovered `enum: []` the parser yields enum=null; use + // the empty list so the deep store emits ZERO members. + java.util.List enumMembers = surface.getEnum(); + if (enumMembers == null) { + enumMembers = java.util.Collections.emptyList(); + } + List enumStrs = new ArrayList<>(); + String predominantKind = "string"; + for (Object e : enumMembers) { + String es = e != null ? e.toString() : "null"; + if ("string".equals(predominantKind)) { + es = CppBoostBeastClientCodegen.escapeCppStringContent(es); + } + enumStrs.add(es); + if (e instanceof Integer || e instanceof Long || e instanceof Short || e instanceof Byte) { + predominantKind = "integer"; + } else if (e instanceof Double || e instanceof Float || e instanceof java.math.BigDecimal) { + if (!"integer".equals(predominantKind)) predominantKind = "number"; + } else if (e instanceof Boolean) { + if (!"integer".equals(predominantKind) && !"number".equals(predominantKind)) predominantKind = "bool"; + } + } + validateParams.put("validation-enum-values", enumStrs); + validateParams.put("validation-enum-kind", predominantKind); + validateParams.put("validation-enum-kind-string", "string".equals(predominantKind)); + validateParams.put("validation-enum-kind-integer", "integer".equals(predominantKind)); + validateParams.put("validation-enum-kind-number", "number".equals(predominantKind)); + validateParams.put("validation-enum-kind-bool", "bool".equals(predominantKind)); + validateParams.put("has-validation-enum", true); + // Preserve raw deep JSON enum members for exact IR emission. + validateParams.put("validation-enum-raw", enumMembers); + if (pristineEnumJson != null) { + validateParams.put("validation-enum-json", pristineEnumJson); + } + } + // Const: use raw recovery to distinguish an explicit JSON null + // from the parser's absent-const sentinel. + Object constVal = surface.getConst(); + String pristineConstJson = Oas31RawSpecRecovery.constJsonOf(surface); + if (constVal != null || Oas31RawSpecRecovery.hasExplicitConst(surface)) { + supported.add("const"); + if (constVal instanceof Number) { + validateParams.put("validation-const-type", "number"); + validateParams.put("validation-const-value", constVal.toString()); + } else if (constVal instanceof Boolean) { + validateParams.put("validation-const-type", "boolean"); + validateParams.put("validation-const-value", constVal.toString()); + } else if (constVal == null || "null".equals(pristineConstJson)) { + validateParams.put("validation-const-type", "null"); + validateParams.put("validation-const-value", "null"); + } else { + validateParams.put("validation-const-type", "string"); + validateParams.put("validation-const-value", + CppBoostBeastClientCodegen.escapeCppStringContent(constVal.toString())); + } + validateParams.put("has-validation-const", true); + if (constVal != null) { + validateParams.put("validation-const-raw", constVal); + } + if (pristineConstJson != null) { + validateParams.put("validation-const-json", pristineConstJson); + } + } + // Use ModelUtils.resolveMinimumBound / resolveMaximumBound for + // proper OAS 3.0→3.1 resolution (boolean → numeric conversion, + // allOf intersection, $ref traversal). + ModelUtils.ResolvedMinBound resolvedMin = ModelUtils.resolveMinimumBound(openAPI, surface); + ModelUtils.ResolvedMaxBound resolvedMax = ModelUtils.resolveMaximumBound(openAPI, surface); + if (resolvedMin != null || resolvedMax != null + || surface.getMultipleOf() != null) { + supported.add("numeric-range"); + if (resolvedMin != null) { + validateParams.put("validation-min", resolvedMin.minBound); + if (resolvedMin.exclusive) { + validateParams.put("validation-exclusive-min", resolvedMin.minBound); + } + } + if (resolvedMax != null) { + validateParams.put("validation-max", resolvedMax.maxBound); + if (resolvedMax.exclusive) { + validateParams.put("validation-exclusive-max", resolvedMax.maxBound); + } + } + if (surface.getMultipleOf() != null) { + validateParams.put("validation-multiple-of", + surface.getMultipleOf()); + } + validateParams.put("has-validation-numeric", true); + } + String minLenLex = Oas31RawSpecRecovery.countBoundLexemeOf(surface, "minLength"); + String maxLenLex = Oas31RawSpecRecovery.countBoundLexemeOf(surface, "maxLength"); + if (surface.getMinLength() != null + || surface.getMaxLength() != null + || minLenLex != null || maxLenLex != null) { + supported.add("string-length"); + if (minLenLex != null) { + validateParams.put("validation-min-length", minLenLex); + } else if (surface.getMinLength() != null) { + validateParams.put("validation-min-length", + surface.getMinLength()); + } + if (maxLenLex != null) { + validateParams.put("validation-max-length", maxLenLex); + } else if (surface.getMaxLength() != null) { + validateParams.put("validation-max-length", + surface.getMaxLength()); + } + validateParams.put("has-validation-string-length", true); + } + if (surface.getPattern() != null) { + supported.add("pattern"); + validateParams.put("validation-pattern", + CppBoostBeastClientCodegen.escapeCppStringContent(surface.getPattern())); + validateParams.put("has-validation-pattern", true); + } + if (surface.getPrefixItems() != null + && !surface.getPrefixItems().isEmpty()) { + supported.add("array-prefix-items"); + validateParams.put("validation-prefix-items", + surface.getPrefixItems()); + validateParams.put("has-validation-prefix-items", true); + } + // `items` is evaluated over array entries after prefixItems. + if (surface.getItems() != null) { + validateParams.put("validation-items", surface.getItems()); + } + String minItemsLex = Oas31RawSpecRecovery.countBoundLexemeOf(surface, "minItems"); + String maxItemsLex = Oas31RawSpecRecovery.countBoundLexemeOf(surface, "maxItems"); + if (surface.getMinItems() != null + || surface.getMaxItems() != null + || minItemsLex != null || maxItemsLex != null) { + supported.add("array-length"); + if (minItemsLex != null) { + validateParams.put("validation-min-items", minItemsLex); + } else if (surface.getMinItems() != null) { + validateParams.put("validation-min-items", + surface.getMinItems()); + } + if (maxItemsLex != null) { + validateParams.put("validation-max-items", maxItemsLex); + } else if (surface.getMaxItems() != null) { + validateParams.put("validation-max-items", + surface.getMaxItems()); + } + validateParams.put("has-validation-array-length", true); + } + // uniqueItems: PRESENCE (any value) so the keyword never + // fail-closes; `false` is a no-op that still emits the node. + if (surface.getUniqueItems() != null) { + supported.add("unique-items"); + validateParams.put("has-validation-unique-items", true); + validateParams.put("validation-unique-items", + surface.getUniqueItems()); + } + // required: supported — presence check is generated in validator + if (surface.getRequired() != null) { + supported.add("object-properties"); + validateParams.put("validation-required", + surface.getRequired()); + validateParams.put("has-validation-object-props", true); + } + // Properties become child IR rows because their schemas affect + // branch membership and must never be skipped. + if (surface.getProperties() != null + && !surface.getProperties().isEmpty()) { + supported.add("object-properties"); + validateParams.put("validation-properties", + surface.getProperties()); + validateParams.put("has-validation-properties", true); + } + // additionalProperties is tri-state: absent/true allows, false + // rejects, and a schema validates each additional member. + Object addPropsVal = surface.getAdditionalProperties(); + if (addPropsVal != null) { + supported.add("additional-properties"); + if (addPropsVal instanceof Schema) { + Schema addPropSchema = (Schema) addPropsVal; + Boolean apBool = addPropSchema.getBooleanSchemaValue(); + if (apBool != null) { + validateParams.put("validation-additional-properties-kind", + Boolean.TRUE.equals(apBool) ? "allowed" : "reject"); + } else { + validateParams.put("validation-additional-properties-kind", "schema"); + validateParams.put("validation-additional-properties-schema", addPropSchema); + } + } else if (addPropsVal instanceof Boolean) { + validateParams.put("validation-additional-properties-kind", + Boolean.TRUE.equals(addPropsVal) ? "allowed" : "reject"); + } + } + String minPropsLex = Oas31RawSpecRecovery.countBoundLexemeOf(surface, "minProperties"); + String maxPropsLex = Oas31RawSpecRecovery.countBoundLexemeOf(surface, "maxProperties"); + if (surface.getMinProperties() != null || minPropsLex != null) { + supported.add("object-property-count"); + validateParams.put("validation-min-properties", + minPropsLex != null + ? minPropsLex : surface.getMinProperties()); + } + if (surface.getMaxProperties() != null || maxPropsLex != null) { + supported.add("object-property-count"); + validateParams.put("validation-max-properties", + maxPropsLex != null + ? maxPropsLex : surface.getMaxProperties()); + } + // Nested allOf, anyOf, and oneOf each become applicator children; + // all three may coexist. A $ref branch excludes this inline scan + // because its target row owns the referenced composition. + if (!refBranchExcluded) { + if (surface.getOneOf() != null && !surface.getOneOf().isEmpty()) { + validateParams.put("validation-oneof-schemas", surface.getOneOf()); + } + if (surface.getAnyOf() != null && !surface.getAnyOf().isEmpty()) { + validateParams.put("validation-anyof-schemas", surface.getAnyOf()); + } + if (surface.getAllOf() != null && !surface.getAllOf().isEmpty()) { + validateParams.put("validation-allof-schemas", surface.getAllOf()); + } + } + // unevaluatedItems accepts either a boolean or schema value. + if (surface.getUnevaluatedItems() != null) { + validateParams.put("validation-unevaluated-items", + surface.getUnevaluatedItems()); + } + // Applied conditional branches contribute annotations and + // evaluated coverage to enclosing unevaluated checks. + if (surface.getIf() != null) { + validateParams.put("validation-if", surface.getIf()); + } + if (surface.getThen() != null) { + validateParams.put("validation-then", surface.getThen()); + } + if (surface.getElse() != null) { + validateParams.put("validation-else", surface.getElse()); + } + if (surface.getDependentSchemas() != null + && !surface.getDependentSchemas().isEmpty()) { + validateParams.put("validation-dependent-schemas", + surface.getDependentSchemas()); + } + // Resolved nested composition is evaluated by the model's IR row. + // `not` is carried as a child schema into the same evaluator. + if (surface.getNot() != null) { + validateParams.put("validation-not-schema", surface.getNot()); + } + + // Detect unsupported assertion keywords + io.swagger.v3.oas.models.media.Discriminator targetDisc = + surface.getDiscriminator(); + if (targetDisc != null) { + // Discriminator on branches is annotation-only for now + } + // if/then/else: not yet implemented as a conditional applicator; + // NOT fail-closed so "ref-to-if" corpora still GENERATE and run + // (the inline-ref/if-schema content is densified via $id + // resolution; honest: a bare if-then-else without a covering ref + // is ignored, measured as FAIL not BLOCKED). + if (surface.getIf() != null) { + validateParams.put("validation-if-schema", surface.getIf()); + } + if (surface.getThen() != null) { + validateParams.put("validation-then-schema", surface.getThen()); + } + if (surface.getElse() != null) { + validateParams.put("validation-else-schema", surface.getElse()); + } + // dependentRequired: the parser MERGES the required lists of + // multi-entry maps into one shared corrupt list (see + // recoverPristineLiterals (c) + + // DependentRequiredParserRetentionTest); the raw-literal + // recovery extension is authoritative when present. + Object depReqNative = surface.getDependentRequired(); + if (depReqNative != null + && !((java.util.Map) depReqNative).isEmpty() + && surface.getExtensions() != null + && surface.getExtensions().containsKey( + "x-oas31-dependent-required")) { + depReqNative = surface.getExtensions() + .get("x-oas31-dependent-required"); + } + if (depReqNative instanceof java.util.Map + && !((java.util.Map) depReqNative).isEmpty()) { + supported.add("dependent-required"); + validateParams.put("validation-dependent-required", + depReqNative); + } + // `contains` becomes a child row and min/maxContains remain exact + // count bounds. Both bounds are inert without `contains`. + if (surface.getContains() != null) { + supported.add("contains"); + validateParams.put("validation-contains-schema", + surface.getContains()); + String minC = Oas31RawSpecRecovery.countBoundLexemeOf(surface, "minContains"); + String maxC = Oas31RawSpecRecovery.countBoundLexemeOf(surface, "maxContains"); + if (surface.getMinContains() != null || minC != null) { + validateParams.put("validation-min-contains", + minC != null ? minC : surface.getMinContains()); + } + if (surface.getMaxContains() != null || maxC != null) { + validateParams.put("validation-max-contains", + maxC != null ? maxC : surface.getMaxContains()); + } + } else { + if (surface.getMinContains() != null + || surface.getMaxContains() != null) { + // inert per 2020-12 (no adjacent contains) — never + // fail generation, never fail validation. + supported.add("contains-count-inert"); + } + } + if (surface.getUnevaluatedProperties() != null) { + supported.add("unevaluated"); + validateParams.put("validation-unevaluated-properties", + surface.getUnevaluatedProperties()); + } + // contentEncoding, contentMediaType, and contentSchema are + // annotations under JSON Schema 2020-12 and never affect + // composition membership. + if (surface.getContentMediaType() != null) { + supported.add("content-media-type"); + } + if (surface.getContentEncoding() != null) { + supported.add("content-encoding"); + } + if (surface.getContentSchema() != null) { + supported.add("content-schema"); + } + // patternProperties and propertyNames become child rows and are + // densified through the same full raw-schema path. + if (surface.getPatternProperties() != null + && !surface.getPatternProperties().isEmpty()) { + supported.add("pattern-properties"); + validateParams.put("validation-pattern-properties", + surface.getPatternProperties()); + } + if (surface.getPropertyNames() != null) { + supported.add("property-names"); + validateParams.put("validation-property-names", + surface.getPropertyNames()); + } + // Preserve boolean schemas so the evaluator can implement true + // as always-valid and false as always-invalid. + if (surface.getBooleanSchemaValue() != null) { + validateParams.put("validation-boolean-value", + surface.getBooleanSchemaValue()); + } + // Annotation-vocabulary keywords use the same parameter channel. + { + final java.util.Map vp2 = validateParams; + final java.util.List sup = supported; + readAnnotationKeywords(surface, (key, value) -> { + vp2.put("validation-ann-" + key, value); + if (key.equals("comment") || key.startsWith("extra:") + || key.equals("title") || key.equals("description") + || key.equals("default") || key.equals("examples") + || key.equals("format") || key.equals("contentEncoding") + || key.equals("contentMediaType") + || key.equals("contentSchema") + || key.equals("deprecated") || key.equals("readOnly") + || key.equals("writeOnly")) { + sup.add("annotation:" + key); + } + }); + } + } + + /** + * Reads annotation-vocabulary keywords into a key/value sink. Every value + * is serialized as one complete JSON value. {@code $comment} is checked for + * string shape but deliberately does not produce annotation output. + */ + static void readAnnotationKeywords( + io.swagger.v3.oas.models.media.Schema schema, + BiConsumer sink) { + if (schema == null) return; + if (schema.getTitle() != null) { + sink.accept("title", toJsonLiteral(schema.getTitle())); + } + if (schema.getDescription() != null) { + sink.accept("description", toJsonLiteral(schema.getDescription())); + } + String pristineDefaultJson = Oas31RawSpecRecovery.defaultJsonOf(schema); + if (pristineDefaultJson != null) { + sink.accept("default", pristineDefaultJson); + } else if (schema.getDefault() != null) { + sink.accept("default", toJsonLiteral(schema.getDefault())); + } + String pristineExamplesJson = Oas31RawSpecRecovery.examplesJsonOf(schema); + if (pristineExamplesJson != null) { + sink.accept("examples", pristineExamplesJson); + } else if (schema.getExamples() != null) { + sink.accept("examples", toJsonLiteral(schema.getExamples())); + } + if (schema.getDeprecated() != null) { + sink.accept("deprecated", toJsonLiteral(schema.getDeprecated())); + } + if (schema.getReadOnly() != null) { + sink.accept("readOnly", toJsonLiteral(schema.getReadOnly())); + } + if (schema.getWriteOnly() != null) { + sink.accept("writeOnly", toJsonLiteral(schema.getWriteOnly())); + } + if (schema.getFormat() != null) { + sink.accept("format", toJsonLiteral(schema.getFormat())); + } + if (schema.getContentEncoding() != null) { + sink.accept("contentEncoding", toJsonLiteral(schema.getContentEncoding())); + } + if (schema.getContentMediaType() != null) { + sink.accept("contentMediaType", toJsonLiteral(schema.getContentMediaType())); + } + if (schema.getContentSchema() != null) { + sink.accept("contentSchema", toJsonLiteral(schema.getContentSchema())); + } + Object comment = schema.get$comment(); + if (comment != null) { + sink.accept("comment", comment instanceof String + ? (String) comment : "NON-STRING"); + if (!(comment instanceof String)) { + sink.accept("comment-shape-violation", "TRUE"); + } + } + if (schema.getExtensions() != null) { + for (Object entryObject : schema.getExtensions().entrySet()) { + Map.Entry entry = (Map.Entry) entryObject; + String key = String.valueOf(entry.getKey()); + if (key.startsWith("x-oas31-")) { + continue; + } + sink.accept("extra:" + key, toJsonLiteral(entry.getValue())); + } + } + } + + private static String toJsonLiteral(Object value) { + try { + return Json.mapper().writeValueAsString(value); + } catch (com.fasterxml.jackson.core.JsonProcessingException ex) { + throw new IllegalArgumentException("Unable to serialize a schema JSON value", ex); + } + } +} 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..e81af71a107a 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 @@ -7,7 +7,25 @@ if (POLICY CMP0167) cmake_policy(SET CMP0167 OLD) endif () -find_package(Boost 1.75 REQUIRED) +set(BOOST_BOOST_TARGET_PREDEFINED FALSE) +set(BOOST_JSON_TARGET_PREDEFINED FALSE) +if (TARGET Boost::boost) + set(BOOST_BOOST_TARGET_PREDEFINED TRUE) +endif () +if (TARGET Boost::json) + set(BOOST_JSON_TARGET_PREDEFINED TRUE) +endif () + +find_package(Boost 1.75 REQUIRED COMPONENTS json) + +# Imported targets created in this subdirectory are otherwise invisible to +# sibling consumers when this project is included with add_subdirectory(). +if (NOT BOOST_BOOST_TARGET_PREDEFINED) + set_property(TARGET Boost::boost PROPERTY IMPORTED_GLOBAL TRUE) +endif () +if (NOT BOOST_JSON_TARGET_PREDEFINED) + set_property(TARGET Boost::json PROPERTY IMPORTED_GLOBAL TRUE) +endif () set(THREADS_TARGET_PREDEFINED FALSE) if (TARGET Threads::Threads) set(THREADS_TARGET_PREDEFINED TRUE) @@ -48,8 +66,11 @@ endif () add_library(${PROJECT_NAME} SHARED) +{{#hasExportMacro}} +set_property(TARGET ${PROJECT_NAME} PROPERTY DEFINE_SYMBOL "{{exportDefine}}") +{{/hasExportMacro}} -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) @@ -71,15 +92,33 @@ target_sources(${PROJECT_NAME} PRIVATE {{/apis}} {{/apiInfo}} # other +{{#hasExportMacro}} + api/ApiExport.h +{{/hasExportMacro}} api/HttpClient.h api/HttpClientImpl.cpp api/HttpClientImpl.h model/AnyType.h + model/ValidationTypes.h + model/NullableField.h + model/Oas31DeepEqual.h + model/Oas31ExactNumber.cpp + model/Oas31ExactNumber.h + model/Oas31SchemaIr.h + model/Oas31ExactJson.h + model/Oas31Validator.h +{{#validateOnDecode}} + model/schema_ir.generated.cpp +{{#oas31SchemaIrChunkFiles}} + model/{{filename}} +{{/oas31SchemaIrChunkFiles}} + model/Oas31SchemaRegistry.h +{{/validateOnDecode}} ) target_link_libraries(${PROJECT_NAME} - PUBLIC Boost::boost OpenSSL::SSL Threads::Threads) + PUBLIC Boost::boost Boost::json OpenSSL::SSL Threads::Threads) target_include_directories(${PROJECT_NAME} PUBLIC $ @@ -96,4 +135,26 @@ install(TARGETS ${PROJECT_NAME} install(DIRECTORY api model DESTINATION "${CMAKE_INSTALL_INCLUDEDIR}/${PROJECT_NAME}" - FILES_MATCHING PATTERN "*.h") + FILES_MATCHING PATTERN "*.h" PATTERN "*.hpp") + +# ────────────────────────────────────────────────────────────────────── +# Wire-level multipart encoding test executable. +# Build and run: +# cmake -DMULTIPART_ENCODING_SELF_TEST=ON -B build +# cmake --build build --parallel +# ctest --test-dir build --output-on-failure +# ────────────────────────────────────────────────────────────────────── +option(MULTIPART_ENCODING_SELF_TEST "Enable multipart encoding wire-level self-test" OFF) +if (MULTIPART_ENCODING_SELF_TEST) + enable_testing() + add_executable(${PROJECT_NAME}_multipart_wire_test + test/MultipartWireTest.cpp) + + set_property(TARGET ${PROJECT_NAME}_multipart_wire_test + PROPERTY CXX_STANDARD 17) + target_link_libraries(${PROJECT_NAME}_multipart_wire_test + PRIVATE Boost::boost) + + add_test(NAME multipart-encoding-wire-test + COMMAND ${PROJECT_NAME}_multipart_wire_test) +endif () diff --git a/modules/openapi-generator/src/main/resources/cpp-boost-beast-client/MultipartWireTest.cpp.mustache b/modules/openapi-generator/src/main/resources/cpp-boost-beast-client/MultipartWireTest.cpp.mustache new file mode 100644 index 000000000000..5f3f35d76d6d --- /dev/null +++ b/modules/openapi-generator/src/main/resources/cpp-boost-beast-client/MultipartWireTest.cpp.mustache @@ -0,0 +1,262 @@ +// ----------------------------------------------------------------------------- +// Wire-level multipart encoding regression executable. +// Exercises the shared serializer used by generated API implementations. Run via +// CTest: +// cmake -DMULTIPART_ENCODING_SELF_TEST=ON -B build +// cmake --build build --parallel +// ctest --test-dir build --output-on-failure +// ----------------------------------------------------------------------------- + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "../api/HttpClient.h" + +{{#apiNamespaceDeclarations}} +namespace {{this}} { +{{/apiNamespaceDeclarations}} + +namespace { + +[[noreturn]] void fail(const std::string& message) { + throw std::runtime_error(message); +} + +void require(bool condition, const std::string& message) { + if (!condition) { + fail(message); + } +} + +struct MultipartPart { + std::string contentDisposition; + std::string contentType; + std::string payload; +}; + +std::vector parseMultipartBody( + const std::string& wire, const std::string& boundary) { + const std::string delimiter = "--" + boundary; + std::vector parts; + std::size_t offset = 0; + + while (true) { + require(wire.compare(offset, delimiter.size(), delimiter) == 0, + "multipart part must begin with its boundary delimiter"); + offset += delimiter.size(); + if (wire.compare(offset, 4, "--\r\n") == 0) { + offset += 4; + require(offset == wire.size(), "multipart data must end after closing boundary"); + return parts; + } + require(wire.compare(offset, 2, "\r\n") == 0, + "multipart boundary must be followed by CRLF"); + offset += 2; + + const std::size_t headerEnd = wire.find("\r\n\r\n", offset); + require(headerEnd != std::string::npos, "multipart part must terminate headers"); + MultipartPart part; + std::size_t headerOffset = offset; + while (headerOffset < headerEnd) { + const std::size_t lineEnd = wire.find("\r\n", headerOffset); + require(lineEnd != std::string::npos && lineEnd <= headerEnd, + "multipart header line must terminate with CRLF"); + const std::size_t colon = wire.find(':', headerOffset); + require(colon != std::string::npos && colon < lineEnd, + "multipart header must have a name and value"); + const std::string headerName = wire.substr(headerOffset, colon - headerOffset); + const std::string headerValue = wire.substr(colon + 1, lineEnd - colon - 1); + require(!headerValue.empty() && headerValue.front() == ' ', + "multipart header values must have one leading space"); + if (headerName == "Content-Disposition") { + part.contentDisposition = headerValue.substr(1); + } else if (headerName == "Content-Type") { + part.contentType = headerValue.substr(1); + } + headerOffset = lineEnd + 2; + } + require(!part.contentDisposition.empty(), + "multipart part must have Content-Disposition"); + + const std::size_t payloadOffset = headerEnd + 4; + const std::size_t nextBoundary = wire.find("\r\n" + delimiter, payloadOffset); + require(nextBoundary != std::string::npos, + "multipart part payload must be followed by a boundary delimiter"); + part.payload = wire.substr(payloadOffset, nextBoundary - payloadOffset); + parts.emplace_back(std::move(part)); + offset = nextBoundary + 2; + } +} + +void requirePart(const std::vector& parts, std::size_t index, + const std::string& disposition, const std::string& contentType, + const std::string& payload) { + require(index < parts.size(), "multipart part index must exist"); + const MultipartPart& part = parts[index]; + require(part.contentDisposition == disposition, + "multipart Content-Disposition must match the encoded field"); + require(part.contentType == contentType, + "multipart Content-Type must match the encoded field"); + require(part.payload == payload, "multipart payload must match the encoded field"); +} + +void testExplicitEncoding() { + const std::string boundary = "==BOUNDARY=="; + std::vector parameters; + parameters.emplace_back("avatar", "\x89PNG\r\n\x1a\n", true, "image/png", "avatar"); + parameters.emplace_back("report", "%PDF-1.4", true, "application/pdf", "report"); + + const std::vector parts = parseMultipartBody( + serializeMultipartFormData(parameters, boundary), boundary); + require(parts.size() == 2, "explicit encoding must produce both parts"); + requirePart(parts, 0, "form-data; name=\"avatar\"; filename=\"avatar\"", + "image/png", "\x89PNG\r\n\x1a\n"); + requirePart(parts, 1, "form-data; name=\"report\"; filename=\"report\"", + "application/pdf", "%PDF-1.4"); + + parameters.clear(); + parameters.emplace_back("description", "A description", false); + const std::vector stringParts = parseMultipartBody( + serializeMultipartFormData(parameters, boundary), boundary); + require(stringParts.size() == 1, "a text field must produce one part"); + requirePart(stringParts, 0, "form-data; name=\"description\"", "", "A description"); +} + +void testDefaultContentTypes() { + const std::string boundary = "==BOUNDARY=="; + + std::vector textParameters; + textParameters.emplace_back("textContent", "Hello", false, "text/plain"); + const std::vector textParts = parseMultipartBody( + serializeMultipartFormData(textParameters, boundary), boundary); + requirePart(textParts, 0, "form-data; name=\"textContent\"", "text/plain", "Hello"); + + std::vector arrayParameters; + arrayParameters.emplace_back("tags", "a,b,c", false, "text/plain"); + const std::vector arrayParts = parseMultipartBody( + serializeMultipartFormData(arrayParameters, boundary), boundary); + requirePart(arrayParts, 0, "form-data; name=\"tags\"", "text/plain", "a,b,c"); + + std::vector jsonParameters; + jsonParameters.emplace_back("payload", "{\"key\":\"value\"}", false, + "application/json"); + jsonParameters.emplace_back("items", "[{\"id\":1},{\"id\":2}]", false, + "application/json"); + const std::vector jsonParts = parseMultipartBody( + serializeMultipartFormData(jsonParameters, boundary), boundary); + require(jsonParts.size() == 2, "JSON fields must produce both parts"); + requirePart(jsonParts, 0, "form-data; name=\"payload\"", "application/json", + "{\"key\":\"value\"}"); + requirePart(jsonParts, 1, "form-data; name=\"items\"", "application/json", + "[{\"id\":1},{\"id\":2}]"); + + std::vector binaryParameters; + binaryParameters.emplace_back("rawData", "binary-payload", true, + "application/octet-stream", "rawData"); + const std::vector binaryParts = parseMultipartBody( + serializeMultipartFormData(binaryParameters, boundary), boundary); + requirePart(binaryParts, 0, "form-data; name=\"rawData\"; filename=\"rawData\"", + "application/octet-stream", "binary-payload"); +} + +void testContentDispositionAndMixedEncoding() { + const std::string boundary = "==BOUNDARY=="; + std::vector parameters; + parameters.emplace_back("fieldA", "valueA", false); + parameters.emplace_back("fieldB", "valueB", true, "image/png", "photo.png"); + parameters.emplace_back("fieldC", "valueC", true, "image/png", "fieldC"); + parameters.emplace_back("fieldD", "valueD", true, "image/png", ""); + parameters.emplace_back("signature", "sig", true, "application/octet-stream", "signature"); + + const std::vector parts = parseMultipartBody( + serializeMultipartFormData(parameters, boundary), boundary); + require(parts.size() == 5, "mixed encoding must preserve all field parts"); + requirePart(parts, 0, "form-data; name=\"fieldA\"", "", "valueA"); + requirePart(parts, 1, "form-data; name=\"fieldB\"; filename=\"photo.png\"", + "image/png", "valueB"); + requirePart(parts, 2, "form-data; name=\"fieldC\"; filename=\"fieldC\"", + "image/png", "valueC"); + requirePart(parts, 3, "form-data; name=\"fieldD\"; filename=\"\"", + "image/png", "valueD"); + requirePart(parts, 4, "form-data; name=\"signature\"; filename=\"signature\"", + "application/octet-stream", "sig"); +} + +void testHeaderInjectionRejected() { + std::vector filenameParameters; + filenameParameters.emplace_back( + "file", "value", true, "application/octet-stream", "bad\r\nname"); + bool filenameRejected = false; + try { + (void)serializeMultipartFormData(filenameParameters, "==BOUNDARY=="); + } catch (const std::invalid_argument&) { + filenameRejected = true; + } + require(filenameRejected, "multipart serializer must reject CRLF in filenames"); + + std::vector contentTypeParameters; + contentTypeParameters.emplace_back( + "file", "value", true, "text/plain\r\nX-Evil: yes", "file"); + bool contentTypeRejected = false; + try { + (void)serializeMultipartFormData(contentTypeParameters, "==BOUNDARY=="); + } catch (const std::invalid_argument&) { + contentTypeRejected = true; + } + require(contentTypeRejected, "multipart serializer must reject CRLF in content types"); +} + +void testBoundarySelectionAndWireStructure() { + std::vector ordinaryParameters; + ordinaryParameters.emplace_back("data", "value", false); + require(selectMultipartBoundary(ordinaryParameters) == "OpenAPIGeneratorBoundary", + "a non-colliding payload must retain the stable default boundary"); + + std::vector collidingParameters; + collidingParameters.emplace_back("data", "OpenAPIGeneratorBoundary", true, "", "data"); + const std::string selectedBoundary = selectMultipartBoundary(collidingParameters); + require(selectedBoundary != "OpenAPIGeneratorBoundary", + "multipart boundary must not appear in a payload"); + require(selectedBoundary.find("OpenAPIGeneratorBoundary") == 0, + "replacement multipart boundary must retain its stable prefix"); + require(selectedBoundary.size() <= 70, + "multipart boundary must respect the RFC 2046 length limit"); + + const std::string boundary = "TESTBOUNDARY"; + std::vector parameters; + parameters.emplace_back("field", "value", false); + const std::string wire = serializeMultipartFormData(parameters, boundary); + const std::vector parts = parseMultipartBody(wire, boundary); + require(parts.size() == 1, "wire structure must contain one parsed part"); + requirePart(parts, 0, "form-data; name=\"field\"", "", "value"); +} + +} // namespace + +{{#apiNamespaceDeclarations}} +} // namespace {{this}} +{{/apiNamespaceDeclarations}} + +int main() { + try { + using namespace {{apiNamespace}}; + testExplicitEncoding(); + testDefaultContentTypes(); + testContentDispositionAndMixedEncoding(); + testHeaderInjectionRejected(); + testBoundarySelectionAndWireStructure(); + return EXIT_SUCCESS; + } catch (const std::exception& exception) { + std::cerr << "multipart wire test failed: " << exception.what() << '\n'; + return EXIT_FAILURE; + } +} diff --git a/modules/openapi-generator/src/main/resources/cpp-boost-beast-client/NullableField.h.mustache b/modules/openapi-generator/src/main/resources/cpp-boost-beast-client/NullableField.h.mustache new file mode 100644 index 000000000000..af8ad27bd92e --- /dev/null +++ b/modules/openapi-generator/src/main/resources/cpp-boost-beast-client/NullableField.h.mustache @@ -0,0 +1,175 @@ +{{>licenseInfo}} +// ============================================================================ +// NullableField — tri-state nullable wrapper +// +// Tracks effective value and wire presence independently. A schema default can +// supply an effective Null or Value while the property remains absent on the +// wire; only an explicit setter or decoder input marks it present. +// +// Property mapping: +// Required, non-null -> T +// Optional, non-null -> std::optional +// Required, nullable -> null-capable value with Missing rejected +// Optional, nullable -> NullableField +// ============================================================================ + +#ifndef {{schemaValidationHeaderGuardPrefix}}_NULLABLE_FIELD_H_ +#define {{schemaValidationHeaderGuardPrefix}}_NULLABLE_FIELD_H_ + +#include +#include +#include +#include + +{{#modelNamespaceDeclarations}} +namespace {{this}} { +{{/modelNamespaceDeclarations}} + +template +class NullableField { +public: + enum class State { Missing, Null, Value }; + + // Default: missing with no effective value. + NullableField() = default; + + // Construct an explicitly present value. + explicit NullableField(T v) + : state_(State::Value) + , value_(std::move(v)) + , missing_(false) + { + } + + // Factories for explicitly present values. + static NullableField makeNull() { + NullableField result; + result.state_ = State::Null; + result.missing_ = false; + return result; + } + + static NullableField makeValue(T v) { + return NullableField(std::move(v)); + } + + // Schema defaults provide an effective value without wire presence. + static NullableField makeDefaultNull() { + NullableField result; + result.state_ = State::Null; + return result; + } + + static NullableField makeDefaultValue(T v) { + NullableField result; + result.state_ = State::Value; + result.value_ = std::move(v); + return result; + } + + // Effective-value and wire-presence queries are intentionally independent. + bool isMissing() const noexcept { return missing_; } + bool isNull() const noexcept { return state_ == State::Null; } + bool hasValue() const noexcept { return state_ == State::Value; } + + /// Compatible with PropertyStorage hasOptionalValue() convention. + bool hasOptionalValue() const noexcept { return !missing_; } + + /// Resets to Missing state. Compatible with PropertyStorage + /// resetOptionalValue() convention. + void resetOptionalValue() { resetMissing(); } + + /// Access the held value. Throws when state is Missing or Null. + T const& value() const { + requireValueState(); + return value_; + } + + T& value() { + requireValueState(); + return value_; + } + + /// Transition to an explicitly present Null state. + void setNull() { + state_ = State::Null; + value_ = T(); + missing_ = false; + } + + /// Marks an effective default as explicitly present without changing its value. + /// Missing fields without an effective value remain missing. + void promotePresent() noexcept { + if (state_ != State::Missing) { + missing_ = false; + } + } + + /// Reset to missing with no effective value. Generated model decoders + /// restore schema defaults with makeDefaultNull/makeDefaultValue instead. + void resetMissing() { + state_ = State::Missing; + value_ = T(); + missing_ = true; + } + + // Comparison includes presence because defaulted-missing and explicit values + // serialize differently even when their effective values are equal. + bool operator==(NullableField const& other) const { + if (missing_ != other.missing_ || state_ != other.state_) return false; + if (state_ == State::Value) return value_ == other.value_; + return true; + } + + bool operator!=(NullableField const& other) const { + return !(*this == other); + } + + /// Convert to boost::json::value: + /// Missing -> returns a null json::value (caller decides to omit key) + /// Null -> returns nullptr (JSON null) + /// Value -> returns boost::json::value_from(value_) + boost::json::value toJsonValue() const { + switch (state_) { + case State::Null: + return nullptr; + case State::Value: + return boost::json::value_from(value_); + case State::Missing: + default: + return nullptr; + } + } + +private: + void requireValueState() const { + if (state_ != State::Value) { + throw std::logic_error( + state_ == State::Null + ? "NullableField::value() called in Null state" + : "NullableField::value() called without an effective value"); + } + } + + State state_{State::Missing}; + T value_{}; + bool missing_{true}; +}; + +// ============================================================================ +// IsNullableField — trait to detect NullableField at compile time. +// Used by PropertyStorage and other generic wrappers to select the correct +// forwarding methods (hasValue/isNull/setNull/resetMissing) via if constexpr. +// ============================================================================ + +template +struct IsNullableField : std::false_type {}; + +template +struct IsNullableField> : std::true_type {}; + +{{#modelNamespaceDeclarations}} +} +{{/modelNamespaceDeclarations}} + +#endif /* {{schemaValidationHeaderGuardPrefix}}_NULLABLE_FIELD_H_ */ 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..be61bf941f79 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,417 @@ +# {{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. + +### Composed model strategy + +The generator treats composed schemas (`oneOf`, `anyOf`, `allOf`) as first-class +C++ types rather than empty object shells. Generated model headers, source, and +API surfaces therefore differ from the legacy composed-model output. + +| Before | After | +|--------|-------| +| Empty struct `AnyOfStringArray {}` | `std::variant>` | +| Composed → `{}` on wire | Correct encode/decode via `toJsonValue`/`fromJsonValue` | +| Polymorphism disabled | `oneOf`/`anyOf`/`allOf` with generated composition validators (see support matrix) | + +### Ordered composition lowering + +The generator applies the following rules in order to every composed schema: + +1. **Intersect compatible `allOf` schemas** into a flat synthetic schema. Unsatisfiable intersections fail generation. +2. **Lower `anyOf`/`oneOf` null unions** with one definitely non-null branch to `std::optional` (and handle OAS 3.0 `nullable`). +3. **Collapse all-string `anyOf` branches only when none carries enum constraints.** +4. **Flatten nested variants and preserve remaining null alternatives.** +5. **Tag duplicate C++ storage types** with `CompositionBranchValue` so schema identity and match counts survive type erasure. +6. **Use `boost::json::value` where oneOf exclusivity cannot survive static C++ type lowering.** +7. **Collapse one remaining branch; otherwise emit `std::variant`.** + +### Composition validation support + +The generator produces a shared schema IR and evaluator for composition +membership. The matrix states the implemented runtime behavior and its explicit +boundaries; it does not claim to be a complete JSON Schema meta-schema validator. + +| Keyword or family | Generated behavior | Boundary / note | +|-------------------|--------------------|-----------------| +| `oneOf` | Exactly one branch must validate | Match counts use schema validators, not C++ type uniqueness | +| `anyOf` | At least one branch must validate | The first matching branch is stored in source order | +| `allOf` | Every branch validates; object schemas use recursive flat intersection | Unsatisfiable intersections fail generation | +| `$ref` | Resolved component targets and all sibling keywords apply | Unresolved targets fail generation; active `(schema, instance)` pairs are cycle-guarded | +| `$dynamicRef` | Not accepted directly from source documents | Generation fails closed rather than treating it as an ordinary or empty schema | +| `not` | The nested schema must fail | Implemented by the shared evaluator | +| Boolean value schemas | `true` always validates; `false` never validates | Applies to OAS 3.1 schema-valued positions | +| `type` and type arrays | String, boolean, integer, number, array, object, and null | Mathematical integers include `1.0` | +| `enum` / `const` | Exact membership across mixed scalar and deep array/object values | Numeric members retain their exact decimal value | +| Numeric bounds / `multipleOf` | Exact arbitrary-precision decimal evaluation | See the numeric limits below | +| String assertions | `minLength`, `maxLength`, and the supported ECMA-262 pattern subset | Unsupported regex constructs fail closed | +| Array assertions | `prefixItems`, `items`, `minItems`, `maxItems`, `uniqueItems`, `contains`, `minContains`, `maxContains` | Contains bounds are inert when `contains` is absent | +| Object assertions | `required`, `properties`, `patternProperties`, `additionalProperties`, `propertyNames`, `minProperties`, `maxProperties`, `dependentRequired`, `dependentSchemas` | Nested paths retain instance-location diagnostics | +| `unevaluatedProperties` / `unevaluatedItems` | Location-scoped evaluated-member/item tracking across successful applicator branches | This is evaluator coverage tracking, not meta-schema validation | +| `if` / `then` / `else` | Conditional children are evaluated transactionally | Only the selected branch contributes validity, annotations, and evaluated coverage | +| Annotation vocabulary | Title, description, default, examples, deprecated, read/write flags, content metadata, unknown keywords | Collected as annotations and never change the verdict | +| `format` | Annotation only | `formatAssertionPolicy=strict` is rejected because format assertions are not implemented | +| `discriminator` | Orders candidates and improves diagnostics | It never overrides schema match counts; invalid mappings fail generation | + +See `model-source.mustache` for the complete validation scope comment in each +generated model. + +### Exact JSON numbers and implementation limits + +OAS 3.1 validation reads every instance number from its original JSON token. +`ExactNumber` stores an arbitrary-precision integer mantissa and an +arbitrary-precision base-10 exponent, so range checks, mathematical-integer +checks, numeric `enum`/`const`, and `multipleOf` do not pass through `double`. +This includes short tokens with very large exponents such as `1e2147483648`. + +The implementation has one explicit resource limit: a single JSON number token +may contain at most 4096 bytes. Longer tokens are rejected with +`std::length_error`; the limit is based on input size, not numeric magnitude. +Legacy `ExactNumber::add` and `divmod` helpers also reject operations that would +expand by more than 4096 decimal places. Generated validation uses the +non-expanding comparison and divisibility paths, including for huge exponents. + +The exact domain belongs to validation and deep JSON equality. The public model +mapping remains `double` for unconstrained OpenAPI `number` values and fixed-width +integers for `int32`/`int64`; conversion happens only after exact validation and +rejects non-finite or out-of-range destinations. A model stored as `double` does +not promise byte-for-byte numeric round-tripping after application mutation. +If Boost.JSON cannot represent an input number (or would store it as a non-finite +`double`), exact schema validation can still use the original token internally, +but public model, response, and SSE conversion rejects the payload rather than +exposing a surrogate or a non-JSON value. + +### Architecture decision index + +The implementation uses these stable decision IDs in generated support-header +comments: + +| ID | Decision | Implementation | +|----|----------|----------------| +| D1 | Preserve JSON numbers as exact base-10 mantissa/exponent pairs | `Oas31ExactNumber.h`, `Oas31ExactJson.h` | +| D2 | Validate through a raw-instance view before destination conversion | `Oas31Validator.h` | +| D3 | Make branch annotations and evaluated coverage transactional | `ValidationContext` in `Oas31Validator.h` | +| D5 | Densify schemas into one registry interpreted by a shared evaluator; partition large registries into bounded translation units | `Oas31SchemaIr.h`, `Oas31SchemaRegistry.h`, `schema_ir.generated*.cpp` | + +### 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 | Flat value class built from the recursive property intersection | +| 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 + +### Encoding rules + +Generated models keep effective values separate from wire presence: + +| 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. + +### Optional nullable fields + +The generator now implements a full **tri-state model** for optional nullable +fields using `NullableField`. Three wire states are preserved on round-trip: + +| Field kind | C++ type | Encode behavior | +|-----------|----------|----------------| +| Required, non-null | `T` | Always written | +| Optional, non-null | `std::optional` | `has_value()` → written; disengaged → omitted | +| Required, nullable | `std::optional` | Key required; value or `null` accepted | +| Optional, nullable | `NullableField` | `hasValue()` → written; `isNull()` → JSON `null`; `isMissing()` → omitted | + +`NullableField` exposes: +- `isMissing()` / `isNull()` / `hasValue()` — state queries +- `hasOptionalValue()` / `resetOptionalValue()` — compatibility with + property-storage conventions +- `setNull()` — transition to null state +- `resetMissing()` — transition to missing state + +### Schema defaults and wire presence + +Scalar schema defaults initialize the value returned by a getter without +marking an optional property as present. A default-constructed model therefore +omits that key from JSON. Calling the setter marks it present, even when the +assigned value equals the schema default. Decoding an object without the key +restores the schema default and clears wire presence. + +For optional nullable properties, effective state and presence are independent: + +| Schema default | Effective queries while omitted | JSON output | +|----------------|---------------------------------|-------------| +| none | `isMissing()` only | key omitted | +| `null` | `isMissing()` and `isNull()` | key omitted | +| concrete value | `isMissing()` and `hasValue()` | key omitted | + +An explicit wire `null` clears `isMissing()` and sets `isNull()`; an explicit +concrete value clears `isMissing()` and sets `hasValue()`. This preserves the +difference between omission, explicit null, and explicit value while still +honoring the schema's effective default. + +Schema-level OAS 3.0 nullable objects (e.g., `type: object, nullable: true`) +accept JSON `null` at the root level. The model's `fromJsonValue` decodes +`null` into a null-state instance; `toJsonValue` produces `null` when the +model is in the null state. + +### Non-conforming response compatibility + +Generated clients tolerate an explicit JSON `null` for a property whose schema +does not allow null. The value is treated as absent during normal model decoding +and composition-branch validation. Required keys must still be present, but a +present null leaves their generated storage at its default value. Properties +whose schemas allow null retain their normal null representation. + +Use strict schema decoding when the server is guaranteed to conform: + +```sh +openapi-generator generate -g cpp-boost-beast-client \ + -o output --additional-properties=tolerateNonNullableNulls=false +``` + +--- + +## Multipart form data + +Optional form parameters are generated as `boost::optional` arguments. Pass +`boost::none` to omit a part; an engaged empty string still emits an empty part. +Required form parameters retain their value types. +Value-typed overloads preserve existing call sites by forwarding form values as +engaged optionals. + +Variant-valued multipart fields follow the active branch's OpenAPI wire type. +Primitive and primitive-array branches use `text/plain` form serialization, so +string values are not JSON-quoted. Complex branches use `application/json`, and +binary branches use raw `application/octet-stream` data. An explicit Encoding +Object `contentType` overrides the default part content type. + +Each binary multipart method parameter has a companion `Filename` +argument. It defaults to the OpenAPI part name for source compatibility; pass +any value, including an empty string, to control the `filename` parameter in +`Content-Disposition`. Carriage returns and line feeds are rejected in part +names, filenames, and per-part content types. + +--- + +## 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 -Djacoco.skip=true \ + '-Dtest=org.openapitools.codegen.cppboostbeast.*Test' 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`. + +### SSE schema interpretation: two modes + +The generator option `sseSchemaMode` controls how `text/event-stream` response +schemas are interpreted: + +| Mode | Generated callback | +|------|--------------------| +| `representation` (default) | `SseEventCallback`: receives one owning `SseEvent` containing the raw `data`, `event`, `id`, and `retry` fields. No JSON conversion is applied. | +| `jsonEventData` | Receives `(const EventType&, const SseEvent&)`: the complete `data` payload is decoded with the generated schema-aware converter while the original SSE metadata remains available. | + +Both modes return `HttpResponseData`, which preserves the HTTP status, response +headers, a bounded non-stream/error body, and cancellation state. Returning +`false` from the callback stops delivery cooperatively. + +#### Representation mode (default) + +```sh +openapi-generator generate -g cpp-boost-beast-client \ + -o output --additional-properties=sseSchemaMode=representation +``` + +The callback receives `SseEvent` directly. Its `data` member contains all +consecutive `data:` lines joined by LF. `event` defaults to `message`, `id` +retains the stream's last valid event ID, and `retryMilliseconds` is populated +when the event contains a valid decimal `retry:` field. + +#### `jsonEventData` mode (typed convention) + +```sh +openapi-generator generate -g cpp-boost-beast-client \ + -o output --additional-properties=sseSchemaMode=jsonEventData +``` + +The generator treats each complete event `data` payload as one JSON value and +decodes it against the schema in the `text/event-stream` content entry. The +typed callback also receives the corresponding `SseEvent`. An event whose +complete data payload is exactly `[DONE]` is consumed as a terminator before +JSON decoding; representation mode delivers it normally. + +Typed SSE is a generator convention. OpenAPI does not define JSON typing for +each SSE `data` payload. Set `x-sse-event-data-schema: true` on an operation to +enable this convention for that operation while retaining representation mode +elsewhere. + +### Pure and conditional streaming operations + +A response that only exposes `text/event-stream` generates a streaming method +directly. A dual `application/json` / `text/event-stream` operation gains a +separate `{operationId}Stream` companion only when the request-side selector is +known. The normal method sends the selector as `false`; the stream companion +sends it as `true` and forces `Accept: text/event-stream`. + +Conditional contracts can be supplied with: + +- `sseOperationIds`: operation IDs using the conventional `stream` selector. +- `sseRequestPropertyMappings`: `operationId=booleanProperty` mappings. +- `sseEventTypeMappings`: `operationId=Model` typed-event mappings. +- `x-sse-request-property` and `x-sse-event-type`: operation-level equivalents. +- `inferConditionalSseOperations=true` (default): infer only when a dual-content + operation has an unambiguous boolean selector and event model. + +Dual media types alone do not create a stream companion. Ambiguous inference is +left unchanged rather than guessing. Explicit selector metadata is validated: +the operation must have both JSON and SSE responses and the selected request +property must exist and be boolean. + +### Incremental SSE transport + +`HttpClient::executeStream` reads the response incrementally with +`async_read_some`; it never buffers an entire successful event stream. Only a +2xx response with media type `text/event-stream` is framed. Generated API +methods reject a 2xx response with another content type instead of silently +reporting an empty stream. Non-2xx and wrong-content-type bodies retain the +configured aggregate `responseBodyLimit` (8 MiB by default) for diagnostics. + +`SseStreamOptions` supplies per-stream controls: + +| Member | Default | Behavior | +|--------|---------|----------| +| `maxLineBytes` | 64 KiB | Maximum decoded SSE line length. | +| `maxEventBytes` | 1 MiB | Maximum combined event data, type, and last-event ID storage. | +| `isCancelled` | empty | Optional cooperative cancellation predicate, checked between reads. | + +Returning `false` from the event callback also cancels the stream. The +transport is synchronous and permits one in-flight request per +`HttpClientImpl` instance. The configured operation timeout applies to each +network read, so an idle stream still times out without imposing a total stream +duration. + +#### WHATWG framing behavior + +| Input | Handling | +|-------|----------| +| Initial UTF-8 BOM | Consumed, including when split across network reads. | +| LF, CR, or CRLF | Accepted as line endings, including split CRLF boundaries. | +| `data:` | Consecutive values are joined with LF; an empty `data` field still dispatches an event. | +| `event:` | Preserved; omitted or empty values dispatch as `message`. | +| `id:` | Preserved across events; values containing NUL are ignored. | +| `retry:` | Preserved when the value contains decimal digits only and fits `uint64_t`. | +| `:` comment / unknown field | Ignored. | +| Event without a final blank line | Discarded at EOF. | + +Framing operates on raw bytes before optional JSON conversion. Line and event +limits are enforced across arbitrary network chunk boundaries. + +--- + +## 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. + +--- + + +## Requirements + +- C++17 compiler (GCC 7+, Clang 8+, MSVC 2019+) +- CMake 3.14+ +- Boost 1.75+ (Beast/Asio headers and the compiled Boost.JSON library) +- 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/anytype-header.mustache b/modules/openapi-generator/src/main/resources/cpp-boost-beast-client/anytype-header.mustache index 6b0a2d08c219..28a86a5c1e48 100644 --- a/modules/openapi-generator/src/main/resources/cpp-boost-beast-client/anytype-header.mustache +++ b/modules/openapi-generator/src/main/resources/cpp-boost-beast-client/anytype-header.mustache @@ -15,8 +15,8 @@ * Represents any JSON type using boost::json::value */ -#ifndef BOOST_BEAST_OPENAPI_CLIENT_ANYTYPE_H -#define BOOST_BEAST_OPENAPI_CLIENT_ANYTYPE_H +#ifndef {{schemaValidationHeaderGuardPrefix}}_ANYTYPE_H_ +#define {{schemaValidationHeaderGuardPrefix}}_ANYTYPE_H_ #include @@ -33,4 +33,4 @@ using AnyType = boost::json::value; } {{/modelNamespaceDeclarations}} -#endif /* BOOST_BEAST_OPENAPI_CLIENT_ANYTYPE_H */ +#endif /* {{schemaValidationHeaderGuardPrefix}}_ANYTYPE_H_ */ diff --git a/modules/openapi-generator/src/main/resources/cpp-boost-beast-client/api-export.mustache b/modules/openapi-generator/src/main/resources/cpp-boost-beast-client/api-export.mustache new file mode 100644 index 000000000000..0526615ce24b --- /dev/null +++ b/modules/openapi-generator/src/main/resources/cpp-boost-beast-client/api-export.mustache @@ -0,0 +1,15 @@ +{{>licenseInfo}} +#ifndef {{exportHeaderGuard}} +#define {{exportHeaderGuard}} + +#if defined(_WIN32) +# if defined({{exportDefine}}) +# define {{exportMacro}} __declspec(dllexport) +# else +# define {{exportMacro}} __declspec(dllimport) +# endif +#else +# define {{exportMacro}} +#endif + +#endif /* {{exportHeaderGuard}} */ 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..917b4d0ed04d 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 @@ -5,20 +5,26 @@ * {{description}} */ -#ifndef BOOST_BEAST_OPENAPI_CLIENT_{{classname}}_ -#define BOOST_BEAST_OPENAPI_CLIENT_{{classname}}_ +#ifndef {{apiHeaderGuardPrefix}}_{{classname}}_H_ +#define {{apiHeaderGuardPrefix}}_{{classname}}_H_ #include +#include #include #include #include #include +#include #include #include #include -#include "api/HttpClient.h" +#include "HttpClient.h" +#include "../model/ValidationTypes.h" +{{#hasExportMacro}} +#include "ApiExport.h" +{{/hasExportMacro}} {{#imports}}{{{import}}} {{/imports}} @@ -27,31 +33,50 @@ namespace {{this}} { {{/apiNamespaceDeclarations}} -using namespace {{modelNamespace}}; +{{#x-codegen-has-models}}using namespace {{modelNamespace}}; +{{/x-codegen-has-models}} + +{{#operation}} +{{#vendorExtensions.x-codegen-response-union}} +/// Response union type for {{#operationId}}{{{.}}}{{/operationId}}. +struct {{vendorExtensions.x-codegen-response-union}} { + boost::beast::http::status status; + std::string contentType; + std::map headers; + std::variant< + {{#vendorExtensions.x-codegen-response-union-members}} + {{{bodyType}}}{{^last}},{{/last}} + {{/vendorExtensions.x-codegen-response-union-members}} + > body; +}; +{{/vendorExtensions.x-codegen-response-union}} +{{/operation}} /// /// Exception to flag problems in the api's /// -class {{classname}}Exception: public std::exception +class {{#hasExportMacro}}{{exportMacro}} {{/hasExportMacro}}{{classname}}Exception: public std::exception { public: - {{classname}}Exception(boost::beast::http::status statusCode, std::string what); + {{classname}}Exception(boost::beast::http::status statusCode, std::string what, std::string responseBody); boost::beast::http::status getStatus() const; + const std::string& getResponseBody() const noexcept; const char* what() const noexcept override; private: boost::beast::http::status m_status; std::string m_what; + std::string m_responseBody; }; -class {{classname}} { +class {{#hasExportMacro}}{{exportMacro}} {{/hasExportMacro}}{{classname}} { public: {{classname}}( std::shared_ptr client, - const std::string& context = "{{contextPath}}") + const std::string& context = "{{{contextPath}}}") : m_client(std::move(client)), m_context(context) {} @@ -65,10 +90,38 @@ public: /// /// {{notes}} /// - virtual {{#returnType}}{{{.}}}{{/returnType}}{{^returnType}}void{{/returnType}} + {{#vendorExtensions.x-codegen-streaming-response}} + virtual HttpResponseData {{#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-sse-representation-mode}}SseEventCallback {{/vendorExtensions.x-codegen-sse-representation-mode}}{{^vendorExtensions.x-codegen-sse-representation-mode}}std::function {{/vendorExtensions.x-codegen-sse-representation-mode}}onEvent{{#allParams}}, + const {{#vendorExtensions.x-codegen-is-optional-query-parameter}}boost::optional<{{/vendorExtensions.x-codegen-is-optional-query-parameter}}{{#vendorExtensions.x-codegen-is-optional-form-parameter}}boost::optional<{{/vendorExtensions.x-codegen-is-optional-form-parameter}}{{{dataType}}}{{#vendorExtensions.x-codegen-is-optional-form-parameter}}>{{/vendorExtensions.x-codegen-is-optional-form-parameter}}{{#vendorExtensions.x-codegen-is-optional-query-parameter}}>{{/vendorExtensions.x-codegen-is-optional-query-parameter}}& {{{paramName}}}{{/allParams}}{{#allParams}}{{#vendorExtensions.x-codegen-multipart-filename-param}}, + const std::string& {{{vendorExtensions.x-codegen-multipart-filename-param-name}}} = "{{#lambda.cppStringLiteral}}{{baseName}}{{/lambda.cppStringLiteral}}"{{/vendorExtensions.x-codegen-multipart-filename-param}}{{/allParams}}, + const SseStreamOptions& streamOptions = {}); + {{/vendorExtensions.x-codegen-streaming-response}} + {{^vendorExtensions.x-codegen-streaming-response}} + virtual {{#vendorExtensions.x-codegen-response-union}}{{vendorExtensions.x-codegen-response-union}}{{/vendorExtensions.x-codegen-response-union}}{{^vendorExtensions.x-codegen-response-union}}{{#returnType}}{{{returnType}}}{{/returnType}}{{^returnType}}void{{/returnType}}{{/vendorExtensions.x-codegen-response-union}} + {{#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}}{{#vendorExtensions.x-codegen-is-optional-form-parameter}}boost::optional<{{/vendorExtensions.x-codegen-is-optional-form-parameter}}{{{dataType}}}{{#vendorExtensions.x-codegen-is-optional-form-parameter}}>{{/vendorExtensions.x-codegen-is-optional-form-parameter}}{{#vendorExtensions.x-codegen-is-optional-query-parameter}}>{{/vendorExtensions.x-codegen-is-optional-query-parameter}}& {{{paramName}}}{{^-last}}, {{/-last}}{{/allParams}}{{#allParams}}{{#vendorExtensions.x-codegen-multipart-filename-param}}, const std::string& {{{vendorExtensions.x-codegen-multipart-filename-param-name}}} = "{{#lambda.cppStringLiteral}}{{baseName}}{{/lambda.cppStringLiteral}}"{{/vendorExtensions.x-codegen-multipart-filename-param}}{{/allParams}}); + {{/vendorExtensions.x-codegen-streaming-response}} + {{#vendorExtensions.x-codegen-has-optional-form-parameter}} + /// Backward-compatible overload preserving value-typed form arguments. + {{#vendorExtensions.x-codegen-streaming-response}}virtual HttpResponseData{{/vendorExtensions.x-codegen-streaming-response}}{{^vendorExtensions.x-codegen-streaming-response}}virtual {{#vendorExtensions.x-codegen-response-union}}{{vendorExtensions.x-codegen-response-union}}{{/vendorExtensions.x-codegen-response-union}}{{^vendorExtensions.x-codegen-response-union}}{{#returnType}}{{{returnType}}}{{/returnType}}{{^returnType}}void{{/returnType}}{{/vendorExtensions.x-codegen-response-union}}{{/vendorExtensions.x-codegen-streaming-response}} + {{#operationId}}{{{.}}}{{/operationId}}{{^operationId}}{{httpMethod}}_{{vendorExtensions.x-codegen-resource-name}}{{/operationId}}( + {{#vendorExtensions.x-codegen-streaming-response}}{{#vendorExtensions.x-codegen-sse-representation-mode}}SseEventCallback {{/vendorExtensions.x-codegen-sse-representation-mode}}{{^vendorExtensions.x-codegen-sse-representation-mode}}std::function {{/vendorExtensions.x-codegen-sse-representation-mode}}onEvent, {{/vendorExtensions.x-codegen-streaming-response}}{{#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}}{{#allParams}}{{#vendorExtensions.x-codegen-multipart-filename-param}}, const std::string& {{{vendorExtensions.x-codegen-multipart-filename-param-name}}} = "{{#lambda.cppStringLiteral}}{{baseName}}{{/lambda.cppStringLiteral}}"{{/vendorExtensions.x-codegen-multipart-filename-param}}{{/allParams}}{{#vendorExtensions.x-codegen-streaming-response}}, const SseStreamOptions& streamOptions = {}{{/vendorExtensions.x-codegen-streaming-response}}) { + return {{#operationId}}{{{.}}}{{/operationId}}{{^operationId}}{{httpMethod}}_{{vendorExtensions.x-codegen-resource-name}}{{/operationId}}( + {{#vendorExtensions.x-codegen-streaming-response}}std::move(onEvent), {{/vendorExtensions.x-codegen-streaming-response}}{{#allParams}}{{#vendorExtensions.x-codegen-is-optional-form-parameter}}boost::optional<{{{dataType}}}>({{{paramName}}}){{/vendorExtensions.x-codegen-is-optional-form-parameter}}{{^vendorExtensions.x-codegen-is-optional-form-parameter}}{{{paramName}}}{{/vendorExtensions.x-codegen-is-optional-form-parameter}}{{^-last}}, {{/-last}}{{/allParams}}{{#allParams}}{{#vendorExtensions.x-codegen-multipart-filename-param}}, {{{vendorExtensions.x-codegen-multipart-filename-param-name}}}{{/vendorExtensions.x-codegen-multipart-filename-param}}{{/allParams}}{{#vendorExtensions.x-codegen-streaming-response}}, streamOptions{{/vendorExtensions.x-codegen-streaming-response}}); + } + {{/vendorExtensions.x-codegen-has-optional-form-parameter}} + + {{#vendorExtensions.x-codegen-dual-content}} + /// Streams text/event-stream and selects streaming on a request-body copy. + virtual HttpResponseData + {{#operationId}}{{{.}}}{{/operationId}}Stream( + {{#vendorExtensions.x-codegen-sse-representation-mode}}SseEventCallback {{/vendorExtensions.x-codegen-sse-representation-mode}}{{^vendorExtensions.x-codegen-sse-representation-mode}}std::function {{/vendorExtensions.x-codegen-sse-representation-mode}}onEvent{{#allParams}}, + const {{#vendorExtensions.x-codegen-is-optional-query-parameter}}boost::optional<{{/vendorExtensions.x-codegen-is-optional-query-parameter}}{{#vendorExtensions.x-codegen-is-optional-form-parameter}}boost::optional<{{/vendorExtensions.x-codegen-is-optional-form-parameter}}{{{dataType}}}{{#vendorExtensions.x-codegen-is-optional-form-parameter}}>{{/vendorExtensions.x-codegen-is-optional-form-parameter}}{{#vendorExtensions.x-codegen-is-optional-query-parameter}}>{{/vendorExtensions.x-codegen-is-optional-query-parameter}}& {{{paramName}}}{{/allParams}}{{#allParams}}{{#vendorExtensions.x-codegen-multipart-filename-param}}, + const std::string& {{{vendorExtensions.x-codegen-multipart-filename-param-name}}} = "{{#lambda.cppStringLiteral}}{{baseName}}{{/lambda.cppStringLiteral}}"{{/vendorExtensions.x-codegen-multipart-filename-param}}{{/allParams}}, + const SseStreamOptions& streamOptions = {}); + {{/vendorExtensions.x-codegen-dual-content}} {{#vendorExtensions.x-codegen-other-methods}} /// /// {{summary}} @@ -76,12 +129,51 @@ public: /// /// {{notes}} /// - virtual {{#returnType}}{{{.}}}{{/returnType}}{{^returnType}}void{{/returnType}} + {{#vendorExtensions.x-codegen-streaming-response}} + virtual HttpResponseData {{#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-sse-representation-mode}}SseEventCallback {{/vendorExtensions.x-codegen-sse-representation-mode}}{{^vendorExtensions.x-codegen-sse-representation-mode}}std::function {{/vendorExtensions.x-codegen-sse-representation-mode}}onEvent{{#allParams}}, + const {{#vendorExtensions.x-codegen-is-optional-query-parameter}}boost::optional<{{/vendorExtensions.x-codegen-is-optional-query-parameter}}{{#vendorExtensions.x-codegen-is-optional-form-parameter}}boost::optional<{{/vendorExtensions.x-codegen-is-optional-form-parameter}}{{{dataType}}}{{#vendorExtensions.x-codegen-is-optional-form-parameter}}>{{/vendorExtensions.x-codegen-is-optional-form-parameter}}{{#vendorExtensions.x-codegen-is-optional-query-parameter}}>{{/vendorExtensions.x-codegen-is-optional-query-parameter}}& {{{paramName}}}{{/allParams}}{{#allParams}}{{#vendorExtensions.x-codegen-multipart-filename-param}}, + const std::string& {{{vendorExtensions.x-codegen-multipart-filename-param-name}}} = "{{#lambda.cppStringLiteral}}{{baseName}}{{/lambda.cppStringLiteral}}"{{/vendorExtensions.x-codegen-multipart-filename-param}}{{/allParams}}, + const SseStreamOptions& streamOptions = {}); + {{/vendorExtensions.x-codegen-streaming-response}} + {{^vendorExtensions.x-codegen-streaming-response}} + virtual {{#vendorExtensions.x-codegen-response-union}}{{vendorExtensions.x-codegen-response-union}}{{/vendorExtensions.x-codegen-response-union}}{{^vendorExtensions.x-codegen-response-union}}{{#returnType}}{{{returnType}}}{{/returnType}}{{^returnType}}void{{/returnType}}{{/vendorExtensions.x-codegen-response-union}} + {{#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}}{{#vendorExtensions.x-codegen-is-optional-form-parameter}}boost::optional<{{/vendorExtensions.x-codegen-is-optional-form-parameter}}{{{dataType}}}{{#vendorExtensions.x-codegen-is-optional-form-parameter}}>{{/vendorExtensions.x-codegen-is-optional-form-parameter}}{{#vendorExtensions.x-codegen-is-optional-query-parameter}}>{{/vendorExtensions.x-codegen-is-optional-query-parameter}}& {{{paramName}}}{{^-last}}, {{/-last}}{{/allParams}}{{#allParams}}{{#vendorExtensions.x-codegen-multipart-filename-param}}, const std::string& {{{vendorExtensions.x-codegen-multipart-filename-param-name}}} = "{{#lambda.cppStringLiteral}}{{baseName}}{{/lambda.cppStringLiteral}}"{{/vendorExtensions.x-codegen-multipart-filename-param}}{{/allParams}}); + {{/vendorExtensions.x-codegen-streaming-response}} + {{#vendorExtensions.x-codegen-dual-content}} + virtual HttpResponseData + {{#operationId}}{{{.}}}{{/operationId}}Stream( + {{#vendorExtensions.x-codegen-sse-representation-mode}}SseEventCallback {{/vendorExtensions.x-codegen-sse-representation-mode}}{{^vendorExtensions.x-codegen-sse-representation-mode}}std::function {{/vendorExtensions.x-codegen-sse-representation-mode}}onEvent{{#allParams}}, + const {{#vendorExtensions.x-codegen-is-optional-query-parameter}}boost::optional<{{/vendorExtensions.x-codegen-is-optional-query-parameter}}{{#vendorExtensions.x-codegen-is-optional-form-parameter}}boost::optional<{{/vendorExtensions.x-codegen-is-optional-form-parameter}}{{{dataType}}}{{#vendorExtensions.x-codegen-is-optional-form-parameter}}>{{/vendorExtensions.x-codegen-is-optional-form-parameter}}{{#vendorExtensions.x-codegen-is-optional-query-parameter}}>{{/vendorExtensions.x-codegen-is-optional-query-parameter}}& {{{paramName}}}{{/allParams}}{{#allParams}}{{#vendorExtensions.x-codegen-multipart-filename-param}}, + const std::string& {{{vendorExtensions.x-codegen-multipart-filename-param-name}}} = "{{#lambda.cppStringLiteral}}{{baseName}}{{/lambda.cppStringLiteral}}"{{/vendorExtensions.x-codegen-multipart-filename-param}}{{/allParams}}, + const SseStreamOptions& streamOptions = {}); + {{/vendorExtensions.x-codegen-dual-content}} + {{#vendorExtensions.x-codegen-has-optional-form-parameter}} + /// Backward-compatible overload preserving value-typed form arguments. + {{#vendorExtensions.x-codegen-streaming-response}}virtual HttpResponseData{{/vendorExtensions.x-codegen-streaming-response}}{{^vendorExtensions.x-codegen-streaming-response}}virtual {{#vendorExtensions.x-codegen-response-union}}{{vendorExtensions.x-codegen-response-union}}{{/vendorExtensions.x-codegen-response-union}}{{^vendorExtensions.x-codegen-response-union}}{{#returnType}}{{{returnType}}}{{/returnType}}{{^returnType}}void{{/returnType}}{{/vendorExtensions.x-codegen-response-union}}{{/vendorExtensions.x-codegen-streaming-response}} + {{#operationId}}{{{.}}}{{/operationId}}{{^operationId}}{{httpMethod}}_{{vendorExtensions.x-codegen-resource-name}}{{/operationId}}( + {{#vendorExtensions.x-codegen-streaming-response}}{{#vendorExtensions.x-codegen-sse-representation-mode}}SseEventCallback {{/vendorExtensions.x-codegen-sse-representation-mode}}{{^vendorExtensions.x-codegen-sse-representation-mode}}std::function {{/vendorExtensions.x-codegen-sse-representation-mode}}onEvent, {{/vendorExtensions.x-codegen-streaming-response}}{{#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}}{{#allParams}}{{#vendorExtensions.x-codegen-multipart-filename-param}}, const std::string& {{{vendorExtensions.x-codegen-multipart-filename-param-name}}} = "{{#lambda.cppStringLiteral}}{{baseName}}{{/lambda.cppStringLiteral}}"{{/vendorExtensions.x-codegen-multipart-filename-param}}{{/allParams}}{{#vendorExtensions.x-codegen-streaming-response}}, const SseStreamOptions& streamOptions = {}{{/vendorExtensions.x-codegen-streaming-response}}) { + return {{#operationId}}{{{.}}}{{/operationId}}{{^operationId}}{{httpMethod}}_{{vendorExtensions.x-codegen-resource-name}}{{/operationId}}( + {{#vendorExtensions.x-codegen-streaming-response}}std::move(onEvent), {{/vendorExtensions.x-codegen-streaming-response}}{{#allParams}}{{#vendorExtensions.x-codegen-is-optional-form-parameter}}boost::optional<{{{dataType}}}>({{{paramName}}}){{/vendorExtensions.x-codegen-is-optional-form-parameter}}{{^vendorExtensions.x-codegen-is-optional-form-parameter}}{{{paramName}}}{{/vendorExtensions.x-codegen-is-optional-form-parameter}}{{^-last}}, {{/-last}}{{/allParams}}{{#allParams}}{{#vendorExtensions.x-codegen-multipart-filename-param}}, {{{vendorExtensions.x-codegen-multipart-filename-param-name}}}{{/vendorExtensions.x-codegen-multipart-filename-param}}{{/allParams}}{{#vendorExtensions.x-codegen-streaming-response}}, streamOptions{{/vendorExtensions.x-codegen-streaming-response}}); + } + {{/vendorExtensions.x-codegen-has-optional-form-parameter}} {{/vendorExtensions.x-codegen-other-methods}} {{/operation}} + /// Credential hook invoked before each operation with its effective security + /// requirements. Operation requirements override root requirements and an + /// empty list clears them. Override this no-op to attach credentials; + /// `target` is mutable for apiKey-in-query placement. + virtual void applyOperationSecurity( + const std::string& operationId, + const std::vector& requirements, + std::string& target, + std::map& headers) { + (void)operationId; (void)requirements; (void)target; (void)headers; + } + protected: virtual std::string base64encode(const std::string& str); @@ -104,4 +196,4 @@ protected: {{/operations}} -#endif /* BOOST_BEAST_OPENAPI_CLIENT_{{classname}}_ */ +#endif /* {{apiHeaderGuardPrefix}}_{{classname}}_H_ */ 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..b993a6d9a594 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,11 +1,33 @@ -{{#returnType}}{{{.}}}{{/returnType}}{{^returnType}}void{{/returnType}} +{{#vendorExtensions.x-codegen-streaming-response}}HttpResponseData{{/vendorExtensions.x-codegen-streaming-response}}{{^vendorExtensions.x-codegen-streaming-response}}{{#vendorExtensions.x-codegen-response-union}}{{vendorExtensions.x-codegen-response-union}}{{/vendorExtensions.x-codegen-response-union}}{{^vendorExtensions.x-codegen-response-union}}{{#returnType}}{{{.}}}{{/returnType}}{{^returnType}}void{{/returnType}}{{/vendorExtensions.x-codegen-response-union}}{{/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}}) { + {{#vendorExtensions.x-codegen-streaming-response}}{{#vendorExtensions.x-codegen-sse-representation-mode}}SseEventCallback {{/vendorExtensions.x-codegen-sse-representation-mode}}{{^vendorExtensions.x-codegen-sse-representation-mode}}std::function {{/vendorExtensions.x-codegen-sse-representation-mode}}onEvent{{#allParams}}, + const {{#vendorExtensions.x-codegen-is-optional-query-parameter}}boost::optional<{{/vendorExtensions.x-codegen-is-optional-query-parameter}}{{#vendorExtensions.x-codegen-is-optional-form-parameter}}boost::optional<{{/vendorExtensions.x-codegen-is-optional-form-parameter}}{{{dataType}}}{{#vendorExtensions.x-codegen-is-optional-form-parameter}}>{{/vendorExtensions.x-codegen-is-optional-form-parameter}}{{#vendorExtensions.x-codegen-is-optional-query-parameter}}>{{/vendorExtensions.x-codegen-is-optional-query-parameter}}& {{{paramName}}}{{/allParams}}{{#allParams}}{{#vendorExtensions.x-codegen-multipart-filename-param}}, + const std::string& {{{vendorExtensions.x-codegen-multipart-filename-param-name}}}{{/vendorExtensions.x-codegen-multipart-filename-param}}{{/allParams}}, + const SseStreamOptions& streamOptions{{/vendorExtensions.x-codegen-streaming-response}}{{^vendorExtensions.x-codegen-streaming-response}}{{#allParams}}const {{#vendorExtensions.x-codegen-is-optional-query-parameter}}boost::optional<{{/vendorExtensions.x-codegen-is-optional-query-parameter}}{{#vendorExtensions.x-codegen-is-optional-form-parameter}}boost::optional<{{/vendorExtensions.x-codegen-is-optional-form-parameter}}{{{dataType}}}{{#vendorExtensions.x-codegen-is-optional-form-parameter}}>{{/vendorExtensions.x-codegen-is-optional-form-parameter}}{{#vendorExtensions.x-codegen-is-optional-query-parameter}}>{{/vendorExtensions.x-codegen-is-optional-query-parameter}}& {{{paramName}}}{{^-last}}, {{/-last}}{{/allParams}}{{#allParams}}{{#vendorExtensions.x-codegen-multipart-filename-param}}, const std::string& {{{vendorExtensions.x-codegen-multipart-filename-param-name}}}{{/vendorExtensions.x-codegen-multipart-filename-param}}{{/allParams}}{{/vendorExtensions.x-codegen-streaming-response}}) { std::string serializedRequestBody; - std::string path = m_context + "{{{path}}}"; + std::string path = operationServerPrefix(m_context, "{{{vendorExtensions.x-codegen-op-server}}}") + "{{{vendorExtensions.x-codegen-cpp-path}}}"; std::map headers; +{{#vendorExtensions.x-codegen-streaming-response}} + if (!onEvent) throw std::invalid_argument("onEvent callback must not be empty"); +{{/vendorExtensions.x-codegen-streaming-response}} +{{#vendorExtensions.x-codegen-conditional-sse}} +{{#vendorExtensions.x-codegen-sse-request-shared-ptr}} + if (!{{{vendorExtensions.x-codegen-sse-request-param}}}) { + throw std::invalid_argument("conditional SSE request body must not be null"); + } + auto {{{vendorExtensions.x-codegen-sse-request-local}}} = + std::make_shared<{{{vendorExtensions.x-codegen-sse-request-type}}}>( + *{{{vendorExtensions.x-codegen-sse-request-param}}}); + {{{vendorExtensions.x-codegen-sse-request-local}}}->{{{vendorExtensions.x-codegen-sse-request-setter}}}({{{vendorExtensions.x-codegen-sse-request-false-value}}}); +{{/vendorExtensions.x-codegen-sse-request-shared-ptr}} +{{^vendorExtensions.x-codegen-sse-request-shared-ptr}} + auto {{{vendorExtensions.x-codegen-sse-request-local}}} = + {{{vendorExtensions.x-codegen-sse-request-param}}}; + {{{vendorExtensions.x-codegen-sse-request-local}}}.{{{vendorExtensions.x-codegen-sse-request-setter}}}({{{vendorExtensions.x-codegen-sse-request-false-value}}}); +{{/vendorExtensions.x-codegen-sse-request-shared-ptr}} +{{/vendorExtensions.x-codegen-conditional-sse}} {{#hasConsumes}} - static const std::vector contentTypes{ {{#consumes}}"{{{mediaType}}}",{{/consumes}} }; + static const std::vector contentTypes{ {{#consumes}}"{{{cppMediaType}}}",{{/consumes}} }; std::string requestContentType = selectPreferredContentType(contentTypes); headers["Content-Type"] = requestContentType; {{/hasConsumes}} @@ -17,10 +39,10 @@ // Body params {{#bodyParam}} if (isJsonContentType(requestContentType)) { - serializedRequestBody = boost::json::serialize(toRequestJsonValue({{paramName}})); + serializedRequestBody = boost::json::serialize(toRequestJsonValue({{#vendorExtensions.x-codegen-conditional-sse}}{{{vendorExtensions.x-codegen-sse-request-local}}}{{/vendorExtensions.x-codegen-conditional-sse}}{{^vendorExtensions.x-codegen-conditional-sse}}{{paramName}}{{/vendorExtensions.x-codegen-conditional-sse}})); } else { {{#vendorExtensions.x-codegen-is-raw-body}} - serializedRequestBody = toRawBodyValue({{paramName}}); + serializedRequestBody = toRawBodyValue({{#vendorExtensions.x-codegen-conditional-sse}}{{{vendorExtensions.x-codegen-sse-request-local}}}{{/vendorExtensions.x-codegen-conditional-sse}}{{^vendorExtensions.x-codegen-conditional-sse}}{{paramName}}{{/vendorExtensions.x-codegen-conditional-sse}}); {{/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"); @@ -33,10 +55,30 @@ std::vector formParameters; {{#allParams}} {{#isFormParam}} +{{^required}} + if (hasFormParameterValue({{{paramName}}})) { +{{/required}} +{{#vendorExtensions.x-codegen-is-variant-form-param}} + addVariantFormParameter(formParameters, "{{#lambda.cppStringLiteral}}{{baseName}}{{/lambda.cppStringLiteral}}", {{#vendorExtensions.x-codegen-is-optional-form-parameter}}*{{/vendorExtensions.x-codegen-is-optional-form-parameter}}{{{paramName}}}{{#contentType}}, "{{#lambda.cppStringLiteral}}{{contentType}}{{/lambda.cppStringLiteral}}"{{/contentType}}); +{{/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}}); + "{{#lambda.cppStringLiteral}}{{baseName}}{{/lambda.cppStringLiteral}}", + toFormParameterValue({{#vendorExtensions.x-codegen-is-optional-form-parameter}}*{{/vendorExtensions.x-codegen-is-optional-form-parameter}}{{{paramName}}}), + {{#isFile}}true{{/isFile}}{{^isFile}}{{#isBinary}}true{{/isBinary}}{{^isBinary}}false{{/isBinary}}{{/isFile}}{{#contentType}}, + "{{#lambda.cppStringLiteral}}{{contentType}}{{/lambda.cppStringLiteral}}"{{/contentType}}{{^contentType}}{{#isFile}}, + "application/octet-stream"{{/isFile}}{{^isFile}}{{#isBinary}}, + "application/octet-stream"{{/isBinary}}{{/isFile}}{{^isFile}}{{#isString}}, + "text/plain"{{/isString}}{{/isFile}}{{^isFile}}{{#isMap}}, + "application/json"{{/isMap}}{{/isFile}}{{^isFile}}{{#isModel}}, + "application/json"{{/isModel}}{{/isFile}}{{^isFile}}{{#isArray}}{{#items.isModel}}, + "application/json"{{/items.isModel}}{{^items.isModel}}, + "text/plain"{{/items.isModel}}{{/isArray}}{{/isFile}}{{/contentType}}{{#vendorExtensions.x-codegen-multipart-filename-param}}, + {{{vendorExtensions.x-codegen-multipart-filename-param-name}}}{{/vendorExtensions.x-codegen-multipart-filename-param}}); +{{/vendorExtensions.x-codegen-is-variant-form-param}} +{{^required}} + } +{{/required}} {{/isFormParam}} {{/allParams}} if (normalizeMediaType(requestContentType) == "multipart/form-data") { @@ -48,70 +90,28 @@ } {{/hasFormParams}} {{#hasPathParams}} - // path params + // Path parameters use their declared simple, label, or matrix style. {{#pathParams}} - replacePathParameter(path, "{{{baseName}}}", {{{paramName}}}); + replacePathParameter(path, "{{#lambda.cppStringLiteral}}{{baseName}}{{/lambda.cppStringLiteral}}", {{{paramName}}}, "{{{vendorExtensions.x-codegen-param-style}}}", {{vendorExtensions.x-codegen-param-explode}}); {{/pathParams}} {{/hasPathParams}} {{#hasQueryParams}} - // query params + // Query parameters preserve style, explode, allowReserved, and allowEmptyValue. 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( + appendParamQueryParameter( 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}} + "{{#lambda.cppStringLiteral}}{{baseName}}{{/lambda.cppStringLiteral}}", + {{#vendorExtensions.x-codegen-is-optional-query-parameter}}*{{/vendorExtensions.x-codegen-is-optional-query-parameter}}{{{paramName}}}, + "{{{vendorExtensions.x-codegen-param-style}}}", + {{vendorExtensions.x-codegen-param-explode}}, + {{vendorExtensions.x-codegen-param-allow-reserved}}, + {{vendorExtensions.x-codegen-param-allow-empty-value}}); {{#vendorExtensions.x-codegen-is-optional-query-parameter}} } {{/vendorExtensions.x-codegen-is-optional-query-parameter}} @@ -121,15 +121,35 @@ {{#hasHeaderParams}} // headers {{#headerParams}} - headers.emplace("{{{baseName}}}", serializeHeaderParameterValue({{{paramName}}})); + headers.emplace("{{#lambda.cppStringLiteral}}{{baseName}}{{/lambda.cppStringLiteral}}", serializeHeaderParameterValue({{{paramName}}}, {{vendorExtensions.x-codegen-param-explode}})); {{/headerParams}} {{/hasHeaderParams}} +{{#hasCookieParams}} + // Form-style cookie parameters are joined into one Cookie header. + std::string cookieHeader; +{{#cookieParams}} + appendCookieParameter(cookieHeader, "{{#lambda.cppStringLiteral}}{{baseName}}{{/lambda.cppStringLiteral}}", {{{paramName}}}, {{vendorExtensions.x-codegen-param-explode}}); +{{/cookieParams}} + if (!cookieHeader.empty()) { + headers.emplace("Cookie", cookieHeader); + } +{{/hasCookieParams}} +{{#vendorExtensions.x-codegen-response-union}} + std::string responseContentType = "application/json"; +{{/vendorExtensions.x-codegen-response-union}} +{{^vendorExtensions.x-codegen-response-union}} {{#returnType}} std::string responseContentType = "application/json"; {{/returnType}} +{{/vendorExtensions.x-codegen-response-union}} {{#hasProduces}} - static const std::vector acceptTypes{ {{#produces}}"{{{mediaType}}}",{{/produces}} }; + static const std::vector acceptTypes{ {{#produces}}"{{{cppMediaType}}}",{{/produces}} }; +{{#vendorExtensions.x-codegen-response-union}} + responseContentType = selectPreferredContentType(acceptTypes); + headers["Accept"] = responseContentType; +{{/vendorExtensions.x-codegen-response-union}} +{{^vendorExtensions.x-codegen-response-union}} {{#returnType}} responseContentType = selectPreferredContentType(acceptTypes); headers["Accept"] = responseContentType; @@ -137,9 +157,116 @@ {{^returnType}} headers["Accept"] = selectPreferredContentType(acceptTypes); {{/returnType}} +{{/vendorExtensions.x-codegen-response-union}} {{/hasProduces}} + {{#vendorExtensions.x-codegen-op-has-security}} + // Security alternatives are OR groups containing AND-required schemes. + { + static const std::vector operationSecurity = { + {{#vendorExtensions.x-codegen-op-security-groups}} + SecurityRequirementGroup{ { + {{#.}} + SecuritySchemeUse{"{{{name}}}", "{{{type}}}", "{{{in}}}", "{{{paramName}}}", "{{{httpScheme}}}", {{#scopesRendered}}{ {{{scopesRendered}}} }{{/scopesRendered}}{{^scopesRendered}}{}{{/scopesRendered}}}, + {{/.}} + } }, + {{/vendorExtensions.x-codegen-op-security-groups}} + }; + applyOperationSecurity("{{#lambda.cppStringLiteral}}{{operationId}}{{/lambda.cppStringLiteral}}", operationSecurity, path, headers); + } +{{/vendorExtensions.x-codegen-op-has-security}} +{{#vendorExtensions.x-codegen-op-callbacks}} + // Callback metadata preserved; no inbound listener is generated: {{{.}}} +{{/vendorExtensions.x-codegen-op-callbacks}} +{{#vendorExtensions.x-codegen-op-links}} + // Link metadata preserved; no automatic traversal is generated: {{{.}}} +{{/vendorExtensions.x-codegen-op-links}} auto statusCode = boost::beast::http::status::unknown; +{{#vendorExtensions.x-codegen-streaming-response}} + std::string responseBody; + HttpResponseData deserializedResponse; + try { + deserializedResponse = m_client->executeStream( + "{{httpMethod}}", + path, + serializedRequestBody, + headers, +{{#vendorExtensions.x-codegen-sse-representation-mode}} + std::move(onEvent), +{{/vendorExtensions.x-codegen-sse-representation-mode}} +{{^vendorExtensions.x-codegen-sse-representation-mode}} + [onEvent = std::move(onEvent)](const SseEvent& event) mutable { + if (event.data == "[DONE]") return false; + {{schemaValidationNamespace}}::ExactJsonValue exactEvent = + {{schemaValidationNamespace}}::parseExactJson(event.data); + {{schemaValidationNamespace}}::requireModelConvertibleJson(exactEvent); + {{schemaValidationNamespace}}::ExactInstanceScope exactScope(exactEvent); +{{#vendorExtensions.x-codegen-stream-element-type}} + auto value = fromJsonValue_{{{.}}}(exactEvent.value); +{{/vendorExtensions.x-codegen-stream-element-type}} +{{^vendorExtensions.x-codegen-stream-element-type}} + auto value = {{#vendorExtensions.x-codegen-stream-is-oneof}}OneOf{{/vendorExtensions.x-codegen-stream-is-oneof}}ResponseJsonValueConverter<{{{returnType}}}>::convert(exactEvent.value); +{{/vendorExtensions.x-codegen-stream-element-type}} + return onEvent(value, event); + }, +{{/vendorExtensions.x-codegen-sse-representation-mode}} + streamOptions); + statusCode = deserializedResponse.status; + responseBody = deserializedResponse.body; + } + catch(const std::exception& exception) { + handleStdException(exception); + } + catch(...) { + handleUncaughtException(); + } + if (static_cast(statusCode) / 100U == 2U + && !deserializedResponse.isEventStream) { + throw {{classname}}Exception( + statusCode, "Expected text/event-stream response", responseBody); + } +{{/vendorExtensions.x-codegen-streaming-response}} +{{^vendorExtensions.x-codegen-streaming-response}} +{{#vendorExtensions.x-codegen-response-union}} + // Union-aware response-union path: use executeWithMetadata to capture + // actual Content-Type from response headers. + std::string responseBody; + std::map responseHeaders; + try { + auto responseData = m_client->executeWithMetadata( + "{{httpMethod}}", path, serializedRequestBody, headers); + statusCode = responseData.status; + responseBody = std::move(responseData.body); + responseHeaders = responseData.headers; + // Use actual Content-Type from response headers; fall back to + // the requested Accept type for legacy adapters. + { + std::string responseContentTypeLower = responseContentType; + for (auto& c : responseContentTypeLower) { + c = static_cast(std::tolower( + static_cast(c))); + } + for (const auto& [hdrKey, hdrVal] : responseData.headers) { + std::string keyLower = hdrKey; + for (auto& c : keyLower) { + c = static_cast(std::tolower( + static_cast(c))); + } + if (keyLower == "content-type") { + responseContentType = hdrVal; + break; + } + } + } + } + catch(const std::exception& exception) { + handleStdException(exception); + } + catch(...) { + handleUncaughtException(); + } +{{/vendorExtensions.x-codegen-response-union}} +{{^vendorExtensions.x-codegen-response-union}} std::string responseBody; try { std::tie(statusCode, responseBody) = @@ -154,30 +281,131 @@ catch(...) { handleUncaughtException(); } +{{/vendorExtensions.x-codegen-response-union}} +{{#vendorExtensions.x-codegen-response-union}} + {{{vendorExtensions.x-codegen-response-union}}} deserializedResponse; + deserializedResponse.status = statusCode; + deserializedResponse.contentType = responseContentType; + deserializedResponse.headers = std::move(responseHeaders); +{{/vendorExtensions.x-codegen-response-union}} +{{^vendorExtensions.x-codegen-response-union}} {{#returnType}} {{{.}}} deserializedResponse = {{{defaultResponse}}}; {{/returnType}} +{{/vendorExtensions.x-codegen-response-union}} +{{/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-response-union-body-type}} +{{#dataType}} + {{{dataType}}} parsedBody; + {{#vendorExtensions.x-codegen-response-is-oneof}} + {{#vendorExtensions.x-cpp-use-model-from-json-value}} + deserializeJsonResponseBody( + parsedBody, + 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}}, + [](boost::json::value const& responseValue) { + return fromJsonValue_{{dataType}}(responseValue); + }); + {{/vendorExtensions.x-cpp-use-model-from-json-value}} + {{^vendorExtensions.x-cpp-use-model-from-json-value}} + OneOfResponseBodyDeserializer<{{{dataType}}}>::deserialize( + parsedBody, 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}}); + {{/vendorExtensions.x-cpp-use-model-from-json-value}} + {{/vendorExtensions.x-codegen-response-is-oneof}} + {{^vendorExtensions.x-codegen-response-is-oneof}} + {{#vendorExtensions.x-cpp-use-model-from-json-value}} + deserializeJsonResponseBody( + parsedBody, + 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}}, + [](boost::json::value const& responseValue) { + return fromJsonValue_{{dataType}}(responseValue); + }); + {{/vendorExtensions.x-cpp-use-model-from-json-value}} + {{^vendorExtensions.x-cpp-use-model-from-json-value}} + ResponseBodyDeserializer<{{{dataType}}}>::deserialize( + parsedBody, 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}}); + {{/vendorExtensions.x-cpp-use-model-from-json-value}} + {{/vendorExtensions.x-codegen-response-is-oneof}} + deserializedResponse.body = {{{vendorExtensions.x-codegen-response-union-body-type}}}{std::move(parsedBody)}; +{{/dataType}} +{{^dataType}} + deserializedResponse.body = {{{vendorExtensions.x-codegen-response-union-body-type}}}{}; +{{/dataType}} + return deserializedResponse; +{{/vendorExtensions.x-codegen-response-union-body-type}} +{{^vendorExtensions.x-codegen-response-union-body-type}} +{{#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}} + {{#vendorExtensions.x-codegen-response-is-oneof}} + {{#vendorExtensions.x-cpp-use-model-from-json-value}} + deserializeJsonResponseBody( + 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}}, + [](boost::json::value const& responseValue) { + return fromJsonValue_{{dataType}}(responseValue); + }); + {{/vendorExtensions.x-cpp-use-model-from-json-value}} + {{^vendorExtensions.x-cpp-use-model-from-json-value}} + OneOfResponseBodyDeserializer<{{{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}}); + {{/vendorExtensions.x-cpp-use-model-from-json-value}} + {{/vendorExtensions.x-codegen-response-is-oneof}} + {{^vendorExtensions.x-codegen-response-is-oneof}} + {{#vendorExtensions.x-cpp-use-model-from-json-value}} + deserializeJsonResponseBody( + 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}}, + [](boost::json::value const& responseValue) { + return fromJsonValue_{{dataType}}(responseValue); + }); + {{/vendorExtensions.x-cpp-use-model-from-json-value}} + {{^vendorExtensions.x-cpp-use-model-from-json-value}} 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}}); + {{/vendorExtensions.x-cpp-use-model-from-json-value}} + {{/vendorExtensions.x-codegen-response-is-oneof}} {{/dataType}} return deserializedResponse; {{/returnType}} {{^returnType}} return; {{/returnType}} +{{/vendorExtensions.x-codegen-streaming-response}} +{{/vendorExtensions.x-codegen-return-compatible}} +{{/vendorExtensions.x-codegen-response-union-body-type}} {{/is2xx}} {{^is2xx}} - throw {{classname}}Exception(statusCode, "{{{message}}}"); + throw {{classname}}Exception(statusCode, "{{{vendorExtensions.x-codegen-cpp-message}}}", responseBody); {{/is2xx}} } {{/isDefault}} @@ -185,29 +413,286 @@ {{#responses}} {{#isDefault}} {{#dataType}} +{{#vendorExtensions.x-codegen-response-union-body-type}} + {{{dataType}}} parsedBody; + {{#vendorExtensions.x-cpp-use-model-from-json-value}} + deserializeJsonResponseBody( + parsedBody, + 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}}, + [](boost::json::value const& responseValue) { + return fromJsonValue_{{dataType}}(responseValue); + }); + {{/vendorExtensions.x-cpp-use-model-from-json-value}} + {{^vendorExtensions.x-cpp-use-model-from-json-value}} + ResponseBodyDeserializer<{{{dataType}}}>::deserialize( + parsedBody, 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}}); + {{/vendorExtensions.x-cpp-use-model-from-json-value}} + deserializedResponse.body = {{{vendorExtensions.x-codegen-response-union-body-type}}}{std::move(parsedBody)}; + return deserializedResponse; +{{/vendorExtensions.x-codegen-response-union-body-type}} +{{^vendorExtensions.x-codegen-response-union-body-type}} {{#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}} + {{#vendorExtensions.x-cpp-use-model-from-json-value}} + deserializeJsonResponseBody( + 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}}, + [](boost::json::value const& responseValue) { + return fromJsonValue_{{dataType}}(responseValue); + }); + {{/vendorExtensions.x-cpp-use-model-from-json-value}} + {{^vendorExtensions.x-cpp-use-model-from-json-value}} 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}}); + {{/vendorExtensions.x-cpp-use-model-from-json-value}} 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}}}"); + throw {{classname}}Exception(statusCode, "{{{vendorExtensions.x-codegen-cpp-message}}}", responseBody); {{/vendorExtensions.x-codegen-default-response-is-return-compatible}} +{{/vendorExtensions.x-codegen-response-union-body-type}} {{/dataType}} {{^dataType}} +{{#vendorExtensions.x-codegen-streaming-response}} + return deserializedResponse; +{{/vendorExtensions.x-codegen-streaming-response}} +{{^vendorExtensions.x-codegen-streaming-response}} {{#returnType}} return deserializedResponse; {{/returnType}} {{^returnType}} return; {{/returnType}} +{{/vendorExtensions.x-codegen-streaming-response}} {{/dataType}} {{/isDefault}} {{/responses}} {{^vendorExtensions.x-codegen-has-default-response}} - throw {{classname}}Exception(statusCode, "Unexpected HTTP status code"); + throw {{classname}}Exception(statusCode, "Unexpected HTTP status code", responseBody); {{/vendorExtensions.x-codegen-has-default-response}} } + +{{#vendorExtensions.x-codegen-dual-content}} +HttpResponseData +{{classname}}::{{#operationId}}{{{.}}}{{/operationId}}Stream( + {{#vendorExtensions.x-codegen-sse-representation-mode}}SseEventCallback {{/vendorExtensions.x-codegen-sse-representation-mode}}{{^vendorExtensions.x-codegen-sse-representation-mode}}std::function {{/vendorExtensions.x-codegen-sse-representation-mode}}onEvent{{#allParams}}, + const {{#vendorExtensions.x-codegen-is-optional-query-parameter}}boost::optional<{{/vendorExtensions.x-codegen-is-optional-query-parameter}}{{#vendorExtensions.x-codegen-is-optional-form-parameter}}boost::optional<{{/vendorExtensions.x-codegen-is-optional-form-parameter}}{{{dataType}}}{{#vendorExtensions.x-codegen-is-optional-form-parameter}}>{{/vendorExtensions.x-codegen-is-optional-form-parameter}}{{#vendorExtensions.x-codegen-is-optional-query-parameter}}>{{/vendorExtensions.x-codegen-is-optional-query-parameter}}& {{{paramName}}}{{/allParams}}{{#allParams}}{{#vendorExtensions.x-codegen-multipart-filename-param}}, + const std::string& {{{vendorExtensions.x-codegen-multipart-filename-param-name}}}{{/vendorExtensions.x-codegen-multipart-filename-param}}{{/allParams}}, + const SseStreamOptions& streamOptions) { + std::string serializedRequestBody; + std::string path = operationServerPrefix(m_context, "{{{vendorExtensions.x-codegen-op-server}}}") + "{{{vendorExtensions.x-codegen-cpp-path}}}"; + std::map headers; + if (!onEvent) throw std::invalid_argument("onEvent callback must not be empty"); +{{#vendorExtensions.x-codegen-sse-request-shared-ptr}} + if (!{{{vendorExtensions.x-codegen-sse-request-param}}}) { + throw std::invalid_argument("conditional SSE request body must not be null"); + } + auto {{{vendorExtensions.x-codegen-sse-request-local}}} = + std::make_shared<{{{vendorExtensions.x-codegen-sse-request-type}}}>( + *{{{vendorExtensions.x-codegen-sse-request-param}}}); + {{{vendorExtensions.x-codegen-sse-request-local}}}->{{{vendorExtensions.x-codegen-sse-request-setter}}}({{{vendorExtensions.x-codegen-sse-request-true-value}}}); +{{/vendorExtensions.x-codegen-sse-request-shared-ptr}} +{{^vendorExtensions.x-codegen-sse-request-shared-ptr}} + auto {{{vendorExtensions.x-codegen-sse-request-local}}} = + {{{vendorExtensions.x-codegen-sse-request-param}}}; + {{{vendorExtensions.x-codegen-sse-request-local}}}.{{{vendorExtensions.x-codegen-sse-request-setter}}}({{{vendorExtensions.x-codegen-sse-request-true-value}}}); +{{/vendorExtensions.x-codegen-sse-request-shared-ptr}} +{{#hasConsumes}} + static const std::vector contentTypes{ {{#consumes}}"{{{cppMediaType}}}",{{/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({{{vendorExtensions.x-codegen-sse-request-local}}})); + } else { +{{#vendorExtensions.x-codegen-is-raw-body}} + serializedRequestBody = toRawBodyValue({{{vendorExtensions.x-codegen-sse-request-local}}}); +{{/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}} +{{^required}} + if (hasFormParameterValue({{{paramName}}})) { +{{/required}} +{{#vendorExtensions.x-codegen-is-variant-form-param}} + addVariantFormParameter(formParameters, "{{#lambda.cppStringLiteral}}{{baseName}}{{/lambda.cppStringLiteral}}", {{#vendorExtensions.x-codegen-is-optional-form-parameter}}*{{/vendorExtensions.x-codegen-is-optional-form-parameter}}{{{paramName}}}{{#contentType}}, "{{#lambda.cppStringLiteral}}{{contentType}}{{/lambda.cppStringLiteral}}"{{/contentType}}); +{{/vendorExtensions.x-codegen-is-variant-form-param}} +{{^vendorExtensions.x-codegen-is-variant-form-param}} + formParameters.emplace_back( + "{{#lambda.cppStringLiteral}}{{baseName}}{{/lambda.cppStringLiteral}}", + toFormParameterValue({{#vendorExtensions.x-codegen-is-optional-form-parameter}}*{{/vendorExtensions.x-codegen-is-optional-form-parameter}}{{{paramName}}}), + {{#isFile}}true{{/isFile}}{{^isFile}}{{#isBinary}}true{{/isBinary}}{{^isBinary}}false{{/isBinary}}{{/isFile}}{{#contentType}}, + "{{#lambda.cppStringLiteral}}{{contentType}}{{/lambda.cppStringLiteral}}"{{/contentType}}{{^contentType}}{{#isFile}}, + "application/octet-stream"{{/isFile}}{{^isFile}}{{#isBinary}}, + "application/octet-stream"{{/isBinary}}{{/isFile}}{{^isFile}}{{#isString}}, + "text/plain"{{/isString}}{{/isFile}}{{^isFile}}{{#isMap}}, + "application/json"{{/isMap}}{{/isFile}}{{^isFile}}{{#isModel}}, + "application/json"{{/isModel}}{{/isFile}}{{^isFile}}{{#isArray}}{{#items.isModel}}, + "application/json"{{/items.isModel}}{{^items.isModel}}, + "text/plain"{{/items.isModel}}{{/isArray}}{{/isFile}}{{/contentType}}{{#vendorExtensions.x-codegen-multipart-filename-param}}, + {{{vendorExtensions.x-codegen-multipart-filename-param-name}}}{{/vendorExtensions.x-codegen-multipart-filename-param}}); +{{/vendorExtensions.x-codegen-is-variant-form-param}} +{{^required}} + } +{{/required}} +{{/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 parameters use their declared simple, label, or matrix style. +{{#pathParams}} + replacePathParameter(path, "{{#lambda.cppStringLiteral}}{{baseName}}{{/lambda.cppStringLiteral}}", {{{paramName}}}, "{{{vendorExtensions.x-codegen-param-style}}}", {{vendorExtensions.x-codegen-param-explode}}); +{{/pathParams}} +{{/hasPathParams}} +{{#hasQueryParams}} + // Query parameters preserve style, explode, allowReserved, and allowEmptyValue. + std::stringstream queryParameterStream; + const char* queryParameterSeparator = "?"; +{{#queryParams}} +{{#vendorExtensions.x-codegen-is-optional-query-parameter}} + if ({{{paramName}}}) { +{{/vendorExtensions.x-codegen-is-optional-query-parameter}} + appendParamQueryParameter( + queryParameterStream, + queryParameterSeparator, + "{{#lambda.cppStringLiteral}}{{baseName}}{{/lambda.cppStringLiteral}}", + {{#vendorExtensions.x-codegen-is-optional-query-parameter}}*{{/vendorExtensions.x-codegen-is-optional-query-parameter}}{{{paramName}}}, + "{{{vendorExtensions.x-codegen-param-style}}}", + {{vendorExtensions.x-codegen-param-explode}}, + {{vendorExtensions.x-codegen-param-allow-reserved}}, + {{vendorExtensions.x-codegen-param-allow-empty-value}}); +{{#vendorExtensions.x-codegen-is-optional-query-parameter}} + } +{{/vendorExtensions.x-codegen-is-optional-query-parameter}} +{{/queryParams}} + path += queryParameterStream.str(); +{{/hasQueryParams}} +{{#hasHeaderParams}} + // headers +{{#headerParams}} + headers.emplace("{{#lambda.cppStringLiteral}}{{baseName}}{{/lambda.cppStringLiteral}}", serializeHeaderParameterValue({{{paramName}}}, {{vendorExtensions.x-codegen-param-explode}})); +{{/headerParams}} +{{/hasHeaderParams}} +{{#hasCookieParams}} + // Form-style cookie parameters are joined into one Cookie header. + std::string cookieHeader; +{{#cookieParams}} + appendCookieParameter(cookieHeader, "{{#lambda.cppStringLiteral}}{{baseName}}{{/lambda.cppStringLiteral}}", {{{paramName}}}, {{vendorExtensions.x-codegen-param-explode}}); +{{/cookieParams}} + if (!cookieHeader.empty()) { + headers.emplace("Cookie", cookieHeader); + } +{{/hasCookieParams}} + + // For streaming, force Accept to text/event-stream (ignoring JSON produces) + headers["Accept"] = "text/event-stream"; + + {{#vendorExtensions.x-codegen-op-has-security}} + // Security alternatives are OR groups containing AND-required schemes. + { + static const std::vector operationSecurity = { + {{#vendorExtensions.x-codegen-op-security-groups}} + SecurityRequirementGroup{ { + {{#.}} + SecuritySchemeUse{"{{{name}}}", "{{{type}}}", "{{{in}}}", "{{{paramName}}}", "{{{httpScheme}}}", {{#scopesRendered}}{ {{{scopesRendered}}} }{{/scopesRendered}}{{^scopesRendered}}{}{{/scopesRendered}}}, + {{/.}} + } }, + {{/vendorExtensions.x-codegen-op-security-groups}} + }; + applyOperationSecurity("{{#lambda.cppStringLiteral}}{{operationId}}{{/lambda.cppStringLiteral}}", operationSecurity, path, headers); + } +{{/vendorExtensions.x-codegen-op-has-security}} +{{#vendorExtensions.x-codegen-op-callbacks}} + // Callback metadata preserved; no inbound listener is generated: {{{.}}} +{{/vendorExtensions.x-codegen-op-callbacks}} +{{#vendorExtensions.x-codegen-op-links}} + // Link metadata preserved; no automatic traversal is generated: {{{.}}} +{{/vendorExtensions.x-codegen-op-links}} + auto statusCode = boost::beast::http::status::unknown; + std::string responseBody; + HttpResponseData deserializedResponse; + try { + deserializedResponse = m_client->executeStream( + "{{httpMethod}}", + path, + serializedRequestBody, + headers, +{{#vendorExtensions.x-codegen-sse-representation-mode}} + std::move(onEvent), +{{/vendorExtensions.x-codegen-sse-representation-mode}} +{{^vendorExtensions.x-codegen-sse-representation-mode}} + [onEvent = std::move(onEvent)](const SseEvent& event) mutable { + if (event.data == "[DONE]") return false; + {{schemaValidationNamespace}}::ExactJsonValue exactEvent = + {{schemaValidationNamespace}}::parseExactJson(event.data); + {{schemaValidationNamespace}}::requireModelConvertibleJson(exactEvent); + {{schemaValidationNamespace}}::ExactInstanceScope exactScope(exactEvent); + auto value = {{#vendorExtensions.x-codegen-dual-stream-is-oneof}}OneOf{{/vendorExtensions.x-codegen-dual-stream-is-oneof}}ResponseJsonValueConverter<{{{vendorExtensions.x-codegen-dual-stream-element-type}}}>::convert(exactEvent.value); + return onEvent(value, event); + }, +{{/vendorExtensions.x-codegen-sse-representation-mode}} + streamOptions); + statusCode = deserializedResponse.status; + responseBody = deserializedResponse.body; + } + catch(const std::exception& exception) { + handleStdException(exception); + } + catch(...) { + handleUncaughtException(); + } + if (static_cast(statusCode) / 100U == 2U + && !deserializedResponse.isEventStream) { + throw {{classname}}Exception( + statusCode, "Expected text/event-stream response", responseBody); + } + +{{#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}}}", responseBody); +{{/is2xx}} + } +{{/isDefault}} +{{/responses}} + throw {{classname}}Exception(statusCode, "Unexpected HTTP status code", responseBody); +} +{{/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..65ef6dd11557 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 @@ -1,23 +1,32 @@ {{>licenseInfo}} {{#operations}} +#include #include #include #include #include +#include +#include +#include #include #include #include #include #include +#include +#include +#include -#include #include #include #include #include #include +#include +#include "../model/ValidationTypes.h" +#include "../model/Oas31ExactJson.h" #include "{{classname}}.h" @@ -26,8 +35,13 @@ namespace {{this}} { {{/apiNamespaceDeclarations}} -using namespace {{modelNamespace}}; +{{#x-codegen-webhook-metadata}} +// Preserved inbound request metadata (webhook operation) — no listener generated. +// {{{x-codegen-webhook-metadata}}} +{{/x-codegen-webhook-metadata}} +{{#x-codegen-has-models}}using namespace {{modelNamespace}}; +{{/x-codegen-has-models}} namespace { std::string selectPreferredContentType(const std::vector& contentTypes) { @@ -61,22 +75,34 @@ inline std::string normalizeMediaType(const std::string& contentType) { return mediaType; } -inline bool isJsonContentType(const std::string& contentType) { +[[maybe_unused]] inline bool isJsonContentType(const std::string& contentType) { const std::string mediaType = normalizeMediaType(contentType); return mediaType == "application/json" || (mediaType.size() > 5 && mediaType.compare(mediaType.size() - 5, 5, "+json") == 0); } } -struct FormParameter { - FormParameter(std::string parameterName, std::string parameterValue, bool file) - : name(std::move(parameterName)), value(std::move(parameterValue)), isFile(file) { - } - std::string name; - std::string value; - bool isFile; -}; +template +bool hasFormParameterValue(const T&) noexcept { + return true; +} + +template +bool hasFormParameterValue(const std::shared_ptr& value) noexcept { + return value != nullptr; +} + +template +bool hasFormParameterValue(const std::optional& value) noexcept { + return value.has_value(); +} + +template +bool hasFormParameterValue(const boost::optional& value) noexcept { + return value.has_value(); +} + inline std::string toFormParameterValue(const std::string& value) { return value; @@ -96,16 +122,120 @@ 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 toFormParameterValue(const T& value) { - return boost::lexical_cast(value); +std::string toRawBodyValue(const std::optional& value) { + if (value.has_value()) { + return toRawBodyValue(value.value()); + } + return ""; +} + +// Generic toFormParameterValue: uses operator<< for arithmetic types, +// JSON serialization for object/map types via toRequestJsonValue. +template +struct FormParamSerializer { + static std::string serialize(const T& value) { + std::ostringstream stream; + stream << value; + return stream.str(); + } +}; + +// Map types serialize as JSON after the request converter overloads are defined. +template +struct FormParamSerializer, void> { + static std::string serialize(const std::map& mapValue); +}; + +// Detect types with toJsonValue() member — model classes. +template +struct HasFormToJsonValue : std::false_type {}; + +template +struct HasFormToJsonValue().toJsonValue())>> : std::true_type {}; + +template +std::string toFormParameterValue(const std::shared_ptr& value); + +template +std::string toFormParameterValue(const std::variant& value); + +template +std::string toFormParameterValue( + const CompositionBranchValue& value); + +template +std::string toFormParameterValue(const std::optional& value); + +template +typename std::enable_if::value, std::string>::type +toFormParameterValue(const std::vector& values); + +template +typename std::enable_if::value, std::string>::type +toFormParameterValue(const std::vector& values); + +// Model classes: serialize via toJsonValue() as JSON. +template +typename std::enable_if::value, std::string>::type +toFormParameterValue(const T& value) { + return boost::json::serialize(value.toJsonValue()); } +// Fallback for types without toJsonValue() — operator<< or map serializer. template -std::string toFormParameterValue(const std::vector& values) { +typename std::enable_if::value, std::string>::type +toFormParameterValue(const T& value) { + return FormParamSerializer::serialize(value); +} + +template +std::string toFormParameterValue(const std::shared_ptr& value) { + return value == nullptr ? "" : toFormParameterValue(*value); +} + +template +std::string toFormParameterValue(const std::variant& value) { + return std::visit([](const auto& alternative) -> std::string { + return toFormParameterValue(alternative); + }, value); +} + +template +std::string toFormParameterValue( + const CompositionBranchValue& value) { + return toFormParameterValue(value.value); +} + +template +std::string toFormParameterValue(const std::optional& value) { + if (value.has_value()) { + return toFormParameterValue(value.value()); + } + return ""; +} + +// Arrays of model types (HasFormToJsonValue): serialize as JSON array. +template +typename std::enable_if::value, std::string>::type +toFormParameterValue(const std::vector& values) { + boost::json::array jsonArray; + for (const auto& value : values) { + jsonArray.push_back(value.toJsonValue()); + } + return boost::json::serialize(jsonArray); +} + +// Arrays of types without toJsonValue: comma-delimited (OAS form-style). +template +typename std::enable_if::value, std::string>::type +toFormParameterValue(const std::vector& values) { std::stringstream serializedValues; const char* separator = ""; for (const auto& value : values) { @@ -115,6 +245,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; @@ -165,30 +300,356 @@ inline std::string percentEncodeQueryValue(const std::string& unencodedValue) { return percentEncodeRfc3986Value(unencodedValue); } +// ---- OpenAPI parameter serialization -------------------------------------- +// The wire layer is JSON-driven: every emitted parameter value converts to a +// boost::json::value (toJsonValue overloads below, incl. the generated +// parameter/model classes' toJsonValue()), then style/explode/allowReserved +// render it into the exact wire bytes of OAS 3.1 §Parameter Serialization. + +// allowReserved=true: keep the RFC 3986 reserved set raw in the query value +// (:/?#[]@!$&'()*+,;=). Default (false) = strict percent-encoding. +// RFC 6570 reserved expansion also preserves well-formed pct-encoded triplets. +inline std::string percentEncodeQueryReservedValue(const std::string& unencodedValue) { + static const char hexDigits[] = "0123456789ABCDEF"; + auto isHexDigit = [](unsigned char character) { + return (character >= '0' && character <= '9') + || (character >= 'A' && character <= 'F') + || (character >= 'a' && character <= 'f'); + }; + std::string encodedValue; + encodedValue.reserve(unencodedValue.size()); + for (std::size_t index = 0; index < unencodedValue.size(); ++index) { + const unsigned char character = + static_cast(unencodedValue[index]); + if (character == '%' && index + 2 < unencodedValue.size() + && isHexDigit(static_cast(unencodedValue[index + 1])) + && isHexDigit(static_cast(unencodedValue[index + 2]))) { + encodedValue.append(unencodedValue, index, 3); + index += 2; + } else if ((character >= 'a' && character <= 'z') + || (character >= 'A' && character <= 'Z') + || (character >= '0' && character <= '9') + || character == '-' || character == '.' || character == '_' + || character == '~' + || character == ':' || character == '/' || character == '?' + || character == '#' || character == '[' || character == ']' + || character == '@' || character == '!' || character == '$' + || character == '&' || character == '\'' || character == '(' + || character == ')' || character == '*' || character == '+' + || character == ',' || character == ';' || character == '=') { + encodedValue.push_back(static_cast(character)); + } else { + encodedValue.push_back('%'); + encodedValue.push_back(hexDigits[(character >> 4) & 0x0F]); + encodedValue.push_back(hexDigits[character & 0x0F]); + } + } + return encodedValue; +} + +inline boost::json::value toJsonValue(const boost::json::value& v) { return v; } +inline boost::json::value toJsonValue(const std::string& v) { return boost::json::value(v); } +inline boost::json::value toJsonValue(bool v) { return boost::json::value(v); } +inline boost::json::value toJsonValue(std::int32_t v) { return boost::json::value(v); } +inline boost::json::value toJsonValue(std::int64_t v) { return boost::json::value(v); } +inline boost::json::value toJsonValue(double v) { return boost::json::value(v); } +template +boost::json::value toJsonValue(const std::vector& value); + +template +boost::json::value toJsonValue(const std::map& value); + +template +boost::json::value toJsonValue(const std::shared_ptr& value); + +template +boost::json::value toJsonValue(const std::optional& value); + +template +boost::json::value toJsonValue(const std::variant& value); + +template +boost::json::value toJsonValue( + const CompositionBranchValue& value); + +template +boost::json::value toJsonValue(const T& value); + +template +boost::json::value toJsonValue(const std::vector& v) { + boost::json::array a; + a.reserve(v.size()); + for (const auto& e : v) { a.emplace_back(toJsonValue(e)); } + return boost::json::value(std::move(a)); +} +template +boost::json::value toJsonValue(const std::map& m) { + boost::json::object o; + for (const auto& kv : m) { o[kv.first] = toJsonValue(kv.second); } + return boost::json::value(std::move(o)); +} +template +boost::json::value toJsonValue(const std::shared_ptr& value) { + return value == nullptr ? boost::json::value(nullptr) : toJsonValue(*value); +} +template +boost::json::value toJsonValue(const std::optional& value) { + return value.has_value() ? toJsonValue(*value) : boost::json::value(nullptr); +} +template +boost::json::value toJsonValue(const std::variant& value) { + return std::visit([](const auto& alternative) -> boost::json::value { + return toJsonValue(alternative); + }, value); +} +template +boost::json::value toJsonValue( + const CompositionBranchValue& value) { + return toJsonValue(value.value); +} template -std::string serializePathParameterValue(const T& pathParameterValue) { - return percentEncodePathValue(toFormParameterValue(pathParameterValue)); +boost::json::value toJsonValue(const T& value) { + if constexpr (HasFormToJsonValue::value) { + return value.toJsonValue(); + } + // Legacy/exotic types degrade to the ostringstream string form. + return boost::json::value(toFormParameterValue(value)); +} + +inline std::string jsonScalarText(const boost::json::value& v) { + if (v.is_string()) { return std::string(v.as_string()); } + return boost::json::serialize(v); // numbers, booleans, null +} + +// ---- Operation server precedence ------------------------------------------ +inline std::string serverPathPrefix(const std::string& serverUrl) { + // The HttpClientImpl owns the connection (scheme, host, port); the API + // layer always passes origin-form targets. An absolute server URL + // contributes only its PATH prefix; a server host different from the + // caller's HttpClient configuration must be pointed at by the caller + // (documented seam). Relative server URLs are normalized to origin form. + std::string s = serverUrl; + const std::string authoritySeparator = "://"; + const std::size_t authority = s.find(authoritySeparator); + if (authority != std::string::npos) { + const std::size_t slash = s.find('/', authority + authoritySeparator.size()); + s = slash == std::string::npos ? std::string() : s.substr(slash); + } + if (!s.empty() && s.front() != '/') { + s.insert(s.begin(), '/'); + } + // drop trailing slashes (the request path always begins with '/'), + // including the lone root slash ('' is the correct empty prefix) + while (!s.empty() && s.back() == '/') { s.pop_back(); } + return s; } +inline std::string operationServerPrefix(const std::string& context, + const std::string& resolvedServer) { + return resolvedServer.empty() ? context : serverPathPrefix(resolvedServer); +} + +// ---- query: form / spaceDelimited / pipeDelimited / deepObject ----------- template -std::string serializePathParameterValue(const std::vector& pathParameterValues) { - std::stringstream serializedValues; - const char* separator = ""; - for (const T& pathParameterValue : pathParameterValues) { - serializedValues << separator - << percentEncodePathValue(toFormParameterValue(pathParameterValue)); - separator = ","; +void appendParamQueryParameter( + std::stringstream& queryParameterStream, + const char*& queryParameterSeparator, + const std::string& parameterName, + const T& parameterValue, + const std::string& style, + bool explode, + bool allowReserved, + bool allowEmptyValue) { + const boost::json::value v = toJsonValue(parameterValue); + // Empty-value policy (3.1): without allowEmptyValue an empty string value + // is omitted entirely. + if (!allowEmptyValue && v.is_string() && v.as_string().empty()) { + return; } - return serializedValues.str(); + const std::string encodedName = percentEncodeQueryValue(parameterName); + auto enc = [&](const std::string& s) { + return allowReserved ? percentEncodeQueryReservedValue(s) + : percentEncodeQueryValue(s); + }; + auto appendRaw = [&](const std::string& name, const std::string& value) { + queryParameterStream << queryParameterSeparator << name << '=' << value; + queryParameterSeparator = "&"; + }; + if (style == "deepObject" && v.is_object()) { + // OAS deepObject uses raw brackets as structural delimiters while the + // parameter and member names remain percent-encoded. + for (const auto& member : v.as_object()) { + appendRaw(encodedName + "[" + + percentEncodeQueryValue(std::string(member.key())) + "]", + enc(jsonScalarText(member.value()))); + } + return; + } + if (style == "form" || style == "spaceDelimited" || style == "pipeDelimited") { + // Wire delimiters: form uses a raw comma; spaceDelimited/pipeDelimited + // join with the ENCODED delimiter (%20 / %7C) — the raw space or pipe + // is not valid in a query string and must never appear on the wire. + const char* styleDelimiter = + style == "spaceDelimited" ? "%20" : + style == "pipeDelimited" ? "%7C" : ","; + if (explode) { + if (v.is_array()) { + for (const auto& element : v.as_array()) { + appendRaw(encodedName, enc(jsonScalarText(element))); + } + } else if (v.is_object()) { + // form explode=true object: k=v&k2=v2 — no base name + for (const auto& member : v.as_object()) { + appendRaw(percentEncodeQueryValue(std::string(member.key())), + enc(jsonScalarText(member.value()))); + } + } else { + appendRaw(encodedName, enc(jsonScalarText(v))); + } + } else { + if (v.is_array()) { + std::string joined; + bool first = true; + for (const auto& element : v.as_array()) { + if (!first) { joined += styleDelimiter; } + first = false; + joined += enc(jsonScalarText(element)); + } + appendRaw(encodedName, joined); + } else if (v.is_object()) { + // form explode=false object: name=k,v,k2,v2 + std::string joined; + bool first = true; + for (const auto& member : v.as_object()) { + if (!first) { joined += styleDelimiter; } + first = false; + joined += enc(std::string(member.key())); + joined += styleDelimiter; + joined += enc(jsonScalarText(member.value())); + } + appendRaw(encodedName, joined); + } else { + appendRaw(encodedName, enc(jsonScalarText(v))); + } + } + return; + } + // Unknown styles degrade to the form single-pair form (honest: the + // codegen emits only the styles handled above). + appendRaw(encodedName, enc(jsonScalarText(v))); +} + +// ---- path: simple / label / matrix --------------------------------------- +inline std::string pathStyleValue(const boost::json::value& v, + const std::string& style, + bool explode, + const std::string& parameterName) { + auto enc = [](const std::string& s) { return percentEncodePathValue(s); }; + auto scalar = [&](const boost::json::value& e) { return enc(jsonScalarText(e)); }; + auto encKey = [&](const boost::json::string& key) { + return enc(std::string(key)); + }; + std::string out; + if (style == "label") { + out += "."; + if (explode && v.is_object()) { + bool first = true; + for (const auto& member : v.as_object()) { + if (!first) { out += '.'; } + first = false; + out += encKey(member.key()) + '=' + scalar(member.value()); + } + } else if (v.is_array()) { + bool first = true; + for (const auto& element : v.as_array()) { + if (!first) { out += '.'; } + first = false; + out += scalar(element); + } + } else if (v.is_object()) { + bool first = true; + for (const auto& member : v.as_object()) { + if (!first) { out += '.'; } + first = false; + out += encKey(member.key()) + '.' + scalar(member.value()); + } + } else { + out += scalar(v); + } + } else if (style == "matrix") { + out += ';'; + if (explode && v.is_object()) { + bool first = true; + for (const auto& member : v.as_object()) { + if (!first) { out += ';'; } + first = false; + out += encKey(member.key()) + '=' + scalar(member.value()); + } + } else if (explode && v.is_array()) { + bool first = true; + for (const auto& element : v.as_array()) { + if (!first) { out += ';'; } + first = false; + out += parameterName + '=' + scalar(element); + } + } else if (v.is_array()) { + out += parameterName + '='; + bool first = true; + for (const auto& element : v.as_array()) { + if (!first) { out += ','; } + first = false; + out += scalar(element); + } + } else if (v.is_object()) { + out += parameterName + '='; + bool first = true; + for (const auto& member : v.as_object()) { + if (!first) { out += ','; } + first = false; + out += encKey(member.key()) + ',' + scalar(member.value()); + } + } else { + out += parameterName + '=' + scalar(v); + } + } else { // simple (path/header default) + if (explode && v.is_object()) { + bool first = true; + for (const auto& member : v.as_object()) { + if (!first) { out += ','; } + first = false; + out += encKey(member.key()) + '=' + scalar(member.value()); + } + } else if (v.is_array()) { + bool first = true; + for (const auto& element : v.as_array()) { + if (!first) { out += ','; } + first = false; + out += scalar(element); + } + } else if (v.is_object()) { + bool first = true; + for (const auto& member : v.as_object()) { + if (!first) { out += ','; } + first = false; + out += encKey(member.key()) + ',' + scalar(member.value()); + } + } else { + out += scalar(v); + } + } + return out; } template void replacePathParameter( std::string& path, const std::string& parameterName, - const T& parameterValue) { + const T& parameterValue, + const std::string& style, + bool explode) { const std::string placeholder = "{" + parameterName + "}"; - const std::string serializedValue = serializePathParameterValue(parameterValue); + const std::string serializedValue = + pathStyleValue(toJsonValue(parameterValue), style, explode, parameterName); std::string::size_type position = 0; while ((position = path.find(placeholder, position)) != std::string::npos) { path.replace(position, placeholder.size(), serializedValue); @@ -196,6 +657,7 @@ void replacePathParameter( } } +// ---- query single-value helpers (legacy call sites) ---------------------- template std::string serializeQueryParameterValue(const T& queryParameterValue) { return percentEncodeQueryValue(toFormParameterValue(queryParameterValue)); @@ -215,22 +677,6 @@ std::string serializeQueryParameterValue( return serializedValues.str(); } -template -std::string serializeQueryParameterValue( - const std::map& queryParameterValues, - const std::string& collectionDelimiter) { - std::stringstream serializedValues; - const char* separator = ""; - for (const auto& queryParameterValue : queryParameterValues) { - serializedValues << separator - << percentEncodeQueryValue(queryParameterValue.first) - << collectionDelimiter - << percentEncodeQueryValue(toFormParameterValue(queryParameterValue.second)); - separator = collectionDelimiter.c_str(); - } - return serializedValues.str(); -} - inline void appendQueryParameter( std::stringstream& queryParameterStream, const char*& queryParameterSeparator, @@ -242,81 +688,104 @@ inline void appendQueryParameter( queryParameterSeparator = "&"; } +// ---- header: simple (objects exploded as k=v, pairs comma-delimited) ----- template -void appendMultiQueryParameters( - std::stringstream& queryParameterStream, - const char*& queryParameterSeparator, - const std::string& parameterName, - const std::vector& queryParameterValues) { - for (const T& queryParameterValue : queryParameterValues) { - appendQueryParameter( - queryParameterStream, - queryParameterSeparator, - parameterName, - serializeQueryParameterValue(queryParameterValue)); - } -} - -template -void appendExplodedQueryParameters( - std::stringstream& queryParameterStream, - const char*& queryParameterSeparator, - const std::map& queryParameterValues) { - for (const auto& queryParameterValue : queryParameterValues) { - appendQueryParameter( - queryParameterStream, - queryParameterSeparator, - queryParameterValue.first, - serializeQueryParameterValue(queryParameterValue.second)); +std::string serializeHeaderParameterValue(const T& v, bool explode) { + // RFC 7230 field values: never percent-encoded. + const boost::json::value j = toJsonValue(v); + auto scalar = [](const boost::json::value& e) { return jsonScalarText(e); }; + auto key = [](const boost::json::string& k) { return std::string(k); }; + std::string out; + if (explode && j.is_object()) { + bool first = true; + for (const auto& member : j.as_object()) { + if (!first) { out += ','; } + first = false; + out += key(member.key()) + '=' + scalar(member.value()); + } + } else if (j.is_array()) { + bool first = true; + for (const auto& element : j.as_array()) { + if (!first) { out += ','; } + first = false; + out += scalar(element); + } + } else if (j.is_object()) { + bool first = true; + for (const auto& member : j.as_object()) { + if (!first) { out += ','; } + first = false; + out += key(member.key()) + ',' + scalar(member.value()); + } + } else { + out = scalar(j); } + return out; } +// ---- cookie: form style into the Cookie header (3.1) --------------------- template -void appendDeepObjectQueryParameters( - std::stringstream& queryParameterStream, - const char*& queryParameterSeparator, +void appendCookieParameter( + std::string& cookieHeader, const std::string& parameterName, - const std::map& queryParameterValues) { - for (const auto& queryParameterValue : queryParameterValues) { - appendQueryParameter( - queryParameterStream, - queryParameterSeparator, - parameterName + "[" + queryParameterValue.first + "]", - serializeQueryParameterValue(queryParameterValue.second)); + const T& parameterValue, + bool explode) { + const boost::json::value v = toJsonValue(parameterValue); + auto encode = [](const std::string& value) { + return percentEncodeRfc3986Value(value); + }; + const std::string encodedName = encode(parameterName); + auto scalar = [&](const boost::json::value& e) { + return encode(jsonScalarText(e)); + }; + auto key = [&](const boost::json::string& k) { + return encode(std::string(k)); + }; + auto push = [&](const std::string& pair) { + if (!cookieHeader.empty()) { cookieHeader += "; "; } + cookieHeader += pair; + }; + if (explode) { + if (v.is_array()) { + if (v.as_array().empty()) { return; } // empty = absent + for (const auto& element : v.as_array()) { + push(encodedName + '=' + scalar(element)); + } + } else if (v.is_object()) { + for (const auto& member : v.as_object()) { + push(key(member.key()) + '=' + scalar(member.value())); + } + } else { + push(encodedName + '=' + scalar(v)); + } + } else { + if (v.is_array()) { + if (v.as_array().empty()) { return; } + std::string joined; + bool first = true; + for (const auto& element : v.as_array()) { + if (!first) { joined += ','; } + first = false; + joined += scalar(element); + } + push(encodedName + '=' + joined); + } else if (v.is_object()) { + std::string joined; + bool first = true; + for (const auto& member : v.as_object()) { + if (!first) { joined += ','; } + first = false; + joined += key(member.key()) + ',' + scalar(member.value()); + } + push(encodedName + '=' + joined); + } else { + push(encodedName + '=' + scalar(v)); + } } } -inline std::string serializeHeaderParameterValue(const std::string& headerParameterValue) { - return headerParameterValue; -} - -inline std::string serializeHeaderParameterValue(bool headerParameterValue) { - return headerParameterValue ? "true" : "false"; -} - -template -typename std::enable_if::value, std::string>::type -serializeHeaderParameterValue(const T& headerParameterValue) { - return boost::lexical_cast(headerParameterValue); -} - -template -typename std::enable_if::value, std::string>::type -serializeHeaderParameterValue(const T&) { - throw std::invalid_argument( - "Header parameter serialization supports only primitive values and arrays of primitive values"); -} - -template -std::string serializeHeaderParameterValue(const std::vector& headerParameterValues) { - std::stringstream serializedValues; - const char* separator = ""; - for (const T& headerParameterValue : headerParameterValues) { - serializedValues << separator << serializeHeaderParameterValue(headerParameterValue); - separator = ","; - } - return serializedValues.str(); -} +// ---- header path (Void/raw helpers above; templates' header emission uses +// serializeHeaderParameterValue(T, explode) defined earlier) ---------- inline std::string serializeUrlEncodedFormData(const std::vector& formParameters) { std::stringstream serializedFormData; @@ -331,55 +800,6 @@ inline std::string serializeUrlEncodedFormData(const std::vector& return serializedFormData.str(); } -inline std::string selectMultipartBoundary(const std::vector& formParameters) { - std::string boundary = "OpenAPIGeneratorBoundary"; - for (;;) { - const auto collision = std::find_if( - formParameters.cbegin(), - formParameters.cend(), - [&boundary](const FormParameter& formParameter) { - return formParameter.value.find(boundary) != std::string::npos; - }); - if (collision == formParameters.cend()) { - return boundary; - } - boundary.push_back('X'); - } -} - -inline std::string escapeMultipartParameter(const std::string& value) { - std::string escapedValue; - escapedValue.reserve(value.size()); - for (const char character : value) { - if (character == '\\' || character == '"') { - escapedValue.push_back('\\'); - } - escapedValue.push_back(character); - } - return escapedValue; -} - -inline std::string serializeMultipartFormData( - const std::vector& formParameters, - const std::string& boundary) { - std::stringstream serializedFormData; - for (const auto& formParameter : formParameters) { - serializedFormData << "--" << boundary << "\r\n" - << "Content-Disposition: form-data; name=\"" - << escapeMultipartParameter(formParameter.name) << '"'; - if (formParameter.isFile) { - serializedFormData << "; filename=\"" - << escapeMultipartParameter(formParameter.name) << '"'; - } - serializedFormData << "\r\n"; - if (formParameter.isFile) { - serializedFormData << "Content-Type: application/octet-stream\r\n"; - } - serializedFormData << "\r\n" << formParameter.value << "\r\n"; - } - serializedFormData << "--" << boundary << "--\r\n"; - return serializedFormData.str(); -} namespace { @@ -396,9 +816,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 toRequestJsonValueImpl(const T& requestValue, std::true_type) { + return requestValue.toJsonValue(); +} + +// Dispatch for types without toJsonValue() — primitives, standard containers template -boost::json::value toRequestJsonValue(const T& requestValue); +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,14 +850,43 @@ 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 CompositionBranchValue& value); + + template boost::json::value toRequestJsonValue(const std::shared_ptr& requestValue) { - return requestValue == nullptr ? boost::json::value(nullptr) : requestValue->toJsonValue(); + return requestValue == nullptr + ? boost::json::value(nullptr) + : toRequestJsonValue(*requestValue); +} + +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 CompositionBranchValue& v) { + return toRequestJsonValue(v.value); +} + +template +boost::json::value toRequestJsonValue(const std::optional& requestValue) { + if (requestValue.has_value()) { + return toRequestJsonValue(requestValue.value()); + } + return boost::json::value(nullptr); } template @@ -437,10 +908,118 @@ boost::json::value toRequestJsonValue(const std::map& requestVal return requestObject; } +// ────────────────────────────────────────────────────────────────────── +// Multipart form parameter serialization helpers. +// +// serializeMultipartFormData produces wire-format multipart/form-data +// bodies per RFC 2046. Each FormParameter carries: +// - name : the part name (Content-Disposition form-data name) +// - value : the serialized part body +// - isFile : if true, adds filename and defaults to octet-stream +// - contentType : explicit Content-Type override (Encoding Object) +// - filename : caller-controlled file name (defaults to the part name) +// +// Content-Type precedence (OAS 3.0 §10.4): +// 1. Encoding Object contentType on the property +// 2. OAS default for the property type (octet-stream for binary) +// When no contentType is set and isFile is false, no Content-Type +// header is emitted for the part. +// ────────────────────────────────────────────────────────────────────── + +/// Classifies variant storage by the OAS multipart default for its schema type. +/// CompositionBranchValue is transparent because its tag preserves schema +/// identity, not a distinct wire representation. +template +struct MultipartVariantBranchTraits { + static constexpr bool isBinary = false; + static constexpr bool usesTextPlain = std::is_arithmetic_v + || std::is_enum_v + || std::is_same_v; +}; + +template +struct MultipartVariantBranchTraits> { + static constexpr bool isBinary = std::is_same_v; + static constexpr bool usesTextPlain = !isBinary + && MultipartVariantBranchTraits::usesTextPlain; +}; + +template +struct MultipartVariantBranchTraits> + : MultipartVariantBranchTraits {}; + +template +struct MultipartVariantBranchTraits> + : MultipartVariantBranchTraits {}; + +template +struct MultipartVariantBranchTraits> + : MultipartVariantBranchTraits {}; + +template +struct MultipartVariantBranchTraits> + : MultipartVariantBranchTraits {}; + +/// addVariantFormParameter definition (forward-declared above). +/// Must be defined after toRequestJsonValue so lambdas can find it via ADL. +/// An Encoding Object contentType overrides the branch default. Otherwise, +/// primitive branches and arrays of primitives use text/plain, complex values +/// use application/json, and byte containers use application/octet-stream. +template +void addVariantFormParameter( + std::vector& formParameters, + const std::string& name, + const VariantType& value, + const std::string& encodingContentType = "") { + std::visit([&](auto const& branch) { + using BranchType = std::decay_t; + using BranchTraits = MultipartVariantBranchTraits; + if constexpr (BranchTraits::isBinary) { + const std::string partContentType = encodingContentType.empty() + ? "application/octet-stream" : encodingContentType; + formParameters.emplace_back(name, toFormParameterValue(branch), true, + partContentType, name); + } else if constexpr (BranchTraits::usesTextPlain) { + const std::string partContentType = encodingContentType.empty() + ? "text/plain" : encodingContentType; + formParameters.emplace_back( + name, toFormParameterValue(branch), false, partContentType); + } else { + const std::string partContentType = encodingContentType.empty() + ? "application/json" : encodingContentType; + const std::string jsonValue = + boost::json::serialize(toRequestJsonValue(branch)); + formParameters.emplace_back(name, jsonValue, false, partContentType); + } + }, 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{}); } }; @@ -454,7 +1033,7 @@ struct ResponseJsonValueConverter { template struct ResponseJsonValueConverter> { static std::shared_ptr convert(const boost::json::value& responseValue) { - return responseValue.is_null() ? nullptr : std::make_shared(responseValue); + return responseValue.is_null() ? nullptr : std::make_shared(ResponseJsonValueConverter::convert(responseValue)); } }; @@ -485,6 +1064,220 @@ 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