diff --git a/.github/workflows/samples-cpp-boost-beast-server.yaml b/.github/workflows/samples-cpp-boost-beast-server.yaml new file mode 100644 index 000000000000..3374400b3539 --- /dev/null +++ b/.github/workflows/samples-cpp-boost-beast-server.yaml @@ -0,0 +1,91 @@ +name: Samples cpp boost beast server + +on: + push: + paths: + - "samples/server/petstore/cpp-boost-beast-server/**" + - ".github/workflows/samples-cpp-boost-beast-server.yaml" + - "modules/openapi-generator/src/main/resources/cpp-boost-beast-server/**" + - "modules/openapi-generator/src/main/resources/cpp-boost-beast-common/**" + - "modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/CppBoostBeastServer*.java" + - "modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/CppBoostBeastModelCodegen.java" + - "modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/CppBoostBeastClientCodegen.java" + - "modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/CppBoostBeastOperationFacts.java" + - "modules/openapi-generator/src/test/resources/3_1/cpp-boost-beast-server/**" + - "modules/openapi-generator/src/test/java/org/openapitools/codegen/cppboostbeastserver/**" + pull_request: + paths: + - "samples/server/petstore/cpp-boost-beast-server/**" + - ".github/workflows/samples-cpp-boost-beast-server.yaml" + - "modules/openapi-generator/src/main/resources/cpp-boost-beast-server/**" + - "modules/openapi-generator/src/main/resources/cpp-boost-beast-common/**" + - "modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/CppBoostBeastServer*.java" + - "modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/CppBoostBeastModelCodegen.java" + - "modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/CppBoostBeastClientCodegen.java" + - "modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/CppBoostBeastOperationFacts.java" + - "modules/openapi-generator/src/test/resources/3_1/cpp-boost-beast-server/**" + - "modules/openapi-generator/src/test/java/org/openapitools/codegen/cppboostbeastserver/**" + +jobs: + build: + name: Build cpp boost beast server + strategy: + matrix: + sample: + - samples/server/petstore/cpp-boost-beast-server + os: + - ubuntu-latest + - macOS-latest + - windows-latest + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v7 + + - name: Install dependencies (Linux) + if: matrix.os == 'ubuntu-latest' + run: | + sudo apt-get update + sudo apt-get install -y build-essential cmake libboost-dev libboost-json-dev libboost-url-dev + + - name: Install dependencies (macOS) + if: matrix.os == 'macOS-latest' + run: | + brew install boost cmake + + - name: Install dependencies (Windows) + if: matrix.os == 'windows-latest' + run: | + vcpkg install boost-asio:x64-windows boost-beast:x64-windows boost-json:x64-windows boost-url:x64-windows boost-multiprecision:x64-windows + shell: cmd + timeout-minutes: 30 + + - name: Build + working-directory: ${{ matrix.sample }} + run: | + if [ "${{ matrix.os }}" = "windows-latest" ]; then + cmake -S . -B build -DCMAKE_TOOLCHAIN_FILE="C:/vcpkg/scripts/buildsystems/vcpkg.cmake" + else + cmake -S . -B build -DCMAKE_EXPORT_COMPILE_COMMANDS=1 + fi + cmake --build build + shell: bash + + - name: Set up JDK 17 + if: matrix.os == 'ubuntu-latest' + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 17 + + # The loopback runtime regression compiles the generated contract code + # against the Boost installed above and asserts wire behavior. With the + # require flag set, missing Boost/compilers fail the job instead of + # silently skipping — this leg is the guaranteed execution of that suite. + - name: Run server runtime regression (Linux) + if: matrix.os == 'ubuntu-latest' + run: | + ./mvnw -pl modules/openapi-generator -am test \ + -Dtest=CppBoostBeastServerRuntimeTest \ + -Dsurefire.failIfNoSpecifiedTests=false \ + -Dcpp.boost.beast.require=true -q + shell: bash diff --git a/bin/configs/cpp-boost-beast-server-petstore.yaml b/bin/configs/cpp-boost-beast-server-petstore.yaml new file mode 100644 index 000000000000..1086e285ddc1 --- /dev/null +++ b/bin/configs/cpp-boost-beast-server-petstore.yaml @@ -0,0 +1,8 @@ +generatorName: cpp-boost-beast-server +outputDir: samples/server/petstore/cpp-boost-beast-server +inputSpec: modules/openapi-generator/src/test/resources/3_1/cpp-boost-beast-server/petstore.yaml +templateDir: modules/openapi-generator/src/main/resources/cpp-boost-beast-server +additionalProperties: + hideGenerationTimestamp: "true" + packageName: CppBoostBeastPetstoreServer + addApiImplStubs: "true" diff --git a/docs/generators.md b/docs/generators.md index 1c18af75a025..cea81ab08a0d 100644 --- a/docs/generators.md +++ b/docs/generators.md @@ -92,6 +92,7 @@ The following generators are available: * [ada-server](generators/ada-server.md) * [aspnet-fastendpoints](generators/aspnet-fastendpoints.md) * [aspnetcore](generators/aspnetcore.md) +* [cpp-boost-beast-server (beta)](generators/cpp-boost-beast-server.md) * [cpp-httplib-server](generators/cpp-httplib-server.md) * [cpp-oatpp-server](generators/cpp-oatpp-server.md) * [cpp-pistache-server](generators/cpp-pistache-server.md) diff --git a/docs/generators/cpp-boost-beast-server.md b/docs/generators/cpp-boost-beast-server.md new file mode 100644 index 000000000000..3f633e12afe4 --- /dev/null +++ b/docs/generators/cpp-boost-beast-server.md @@ -0,0 +1,277 @@ +--- +title: Documentation for the cpp-boost-beast-server Generator +--- + +## METADATA + +| Property | Value | Notes | +| -------- | ----- | ----- | +| generator name | cpp-boost-beast-server | pass this to the generate command after -g | +| generator stability | BETA | | +| generator type | SERVER | | +| generator language | C++ | | +| generator default templating engine | mustache | | +| helpTxt | Generates a C++ Boost.Beast HTTP server. | | + +## CONFIG OPTIONS +These options may be applied as additional-properties (cli) or configOptions (plugins). Refer to [configuration docs](https://openapi-generator.tech/docs/configuration) for more details. + +| Option | Description | Values | Default | +| ------ | ----------- | ------ | ------- | +|addApiImplStubs|Generate API implementation stubs that answer 501 problem+json and a sample main.cpp for quick start| |false| +|apiPackage|C++ namespace for apis (convention: name.space.api).| |org.openapitools.server.api| +|compileWithValidation|Emit schema-validation IR and kValidateOnDecode=true in generated ValidationTypes.h (default). Set to false to omit the IR.| |true| +|modelPackage|C++ namespace for models (convention: name.space.model).| |org.openapitools.server.model| +|packageName|C++ package and library name.| |CppBoostBeastServer| +|preserveAdditionalProperties|Retain undeclared JSON object members in generated object models and re-emit them; set to false for strict handling.| |false| +|tolerateNonNullableNulls|Treat explicit JSON null values as absent for generated model properties whose schemas do not allow null. Enabled by default; set to false for strict schema decoding.| |true| + +## IMPORT MAPPING + +| Type/Alias | Imports | +| ---------- | ------- | +|AnyType|#include "AnyType.h"| +|Null|#include <cstddef>| +|boost::json::value|#include <boost/json.hpp>| +|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>| + + +## INSTANTIATION TYPES + +| Type/Alias | Instantiated By | +| ---------- | --------------- | + + +## LANGUAGE PRIMITIVES + + + +## RESERVED WORDS + + + +## FEATURE SET + + +### Client Modification Feature +| Name | Supported | Defined By | +| ---- | --------- | ---------- | +|BasePath|✗|ToolingExtension +|Authorizations|✗|ToolingExtension +|UserAgent|✗|ToolingExtension +|MockServer|✗|ToolingExtension + +### Data Type Feature +| Name | Supported | Defined By | +| ---- | --------- | ---------- | +|Custom|✗|OAS2,OAS3 +|Int32|✓|OAS2,OAS3 +|Int64|✓|OAS2,OAS3 +|Float|✓|OAS2,OAS3 +|Double|✓|OAS2,OAS3 +|Decimal|✗|ToolingExtension +|String|✓|OAS2,OAS3 +|Byte|✗|OAS2,OAS3 +|Binary|✗|OAS2,OAS3 +|Boolean|✓|OAS2,OAS3 +|Date|✗|OAS2,OAS3 +|DateTime|✗|OAS2,OAS3 +|Password|✗|OAS2,OAS3 +|File|✓|OAS2 +|Uuid|✗| +|Array|✓|OAS2,OAS3 +|Null|✓|OAS3 +|AnyType|✓|OAS2,OAS3 +|Object|✓|OAS2,OAS3 +|Maps|✓|ToolingExtension +|CollectionFormat|✓|OAS2 +|CollectionFormatMulti|✓|OAS2 +|Enum|✓|OAS2,OAS3 +|ArrayOfEnum|✓|ToolingExtension +|ArrayOfModel|✓|ToolingExtension +|ArrayOfCollectionOfPrimitives|✓|ToolingExtension +|ArrayOfCollectionOfModel|✓|ToolingExtension +|ArrayOfCollectionOfEnum|✓|ToolingExtension +|MapOfEnum|✓|ToolingExtension +|MapOfModel|✓|ToolingExtension +|MapOfCollectionOfPrimitives|✓|ToolingExtension +|MapOfCollectionOfModel|✓|ToolingExtension +|MapOfCollectionOfEnum|✓|ToolingExtension + +### Documentation Feature +| Name | Supported | Defined By | +| ---- | --------- | ---------- | +|Readme|✓|ToolingExtension +|Model|✓|ToolingExtension +|Api|✓|ToolingExtension + +### Global Feature +| Name | Supported | Defined By | +| ---- | --------- | ---------- | +|Host|✗|OAS2,OAS3 +|BasePath|✗|OAS2,OAS3 +|Info|✓|OAS2,OAS3 +|Schemes|✗|OAS2,OAS3 +|PartialSchemes|✓|OAS2,OAS3 +|Consumes|✓|OAS2 +|Produces|✓|OAS2 +|ExternalDocumentation|✓|OAS2,OAS3 +|Examples|✓|OAS2,OAS3 +|XMLStructureDefinitions|✗|OAS2,OAS3 +|MultiServer|✗|OAS3 +|ParameterizedServer|✗|OAS3 +|ParameterStyling|✓|OAS3 +|Callbacks|✗|OAS3 +|LinkObjects|✗|OAS3 + +### Parameter Feature +| Name | Supported | Defined By | +| ---- | --------- | ---------- | +|Path|✓|OAS2,OAS3 +|Query|✓|OAS2,OAS3 +|Header|✓|OAS2,OAS3 +|Body|✓|OAS2 +|FormUnencoded|✗|OAS2 +|FormMultipart|✗|OAS2 +|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 + +### Security Feature +| Name | Supported | Defined By | +| ---- | --------- | ---------- | +|BasicAuth|✓|OAS2,OAS3 +|ApiKey|✓|OAS2,OAS3 +|OpenIDConnect|✗|OAS3 +|BearerToken|✓|OAS3 +|OAuth2_Implicit|✗|OAS2,OAS3 +|OAuth2_Password|✗|OAS2,OAS3 +|OAuth2_ClientCredentials|✗|OAS2,OAS3 +|OAuth2_AuthorizationCode|✗|OAS2,OAS3 +|SignatureAuth|✗|OAS3 +|AWSV4Signature|✗|ToolingExtension + +### Wire Format Feature +| Name | Supported | Defined By | +| ---- | --------- | ---------- | +|JSON|✓|OAS2,OAS3 +|XML|✗|OAS2,OAS3 +|PROTOBUF|✗|ToolingExtension +|Custom|✗|OAS2,OAS3 diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/CodegenConfig.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/CodegenConfig.java index d9b3d8550e53..13634166b0ec 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/CodegenConfig.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/CodegenConfig.java @@ -77,6 +77,16 @@ public interface CodegenConfig { String embeddedTemplateDir(); + /** + * Additional embedded (classpath) template directories searched after the + * generator's own embedded template directory. Directories are probed in + * order; the first containing the template wins. Used to share templates + * between related generators. + */ + default java.util.List additionalEmbeddedTemplateDirs() { + return java.util.Collections.emptyList(); + } + String modelFileFolder(); String modelTestFileFolder(); diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/DefaultCodegen.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/DefaultCodegen.java index e8f7eb86e02a..63d8b63d39f4 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/DefaultCodegen.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/DefaultCodegen.java @@ -233,6 +233,9 @@ apiTemplateFiles are for API outputs only (controllers/handlers). @Setter protected String templateDir; protected String embeddedTemplateDir; + /** Additional embedded (classpath) template directories searched after + * {@link #embeddedTemplateDir}; see {@link #additionalEmbeddedTemplateDirs()}. */ + protected List additionalEmbeddedTemplateDirs = new ArrayList<>(); protected Map additionalProperties = new HashMap<>(); protected Map serverVariables = new HashMap<>(); protected Map vendorExtensions = new HashMap<>(); @@ -1713,6 +1716,11 @@ public String embeddedTemplateDir() { } } + @Override + public List additionalEmbeddedTemplateDirs() { + return additionalEmbeddedTemplateDirs; + } + @Override public Map apiDocTemplateFiles() { return apiDocTemplateFiles; 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 edd97a8b10e4..d1ac5f851545 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,40 +1,18 @@ 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; -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 static org.openapitools.codegen.utils.StringUtils.camelize; public class CppBoostBeastClientCodegen extends CppBoostBeastModelCodegen { @@ -42,12 +20,6 @@ public class CppBoostBeastClientCodegen extends CppBoostBeastModelCodegen { 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"; @@ -58,83 +30,10 @@ public class CppBoostBeastClientCodegen extends CppBoostBeastModelCodegen { 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; - /** Retains undeclared JSON object members in generated object models. */ - private boolean preserveAdditionalProperties = false; - - 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"; - // 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 = ""; @@ -151,156 +50,11 @@ 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() { @@ -515,102 +269,9 @@ public CppBoostBeastClientCodegen() { 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 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); - } - - @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 - * - * @param name string to be camelized - * @return Camelized string - */ - @Override - public String getterAndSetterCapitalize(String name) { - if (name == null || name.length() == 0) { - return name; - } - - name = toVarName(name); - if (name.startsWith("_")) { - return "_" + camelize(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()) { @@ -703,30 +364,9 @@ public void processOpts() { additionalProperties.remove("exportDefine"); additionalProperties.remove("exportHeaderGuard"); } - String modelNamespace = modelPackage.replaceAll("\\.", "::"); - additionalProperties.put("modelNamespaceDeclarations", modelPackage.split("\\.")); - 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); - + // Preserve the historical validation precedence: formatAssertion, + // then sseSchemaMode, then the remaining shared options. + validateFormatAssertionPolicyOption(); // Configure whether SSE schemas describe the wire representation or the // parsed JSON event data. Unknown values use the documented default. if (additionalProperties.containsKey("sseSchemaMode")) { @@ -742,6 +382,7 @@ public void processOpts() { } } additionalProperties.put("sseSchemaMode", sseSchemaMode); + applySharedCppOptions(); sseOperationIds = parseNameSet( additionalProperties.get("sseOperationIds"), "sseOperationIds"); @@ -766,402 +407,8 @@ public void processOpts() { } 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); - } - preserveAdditionalProperties = false; - if (additionalProperties.containsKey("preserveAdditionalProperties")) { - Object raw = additionalProperties.get("preserveAdditionalProperties"); - if (raw instanceof Boolean) { - preserveAdditionalProperties = (Boolean) raw; - } else { - String value = raw.toString().trim(); - if (!"true".equalsIgnoreCase(value) && !"false".equalsIgnoreCase(value)) { - throw new IllegalArgumentException( - "preserveAdditionalProperties must be true or false: " + value); - } - preserveAdditionalProperties = Boolean.parseBoolean(value); - } - } - additionalProperties.put("preserveAdditionalProperties", preserveAdditionalProperties); - 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); - } - - /** - * Location to write model files. You can use the modelPackage() as defined - * when the class is instantiated - */ - @Override - public String modelFileFolder() { - return (outputFolder + "/model").replace("/", File.separator); - } - - /** - * Location to write api files. You can use the apiPackage() as defined when - * the class is instantiated - */ - @Override - public String apiFileFolder() { - return (outputFolder + "/api").replace("/", File.separator); - } - - @Override - public String toModelImport(String name) { - if (importMapping.containsKey(name)) { - return importMapping.get(name); - } else { - return "#include \"" + name + ".h\""; - } - } - - @Override - public CodegenModel fromModel(String name, Schema 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) { - String newImp = toModelImport(imp); - if (!newImp.isEmpty()) { - codegenModel.imports.add(newImp); - } - } - // Every model header declares vector conversion helpers. - codegenModel.imports.add("#include "); - if (preserveAdditionalProperties) { - codegenModel.imports.add("#include "); - codegenModel.imports.add("#include "); - codegenModel.imports.add("#include "); - reserveExtraJsonPropertyIdentifiers(codegenModel); - } - - // 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; - } - - @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; - } - - if (!codegenParameter.required) { - codegenParameter.vendorExtensions.put(X_CODEGEN_IS_OPTIONAL_QUERY_PARAMETER, true); - } - return codegenParameter; - } - - /** - * 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 { - style = "simple"; // path, header - } - } - Boolean explode = Boolean.TRUE.equals(parameter.getExplode()); - if (parameter.getExplode() == null) { - explode = "form".equals(style); // spec default - } - 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) { - if (style == Parameter.StyleEnum.SPACEDELIMITED) { - return "%20"; - } - if (style == Parameter.StyleEnum.PIPEDELIMITED) { - return "%7C"; - } - return ","; - } - - private void addContainerPropertyNames(List properties) { - for (CodegenProperty property : properties) { - CodegenProperty item = property.items; - while (item != null) { - item.vendorExtensions.put("x-container-property-name", property.name); - item = item.items; - } - } - } - - private void reserveExtraJsonPropertyIdentifiers(CodegenModel codegenModel) { - Set propertyAccessors = new HashSet<>(); - Set propertyMembers = new HashSet<>(); - if (codegenModel.allVars != null) { - for (CodegenProperty property : codegenModel.allVars) { - if (property.getter != null) { - propertyAccessors.add(property.getter); - } - if (property.setter != null) { - propertyAccessors.add(property.setter); - } - if (property.name != null) { - propertyMembers.add("m_" + property.name); - } - } - } - - int maxSuffix = propertyAccessors.size() + propertyMembers.size() + 2; - for (int suffix = 1; suffix <= maxSuffix; suffix++) { - String suffixText = suffix == 1 ? "" : Integer.toString(suffix); - String getter = "getExtraJsonProperties" + suffixText; - String setter = "setExtraJsonProperties" + suffixText; - String member = "m_extraJsonProperties" + suffixText; - if (propertyAccessors.contains(getter) || propertyAccessors.contains(setter) - || propertyMembers.contains(member)) { - continue; - } - codegenModel.vendorExtensions.put("x-cpp-extra-json-properties-getter", getter); - codegenModel.vendorExtensions.put("x-cpp-extra-json-properties-setter", setter); - codegenModel.vendorExtensions.put("x-cpp-extra-json-properties-member", member); - return; - } - throw new IllegalStateException("Unable to reserve C++ extra JSON property identifiers"); - } - - @Override - public String toModelFilename(String name) { - return toModelName(name); } - @Override - public String toApiFilename(String name) { - return toApiName(name); - } @Override public OperationsMap postProcessOperationsWithModels( @@ -1179,600 +426,4 @@ public OperationsMap postProcessOperationsWithModels( inferConditionalSseOperations, hasExplicitRootServers).assemble(objs, allModels); } - - - /** - * Optional - type declaration. This is a String which is used by the - * templates to instantiate your types. There is typically special handling - * for different property types - * - * @return a string value used as the `dataType` field for model templates, - * `returnType` for api templates - */ - @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) { - arrayType = getSchemaType(p) + "<" + getTypeDeclaration(inner) + ">"; - } else { - arrayType = "std::vector"; - } - // Nullable arrays must be wrapped in std::optional so null JSON - // values are representable. The array branch returns before the - // nullable fallback checks at the end of this method. - if (ModelUtils.isNullable(p)) { - return "std::optional<" + arrayType + ">"; - } - return arrayType; - } else if (ModelUtils.isMapSchema(p)) { - Schema inner = ModelUtils.getAdditionalProperties(p); - String innerType = inner == null ? "boost::json::value" : getTypeDeclaration(inner); - 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) - || 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"; - } else if (ModelUtils.isAnyType(p) || ModelUtils.isFreeFormObject(p, openAPI)) { - 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 "\"" + escapeCppStringContent(p.getDefault().toString()) + "\""; - } else { - return "\"\""; - } - } else if (ModelUtils.isBooleanSchema(p)) { - if (p.getDefault() != null) { - return p.getDefault().toString(); - } else { - return "false"; - } - } else if (ModelUtils.isDateSchema(p)) { - if (p.getDefault() != null) { - return "\"" + escapeCppStringContent(p.getDefault().toString()) + "\""; - } else { - return "\"\""; - } - } else if (ModelUtils.isDateTimeSchema(p)) { - if (p.getDefault() != null) { - return "\"" + escapeCppStringContent(p.getDefault().toString()) + "\""; - } else { - return "\"\""; - } - } else if (ModelUtils.isNumberSchema(p)) { - if (ModelUtils.isFloatSchema(p)) { // float - if (p.getDefault() != null) { - return p.getDefault().toString() + "f"; - } else { - return "0.0f"; - } - } else { // double - if (p.getDefault() != null) { - return p.getDefault().toString(); - } else { - return "0.0"; - } - } - } else if (ModelUtils.isIntegerSchema(p)) { - if (ModelUtils.isLongSchema(p)) { // long - if (p.getDefault() != null) { - return p.getDefault().toString() + "L"; - } else { - return "0L"; - } - } else { // integer - if (p.getDefault() != null) { - return p.getDefault().toString(); - } else { - return "0"; - } - } - } else if (ModelUtils.isByteArraySchema(p)) { - if (p.getDefault() != null) { - return "\"" + escapeCppStringContent(p.getDefault().toString()) + "\""; - } else { - return "\"\""; - } - } else if (ModelUtils.isMapSchema(p)) { - Schema inner = ModelUtils.getAdditionalProperties(p); - String innerType = inner == null ? "boost::json::value" : getTypeDeclaration(inner); - return "std::map()"; - } else if (ModelUtils.isArraySchema(p)) { - // Use getItems() directly to handle OpenAPI 3.1 JsonSchema - Schema inner = p.getItems(); - String innerType = inner != null ? getTypeDeclaration(inner) : "boost::json::value"; - return "std::vector<" + innerType + ">()"; - } else if (!StringUtils.isEmpty(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)) { - return "boost::json::value()"; - } - - return "nullptr"; - } - - @Override - public String toDefaultValue(CodegenProperty codegenProperty, Schema schema) { - if (codegenProperty != null) { - if (codegenProperty.dataType != null && codegenProperty.dataType.startsWith("std::shared_ptr<")) { - return "nullptr"; - } - 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); - - boolean isPrimitiveType = parameter.isPrimitiveType == Boolean.TRUE; - boolean isArray = parameter.isArray == Boolean.TRUE; - boolean isMap = parameter.isMap == Boolean.TRUE; - boolean isString = parameter.isString == Boolean.TRUE; - parameter.vendorExtensions.put(X_CODEGEN_IS_RAW_BODY, - isPrimitiveType || isString || parameter.isByteArray || parameter.isBinary - || "std::string".equals(parameter.dataType)); - - 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.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); - } - } - - /** - * Optional - OpenAPI type conversion. This is used to map OpenAPI types in - * a `Schema` into either language specific types via `typeMapping` or - * into complex models if there is not a mapping. - * - * @return a string value of the type or complex model for this property - */ - @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; - if (typeMapping.containsKey(openAPIType)) { - type = typeMapping.get(openAPIType); - } else { - type = openAPIType; - } - - modelName = toModelName(type); - return modelName; - } - - @Override - public void updateCodegenPropertyEnum(CodegenProperty var) { - // Remove prefix added by DefaultCodegen - String originalDefaultValue = var.defaultValue; - 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 index 7e24ce105452..5db482c63c41 100644 --- 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 @@ -62,7 +62,7 @@ public abstract class CppBoostBeastModelCodegen extends AbstractCppCodegen { 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); + protected final Logger LOGGER = LoggerFactory.getLogger(getClass()); /** 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 @@ -99,6 +99,1364 @@ protected static String idOf(io.swagger.v3.oas.models.Operation op) { protected Map> operationCallbacks = new HashMap<>(); protected Map> operationLinks = new HashMap<>(); + protected CppBoostBeastModelCodegen() { + // Shared model/validation templates live in cpp-boost-beast-common so + // client and server generators resolve them from a single source. + additionalEmbeddedTemplateDirs = new ArrayList<>(List.of("cpp-boost-beast-common")); + } + /** Policy for format metadata in composition branch matching. + * Formats remain annotations and never affect branch match counts. */ + protected String formatAssertionPolicy = "annotation"; + + /** Value type for the formatAssertion option. */ + protected static final String FORMAT_ASSERTION_POLICY_ANNOTATION = "annotation"; + /** Controls composition-branch validation during model decoding. */ + protected boolean validateOnDecode = true; + /** Retains undeclared JSON object members in generated object models. */ + protected boolean preserveAdditionalProperties = false; + 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"; + // 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"; + protected Map componentSchemaIdsByName = Collections.emptyMap(); + /** Starts an isolated state set for one generator invocation. */ + protected 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. + */ + protected 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()); + } + /** 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); + } + /** + * 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); + } + + @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); + } + } + @Override + protected ImmutableMap.Builder addMustacheLambdas() { + return super.addMustacheLambdas() + .put("cppStringLiteral", (fragment, writer) -> writer.write( + escapeCppStringContent( + StringEscapeUtils.unescapeHtml4(fragment.execute())))); + } + + @Override + public String escapeText(String input) { + return input == null ? null : escapeCppStringContent(input); + } + @Override + public String getterAndSetterCapitalize(String name) { + if (name == null || name.length() == 0) { + return name; + } + + name = toVarName(name); + + if (name.startsWith("_")) { + return "_" + camelize(name); + } + + return camelize(name); + } + protected 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")); + } + /** + * Location to write model files. You can use the modelPackage() as defined + * when the class is instantiated + */ + @Override + public String modelFileFolder() { + return (outputFolder + "/model").replace("/", File.separator); + } + + /** + * Location to write api files. You can use the apiPackage() as defined when + * the class is instantiated + */ + @Override + public String apiFileFolder() { + return (outputFolder + "/api").replace("/", File.separator); + } + + @Override + public String toModelImport(String name) { + if (importMapping.containsKey(name)) { + return importMapping.get(name); + } else { + return "#include \"" + name + ".h\""; + } + } + + @Override + public CodegenModel fromModel(String name, Schema 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) { + String newImp = toModelImport(imp); + if (!newImp.isEmpty()) { + codegenModel.imports.add(newImp); + } + } + // Every model header declares vector conversion helpers. + codegenModel.imports.add("#include "); + if (preserveAdditionalProperties) { + codegenModel.imports.add("#include "); + codegenModel.imports.add("#include "); + codegenModel.imports.add("#include "); + reserveExtraJsonPropertyIdentifiers(codegenModel); + } + + // 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; + } + + @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; + } + + if (!codegenParameter.required) { + codegenParameter.vendorExtensions.put(X_CODEGEN_IS_OPTIONAL_QUERY_PARAMETER, true); + } + return codegenParameter; + } + + /** + * 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. + */ + protected 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 { + style = "simple"; // path, header + } + } + Boolean explode = Boolean.TRUE.equals(parameter.getExplode()); + if (parameter.getExplode() == null) { + explode = "form".equals(style); // spec default + } + 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())); + } + + protected String queryCollectionDelimiter(Parameter.StyleEnum style) { + if (style == Parameter.StyleEnum.SPACEDELIMITED) { + return "%20"; + } + if (style == Parameter.StyleEnum.PIPEDELIMITED) { + return "%7C"; + } + return ","; + } + + protected void addContainerPropertyNames(List properties) { + for (CodegenProperty property : properties) { + CodegenProperty item = property.items; + while (item != null) { + item.vendorExtensions.put("x-container-property-name", property.name); + item = item.items; + } + } + } + + protected void reserveExtraJsonPropertyIdentifiers(CodegenModel codegenModel) { + Set propertyAccessors = new HashSet<>(); + Set propertyMembers = new HashSet<>(); + if (codegenModel.allVars != null) { + for (CodegenProperty property : codegenModel.allVars) { + if (property.getter != null) { + propertyAccessors.add(property.getter); + } + if (property.setter != null) { + propertyAccessors.add(property.setter); + } + if (property.name != null) { + propertyMembers.add("m_" + property.name); + } + } + } + + int maxSuffix = propertyAccessors.size() + propertyMembers.size() + 2; + for (int suffix = 1; suffix <= maxSuffix; suffix++) { + String suffixText = suffix == 1 ? "" : Integer.toString(suffix); + String getter = "getExtraJsonProperties" + suffixText; + String setter = "setExtraJsonProperties" + suffixText; + String member = "m_extraJsonProperties" + suffixText; + if (propertyAccessors.contains(getter) || propertyAccessors.contains(setter) + || propertyMembers.contains(member)) { + continue; + } + codegenModel.vendorExtensions.put("x-cpp-extra-json-properties-getter", getter); + codegenModel.vendorExtensions.put("x-cpp-extra-json-properties-setter", setter); + codegenModel.vendorExtensions.put("x-cpp-extra-json-properties-member", member); + return; + } + throw new IllegalStateException("Unable to reserve C++ extra JSON property identifiers"); + } + + @Override + public String toModelFilename(String name) { + return toModelName(name); + } + + @Override + public String toApiFilename(String name) { + return toApiName(name); + } + @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) { + arrayType = getSchemaType(p) + "<" + getTypeDeclaration(inner) + ">"; + } else { + arrayType = "std::vector"; + } + // Nullable arrays must be wrapped in std::optional so null JSON + // values are representable. The array branch returns before the + // nullable fallback checks at the end of this method. + if (ModelUtils.isNullable(p)) { + return "std::optional<" + arrayType + ">"; + } + return arrayType; + } else if (ModelUtils.isMapSchema(p)) { + Schema inner = ModelUtils.getAdditionalProperties(p); + String innerType = inner == null ? "boost::json::value" : getTypeDeclaration(inner); + 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) + || 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"; + } else if (ModelUtils.isAnyType(p) || ModelUtils.isFreeFormObject(p, openAPI)) { + 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. + */ + protected 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; + } + + protected 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); + } + + // Genuine containers lower to std::vector / std::map / std::set, where + // a scalar initializer cannot even compile — OpenAI's + // Eval.testing_criteria declares `default: eval` on an array of + // graders. JSON Schema keeps such a default as an annotation the + // decode path cannot honour; drop it so the member value-initializes + // instead of emitting broken code. Composed carriers keep their + // scalar default even when an alternative is a container: the final + // member is a variant alias, and postProcessAllModels rewrites the + // default to fromJsonValue_(...). + if (property.isContainer) { + return null; + } + if (nullableInner instanceof String) { + String inner = (String) nullableInner; + if (inner.startsWith("std::vector<") + || inner.startsWith("std::map<") + || inner.startsWith("std::set<")) { + return null; + } + } + + 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; + } + + protected 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 "\"" + escapeCppStringContent(p.getDefault().toString()) + "\""; + } else { + return "\"\""; + } + } else if (ModelUtils.isBooleanSchema(p)) { + if (p.getDefault() != null) { + return p.getDefault().toString(); + } else { + return "false"; + } + } else if (ModelUtils.isDateSchema(p)) { + if (p.getDefault() != null) { + return "\"" + escapeCppStringContent(p.getDefault().toString()) + "\""; + } else { + return "\"\""; + } + } else if (ModelUtils.isDateTimeSchema(p)) { + if (p.getDefault() != null) { + return "\"" + escapeCppStringContent(p.getDefault().toString()) + "\""; + } else { + return "\"\""; + } + } else if (ModelUtils.isNumberSchema(p)) { + if (ModelUtils.isFloatSchema(p)) { // float + if (p.getDefault() != null) { + return p.getDefault().toString() + "f"; + } else { + return "0.0f"; + } + } else { // double + if (p.getDefault() != null) { + return p.getDefault().toString(); + } else { + return "0.0"; + } + } + } else if (ModelUtils.isIntegerSchema(p)) { + if (ModelUtils.isLongSchema(p)) { // long + if (p.getDefault() != null) { + return p.getDefault().toString() + "L"; + } else { + return "0L"; + } + } else { // integer + if (p.getDefault() != null) { + return p.getDefault().toString(); + } else { + return "0"; + } + } + } else if (ModelUtils.isByteArraySchema(p)) { + if (p.getDefault() != null) { + return "\"" + escapeCppStringContent(p.getDefault().toString()) + "\""; + } else { + return "\"\""; + } + } else if (ModelUtils.isMapSchema(p)) { + Schema inner = ModelUtils.getAdditionalProperties(p); + String innerType = inner == null ? "boost::json::value" : getTypeDeclaration(inner); + return "std::map()"; + } else if (ModelUtils.isArraySchema(p)) { + // Use getItems() directly to handle OpenAPI 3.1 JsonSchema + Schema inner = p.getItems(); + String innerType = inner != null ? getTypeDeclaration(inner) : "boost::json::value"; + return "std::vector<" + innerType + ">()"; + } else if (!StringUtils.isEmpty(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)) { + return "boost::json::value()"; + } + + return "nullptr"; + } + + @Override + public String toDefaultValue(CodegenProperty codegenProperty, Schema schema) { + if (codegenProperty != null) { + if (codegenProperty.dataType != null && codegenProperty.dataType.startsWith("std::shared_ptr<")) { + return "nullptr"; + } + 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); + + boolean isPrimitiveType = parameter.isPrimitiveType == Boolean.TRUE; + boolean isArray = parameter.isArray == Boolean.TRUE; + boolean isMap = parameter.isMap == Boolean.TRUE; + boolean isString = parameter.isString == Boolean.TRUE; + parameter.vendorExtensions.put(X_CODEGEN_IS_RAW_BODY, + isPrimitiveType || isString || parameter.isByteArray || parameter.isBinary + || "std::string".equals(parameter.dataType)); + + // A parameter whose schema failed to resolve (unresolvable $ref) + // carries no dataType; the server generator's degrade path drops it + // with a warning. Wrapping null would NPE here. + if (parameter.dataType != null + && !isPrimitiveType && !isArray && !isMap && !isString && !parameter.dataType.startsWith("std::shared_ptr") + && !"boost::json::value".equals(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 && rejectsUnsupportedFormEncodingStyles()) { + 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); + } + } + /** + * Whether {@link #postProcessParameter(CodegenParameter)} must fail closed + * on form parameters using space-delimited, pipe-delimited, or deep-object + * encodings. The client rejects them because its multipart writer cannot + * serialize them; generators whose runtime never parses form bodies at all + * (the server) override this to {@code false} so such fields follow the + * documented warn-and-drop degradation instead of aborting generation. + */ + protected boolean rejectsUnsupportedFormEncodingStyles() { + return true; + } + + @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; + if (typeMapping.containsKey(openAPIType)) { + type = typeMapping.get(openAPIType); + } else { + type = openAPIType; + } + + modelName = toModelName(type); + return modelName; + } + @Override + public void updateCodegenPropertyEnum(CodegenProperty var) { + // Remove prefix added by DefaultCodegen + String originalDefaultValue = var.defaultValue; + super.updateCodegenPropertyEnum(var); + var.defaultValue = originalDefaultValue; + } + protected 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; + } + + @Override + public void preprocessOpenAPI(OpenAPI openAPI) { + beginGeneration(openAPI); + hasExplicitRootServers = detectExplicitRootServers(); + // The license header embeds the document title, description, version + // and contact email verbatim inside a C++ block comment; a '*/' + // sequence would terminate it and inject arbitrary code, and NUL + // bytes cannot appear in source. Neutralize both before any template + // reads them (DefaultCodegen copies appDescription later, in + // processOpenAPI). + if (openAPI.getInfo() != null) { + io.swagger.v3.oas.models.info.Info info = openAPI.getInfo(); + info.setTitle(sanitizeCommentText(info.getTitle())); + info.setDescription(sanitizeCommentText(info.getDescription())); + info.setVersion(sanitizeCommentText(info.getVersion())); + if (info.getContact() != null) { + info.getContact().setEmail( + sanitizeCommentText(info.getContact().getEmail())); + } + } + + List policyDiagnostics = validateDialectPolicy(openAPI); + if (!policyDiagnostics.isEmpty()) { + throw new IllegalArgumentException(String.join("; ", policyDiagnostics)); + } + super.preprocessOpenAPI(openAPI); + // Webhooks are inbound-only metadata for client and server + // generators. 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())) { + // getTypeDeclaration matches the RAW component name (the + // $ref simple name), postProcessParameter matches the + // model CLASS name; register both spellings so composed + // models keep value semantics on either path. + variantModels.add(schemaName); + variantModels.add(toModelName(schemaName)); + } + } + } +} + + /** Shared namespace/validation options common to client and server generators. */ + protected void applySharedCppOptions() { + String modelNamespace = modelPackage.replaceAll("\\.", "::"); + additionalProperties.put("modelNamespaceDeclarations", modelPackage.split("\\.")); + 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("\\.", "::")); + + validateFormatAssertionPolicyOption(); + + 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(CppBoostBeastModelCodegen::isSchemaValidationSupportingFile); + } + preserveAdditionalProperties = false; + if (additionalProperties.containsKey("preserveAdditionalProperties")) { + Object raw = additionalProperties.get("preserveAdditionalProperties"); + if (raw instanceof Boolean) { + preserveAdditionalProperties = (Boolean) raw; + } else { + String value = raw.toString().trim(); + if (!"true".equalsIgnoreCase(value) && !"false".equalsIgnoreCase(value)) { + throw new IllegalArgumentException( + "preserveAdditionalProperties must be true or false: " + value); + } + preserveAdditionalProperties = Boolean.parseBoolean(value); + } + } + additionalProperties.put("preserveAdditionalProperties", preserveAdditionalProperties); + 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); + } + + /** Validates the optional formatAssertionPolicy knob and pins the + * supported policy. Extracted so the client generator can preserve its + * historical validation precedence (format before sseSchemaMode). */ + protected void validateFormatAssertionPolicyOption() { + 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); + } + + protected void captureOperationMetadata(OpenAPI openAPI) { operationCallbacks.clear(); operationLinks.clear(); @@ -222,6 +1580,18 @@ public Map updateAllModels(Map objs) { } } + // Stamp every model with its component schema IR id after upstream + // model updates complete (mirrors the pre-refactor client pipeline). + refreshComponentSchemaIds(openAPI); + for (Map.Entry entry : objs.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 objs; } @@ -1717,6 +3087,28 @@ static String escapeCppStringContent(String value) { return escaped.toString(); } + /** + * Neutralizes text destined for a generated C++ block comment. The + * license header embeds the document title, description, version and + * contact email verbatim inside a /** ... */ comment; a literal + * */ sequence would terminate the comment early and turn the rest + * of the header text into code, and NUL bytes cannot legally appear in + * a C++ translation unit. Replaces every */ with '* /' (visually + * identical intent, comment-safe; the substitution can never re-form + * the terminator since only a space is inserted between the two + * characters) and rewrites NUL to a space. + */ + static String sanitizeCommentText(String value) { + if (value == null) { + return null; + } + String out = value.indexOf('\0') >= 0 ? value.replace('\0', ' ') : value; + while (out.contains("*/")) { + out = out.replace("*/", "* /"); + } + return out; + } + protected static String toPreprocessorIdentifier(String value) { String sanitized = value.replaceAll("[^A-Za-z0-9_]", "_"); if (!sanitized.isEmpty() && Character.isDigit(sanitized.charAt(0))) { diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/CppBoostBeastOperationFacts.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/CppBoostBeastOperationFacts.java new file mode 100644 index 000000000000..423f62838ed6 --- /dev/null +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/CppBoostBeastOperationFacts.java @@ -0,0 +1,148 @@ +/* + * 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 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 org.openapitools.codegen.CodegenOperation; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Direction-agnostic operation facts shared by the Boost.Beast client and + * server template assemblers: raw-operation lookup, effective security + * groups, and server-list classification. + */ +final class CppBoostBeastOperationFacts { + private CppBoostBeastOperationFacts() { + } + + /** True when the list is exactly swagger-parser's implicit root default + * (a single Server with url "/") and the raw source omitted {@code servers}. */ + 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 {@code security: []} + * clears inherited requirements. */ + static List>> effectiveSecurityGroups( + OpenAPI document, CodegenOperation op) { + List>> groups = new ArrayList<>(); + List requirements = null; + io.swagger.v3.oas.models.Operation raw = operationFor(document, op); + if (raw != null && raw.getSecurity() != null) { + requirements = raw.getSecurity(); // includes `[]` clears + } else if (document != null + && document.getSecurity() != null) { + requirements = document.getSecurity(); + } + if (requirements == null) { + return groups; // no security declared + } + Map schemes = document != null + && document.getComponents() != null + ? document.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). */ + static io.swagger.v3.oas.models.Operation operationFor( + OpenAPI document, CodegenOperation op) { + if (document == null || document.getPaths() == null) { + return null; + } + PathItem item = document.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; + } + + private static String cppString(String value) { + return CppBoostBeastModelCodegen.escapeCppStringContent( + value == null ? "" : value); + } +} diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/CppBoostBeastServerCodegen.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/CppBoostBeastServerCodegen.java new file mode 100644 index 000000000000..30b32046e95e --- /dev/null +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/CppBoostBeastServerCodegen.java @@ -0,0 +1,1160 @@ +/* + * 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 io.swagger.v3.oas.models.Operation; +import io.swagger.v3.oas.models.PathItem; +import io.swagger.v3.oas.models.media.Content; +import io.swagger.v3.oas.models.media.Schema; +import io.swagger.v3.oas.models.parameters.RequestBody; +import io.swagger.v3.oas.models.responses.ApiResponse; +import io.swagger.v3.oas.models.security.SecurityScheme; +import org.openapitools.codegen.utils.ModelUtils; +import org.openapitools.codegen.CodegenType; +import org.openapitools.codegen.SupportingFile; +import org.openapitools.codegen.meta.GeneratorMetadata; +import org.openapitools.codegen.meta.Stability; +import org.openapitools.codegen.meta.features.DataTypeFeature; +import org.openapitools.codegen.meta.features.DocumentationFeature; +import org.openapitools.codegen.meta.features.GlobalFeature; +import org.openapitools.codegen.meta.features.ParameterFeature; +import org.openapitools.codegen.meta.features.SchemaSupportFeature; +import org.openapitools.codegen.meta.features.SecurityFeature; +import org.openapitools.codegen.meta.features.WireFormatFeature; +import org.openapitools.codegen.model.ModelMap; +import org.openapitools.codegen.model.OperationsMap; +import org.apache.commons.lang3.StringUtils; + +import java.io.File; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.EnumSet; +import java.util.HashMap; +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.Set; + +/** + * C++ Boost.Beast HTTP server code generator. Emits an asynchronous + * HTTP/1.1 server (Boost.Beast + Boost.Asio + Boost.URL) with typed + * per-operation request/response contracts, OAS parameter deserialization, + * a pluggable security authorizer seam, RFC 9457 problem responses, and + * decode-time OAS 3.1 schema validation shared with the client generator. + * + *

Mustache templates are located in + * {@code src/main/resources/cpp-boost-beast-server/} with shared + * model/validation templates resolved from {@code cpp-boost-beast-common}. + */ +public class CppBoostBeastServerCodegen extends CppBoostBeastModelCodegen { + + public static final String DEFAULT_PACKAGE_NAME = "CppBoostBeastServer"; + public static final String ADD_API_IMPL_STUBS = "addApiImplStubs"; + + protected String packageName = DEFAULT_PACKAGE_NAME; + + @Override + public CodegenType getTag() { + return CodegenType.SERVER; + } + + @Override + public String getName() { + return "cpp-boost-beast-server"; + } + + @Override + public String getHelp() { + return "Generates a C++ Boost.Beast HTTP server."; + } + + public CppBoostBeastServerCodegen() { + super(); + openapiNormalizer.put("NORMALIZER_CLASS", + CppBoostBeastClientCodegen.CppBoostBeastOpenAPINormalizer.class.getName()); + generatorMetadata = GeneratorMetadata.newBuilder(generatorMetadata) + .stability(Stability.BETA) + .build(); + modifyFeatureSet(features -> features + .includeDocumentationFeatures(DocumentationFeature.Readme) + .securityFeatures(EnumSet.of( + SecurityFeature.ApiKey, + SecurityFeature.BasicAuth, + SecurityFeature.BearerToken)) + .wireFormatFeatures(EnumSet.of(WireFormatFeature.JSON)) + .includeGlobalFeatures( + GlobalFeature.ParameterStyling + ) + .excludeGlobalFeatures( + GlobalFeature.XMLStructureDefinitions, + GlobalFeature.Callbacks, + GlobalFeature.LinkObjects, + // The generated Router registers operation paths + // verbatim and the server binds the endpoint the + // caller passes to listen(): the document's server + // URLs (host and any base path) are not mounted, so + // host, base-path, and multi-server routing are not + // implemented. + GlobalFeature.Host, + GlobalFeature.BasePath, + GlobalFeature.MultiServer + ) + .excludeParameterFeatures( + // Non-JSON request bodies degrade to "no typed body" + // (see collectSurfaceWarnings): the runtime never + // parses urlencoded or multipart payloads. + ParameterFeature.FormUnencoded, + ParameterFeature.FormMultipart + ) + .includeSchemaSupportFeatures( + SchemaSupportFeature.Polymorphism, + SchemaSupportFeature.Composite, + SchemaSupportFeature.oneOf, + SchemaSupportFeature.anyOf, + SchemaSupportFeature.allOf, + SchemaSupportFeature.not, + SchemaSupportFeature.Union + ) + .includeDataTypeFeatures( + DataTypeFeature.Int32, + DataTypeFeature.Int64, + DataTypeFeature.Float, + DataTypeFeature.Double, + DataTypeFeature.String, + DataTypeFeature.Boolean, + DataTypeFeature.Enum, + DataTypeFeature.Array, + DataTypeFeature.Maps, + DataTypeFeature.Object, + DataTypeFeature.Null, + DataTypeFeature.AnyType + ) + .excludeDataTypeFeatures( + DataTypeFeature.Decimal, + DataTypeFeature.Date, + DataTypeFeature.DateTime, + DataTypeFeature.Uuid, + DataTypeFeature.Byte, + DataTypeFeature.Binary, + DataTypeFeature.Password + ) + .includeParameterFeatures( + ParameterFeature.Cookie + ) + ); + + outputFolder = "generated-code" + File.separator + "cpp-boost-beast-server"; + modelTemplateFiles.put("model-header.mustache", ".h"); + modelTemplateFiles.put("model-source.mustache", ".cpp"); + apiTemplateFiles.put("api-header.mustache", ".h"); + apiTemplateFiles.put("api-source.mustache", ".cpp"); + + embeddedTemplateDir = templateDir = "cpp-boost-beast-server"; + + modelPackage = "org.openapitools.server.model"; + apiPackage = "org.openapitools.server.api"; + + cliOptions.clear(); + + addOption(org.openapitools.codegen.CodegenConstants.PACKAGE_NAME, + "C++ package and library name.", DEFAULT_PACKAGE_NAME); + addOption(org.openapitools.codegen.CodegenConstants.MODEL_PACKAGE, + "C++ namespace for models (convention: name.space.model).", this.modelPackage); + addOption(org.openapitools.codegen.CodegenConstants.API_PACKAGE, + "C++ namespace for apis (convention: name.space.api).", this.apiPackage); + org.openapitools.codegen.CliOption compileWithValidationOption = + new org.openapitools.codegen.CliOption("compileWithValidation", + "Emit schema-validation IR and kValidateOnDecode=true in generated" + + " ValidationTypes.h (default). Set to false to omit the IR."); + compileWithValidationOption.defaultValue(Boolean.TRUE.toString()); + cliOptions.add(compileWithValidationOption); + org.openapitools.codegen.CliOption tolerateOption = + new org.openapitools.codegen.CliOption( + "tolerateNonNullableNulls", + "Treat explicit JSON null values as absent for generated model" + + " properties whose schemas do not allow null. Enabled by" + + " default; set to false for strict schema decoding."); + tolerateOption.defaultValue(Boolean.TRUE.toString()); + cliOptions.add(tolerateOption); + org.openapitools.codegen.CliOption preserveOption = + new org.openapitools.codegen.CliOption( + "preserveAdditionalProperties", + "Retain undeclared JSON object members in generated object models" + + " and re-emit them; set to false for strict handling."); + preserveOption.defaultValue(Boolean.FALSE.toString()); + cliOptions.add(preserveOption); + org.openapitools.codegen.CliOption stubsOption = + org.openapitools.codegen.CliOption.newBoolean( + ADD_API_IMPL_STUBS, + "Generate API implementation stubs that answer 501 problem+json" + + " and a sample main.cpp for quick start"); + stubsOption.defaultValue(Boolean.FALSE.toString()); + cliOptions.add(stubsOption); + + supportingFiles.add(new SupportingFile("validation-types.mustache", "model", "ValidationTypes.h")); + supportingFiles.add(new SupportingFile("NullableField.h.mustache", "model", "NullableField.h")); + supportingFiles.add(new SupportingFile("anytype-header.mustache", "model", "AnyType.h")); + 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")); + 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")); + + supportingFiles.add(new SupportingFile("http-server-header.mustache", "server", "HttpServer.h")); + supportingFiles.add(new SupportingFile("http-server-source.mustache", "server", "HttpServer.cpp")); + supportingFiles.add(new SupportingFile("router-header.mustache", "server", "Router.h")); + supportingFiles.add(new SupportingFile("responder-header.mustache", "server", "Responder.h")); + supportingFiles.add(new SupportingFile("problem-header.mustache", "server", "Problem.h")); + supportingFiles.add(new SupportingFile("authorizer-header.mustache", "server", "Authorizer.h")); + supportingFiles.add(new SupportingFile("param-codecs-header.mustache", "server", "ParamCodecs.h")); + supportingFiles.add(new SupportingFile("body-json-header.mustache", "server", "BodyJson.h")); + supportingFiles.add(new SupportingFile("README.mustache", "", "README.md")); + supportingFiles.add(new SupportingFile("CMakeLists.txt.mustache", "", "CMakeLists.txt")); + + languageSpecificPrimitives = new HashSet( + Arrays.asList("int", "char", "bool", "long", "float", "double", + "std::int32_t", "std::int64_t")); + + // Replace (do not inherit) the base maps: DefaultCodegen seeds + // AnyType -> oas_any_type_not_mapped, a placeholder header that no + // C++ template provides. Untyped schemas resolve through the model + // pipeline's isAnyType branch (boost::json::value); mirroring the + // client's wiped-map initialization keeps both generators on one + // convention. + super.typeMapping = new HashMap(); + typeMapping.put("date", "std::string"); + typeMapping.put("DateTime", "std::string"); + typeMapping.put("string", "std::string"); + typeMapping.put("integer", "std::int32_t"); + typeMapping.put("long", "std::int64_t"); + typeMapping.put("boolean", "bool"); + typeMapping.put("array", "std::vector"); + typeMapping.put("set", "std::vector"); + typeMapping.put("map", "std::map"); + typeMapping.put("file", "std::string"); + typeMapping.put("object", "boost::json::value"); + typeMapping.put("number", "double"); + typeMapping.put("UUID", "std::string"); + typeMapping.put("URI", "std::string"); + typeMapping.put("ByteArray", "std::string"); + + importMapping.put("std::vector", "#include "); + importMapping.put("std::map", "#include "); + importMapping.put("std::string", "#include "); + importMapping.put("int32_t", "#include "); + importMapping.put("int64_t", "#include "); + 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 + public void processOpts() { + super.processOpts(); + packageName = additionalProperties.getOrDefault( + org.openapitools.codegen.CodegenConstants.PACKAGE_NAME, + DEFAULT_PACKAGE_NAME).toString(); + if (StringUtils.isBlank(packageName)) { + throw new IllegalArgumentException("packageName must not be blank"); + } + additionalProperties.put( + org.openapitools.codegen.CodegenConstants.PACKAGE_NAME, packageName); + applySharedCppOptions(); + + boolean addStubs = Boolean.parseBoolean( + additionalProperties.getOrDefault(ADD_API_IMPL_STUBS, Boolean.FALSE) + .toString()); + additionalProperties.put(ADD_API_IMPL_STUBS, addStubs); + if (addStubs) { + supportingFiles.add(new SupportingFile("main.mustache", "", "main.cpp")); + } + } + + @Override + public void preprocessOpenAPI(OpenAPI openAPI) { + super.preprocessOpenAPI(openAPI); + for (String warning : collectSurfaceWarnings(openAPI)) { + LOGGER.warn("cpp-boost-beast-server: {}", warning); + } + List rejections = validateServerSupportSurface(openAPI); + if (!rejections.isEmpty()) { + throw new IllegalArgumentException( + "cpp-boost-beast-server: " + String.join("; ", rejections)); + } + } + + /** + * Surfaces the runtime cannot faithfully serve but CAN degrade safely + * on. These warn instead of failing the build because the repository + * contract (AllGeneratorsTest) requires every registered generator to + * generate from the canonical petstore spec, which declares XML, form, + * and multipart payloads plus an oauth2 scheme, and because real-world + * corpora (the OpenAI spec) declare parameter and body shapes the JSON + * runtime cannot decode. Degrade semantics: + *

    + *
  • request media types are filtered to JSON; an operation left with + * no JSON media type loses its typed body and mixed bodies answer + * 415 to non-JSON content types;
  • + *
  • parameters the runtime cannot decode from a raw string are + * dropped from the generated handler (the assembler applies the + * equivalent dataType-level rule);
  • + *
  • security schemes without a credential extractor deny all + * requests with 401.
  • + *
+ */ + List collectSurfaceWarnings(OpenAPI openAPI) { + List warnings = new ArrayList<>(); + if (openAPI == null || openAPI.getPaths() == null) { + return warnings; + } + for (Map.Entry pathEntry : openAPI.getPaths().entrySet()) { + PathItem item = pathEntry.getValue(); + if (item == null || item.readOperationsMap() == null) { + continue; + } + for (Map.Entry opEntry + : item.readOperationsMap().entrySet()) { + Operation operation = opEntry.getValue(); + if (operation == null) { + continue; + } + String operationId = operation.getOperationId() != null + ? operation.getOperationId() + : opEntry.getKey() + " " + pathEntry.getKey(); + RequestBody body = operation.getRequestBody() != null + ? ModelUtils.getReferencedRequestBody( + openAPI, operation.getRequestBody()) + : null; + if (body != null && body.getContent() != null + && !body.getContent().isEmpty()) { + boolean hasJson = false; + List dropped = new ArrayList<>(); + for (String mediaType : body.getContent().keySet()) { + // Mirrors the assembler's requestBodyFacts filter: + // only JSON family decodes; the wildcard and every + // other type is dropped (and warned here). + if (isSupportedRequestMediaType(mediaType)) { + hasJson = true; + } else { + dropped.add(mediaType); + } + } + if (!hasJson) { + warnings.add("operation '" + operationId + + "' declares no JSON request media type (" + + String.join(", ", dropped) + + "); the generated handler receives no typed body"); + } else if (!dropped.isEmpty()) { + warnings.add("operation '" + operationId + + "' also declares request media types the JSON" + + " decoder cannot parse (" + String.join(", ", dropped) + + "); only the JSON declarations are accepted at" + + " runtime (415 for the rest)"); + } + } + // Parameter-shape drops (content-style, objects, arrays with + // non-scalar items, unsupported styles, heterogeneous enums, + // empty types) are classified and warned by the assembler, + // which emits the field and sees the resolved dataType. + if (operation.getResponses() != null) { + for (Map.Entry respEntry + : operation.getResponses().entrySet()) { + ApiResponse response = respEntry.getValue(); + if (response == null || response.getContent() == null) { + continue; + } + for (String mediaType : response.getContent().keySet()) { + if (!isSupportedMediaType(mediaType)) { + warnings.add("operation '" + operationId + + "' response '" + respEntry.getKey() + + "' declares media type '" + mediaType + + "'; the generated responder serializes" + + " responses as JSON"); + } + } + } + } + } + } + for (String scheme : collectUnsupportedSecuritySchemes(openAPI)) { + warnings.add("security scheme '" + scheme + + "' uses a type the runtime cannot extract credentials for" + + " (only apiKey and http are supported); operations" + + " requiring it deny all requests with 401"); + } + return warnings; + } + + @Override + public OperationsMap postProcessOperationsWithModels( + OperationsMap objs, List allModels) { + String modelNamespace = String.valueOf( + additionalProperties.getOrDefault("modelNamespace", "")); + String apiNamespace = String.valueOf( + additionalProperties.getOrDefault("apiNamespace", "")); + return new CppBoostBeastServerTemplateModelAssembler( + sourceOpenApi, modelNamespace, apiNamespace, this::toModelImport, + validateOnDecode).assemble(objs, allModels); + } + + /** + * The server runtime decodes JSON bodies only and never parses a + * form-encoded payload, so flattened form fields (including their + * spaceDelimited/pipeDelimited/deepObject encodings) are dropped by the + * assembler with a warning rather than aborting generation. The shared + * model pipeline's fail-closed reject exists for the client's multipart + * writer, which does not apply here. + */ + @Override + protected boolean rejectsUnsupportedFormEncodingStyles() { + return false; + } + /** + * Generation-time rejection gate for shapes whose generated code could + * not route deterministically: ambiguous path templates and ranged + * status codes. Parameter and body shapes the runtime cannot decode + * DEGRADE instead (dropped with a warning; see + * {@link #collectSurfaceWarnings} and the assembler's dataType rule) so + * real-world corpora still generate compileable code. + */ + List validateServerSupportSurface(OpenAPI openAPI) { + List diagnostics = new ArrayList<>(); + if (openAPI == null || openAPI.getPaths() == null) { + return diagnostics; + } + Map shapeOwners = new LinkedHashMap<>(); + List methodTemplates = new ArrayList<>(); + for (Map.Entry pathEntry : openAPI.getPaths().entrySet()) { + String pathTemplate = pathEntry.getKey(); + String malformed = pathTemplateIssue(pathTemplate); + if (malformed != null) { + diagnostics.add("path template '" + pathTemplate + "' has " + + malformed + "; the router cannot extract its parameters"); + } + String previous = shapeOwners.putIfAbsent( + routeShapeKey(pathTemplate), pathTemplate); + if (previous != null && !previous.equals(pathTemplate)) { + diagnostics.add("path templates '" + previous + "' and '" + pathTemplate + + "' have the same shape; server routing requires distinct shapes"); + } + PathItem item = pathEntry.getValue(); + if (item == null || item.readOperationsMap() == null) { + continue; + } + for (Map.Entry opEntry + : item.readOperationsMap().entrySet()) { + Operation operation = opEntry.getValue(); + if (operation == null) { + continue; + } + methodTemplates.add(new String[]{opEntry.getKey().name(), pathTemplate}); + String operationId = operation.getOperationId() != null + ? operation.getOperationId() + : opEntry.getKey() + " " + pathTemplate; + if (operation.getResponses() != null) { + for (Map.Entry respEntry + : operation.getResponses().entrySet()) { + ApiResponse response = respEntry.getValue(); + if (response == null) { + continue; + } + if (respEntry.getKey() != null + && respEntry.getKey().matches("[1-5]XX")) { + diagnostics.add("operation '" + operationId + + "' declares ranged status code '" + + respEntry.getKey() + + "'; only concrete codes are supported"); + } + } + } + } + } + // Overlapping-shape probe: two templates with DIFFERENT shapes can + // still match the same concrete path with equal literal-token ranking + // (e.g. '/a/{x}b' and '/a/a{y}' both match '/a/ab'), which makes the + // router fall back to registration order. Synthesize a witness per + // pair and verify it with a Java mirror of Router::matches; only a + // verified collision is rejected, so the probe can under-report but + // never reject a deterministically routable pair. + for (int i = 0; i < methodTemplates.size(); i++) { + for (int j = i + 1; j < methodTemplates.size(); j++) { + String[] first = methodTemplates.get(i); + String[] second = methodTemplates.get(j); + if (!first[0].equals(second[0])) { + continue; // different methods never race inside match() + } + if (routeShapeKey(first[1]).equals(routeShapeKey(second[1]))) { + continue; // already rejected as the same shape + } + if (routeLiteralTokens(first[1]) != routeLiteralTokens(second[1])) { + continue; // ranking resolves deterministically + } + String witness = overlappingPathWitness(first[1], second[1]); + if (witness != null) { + diagnostics.add("path templates '" + first[1] + "' and '" + + second[1] + "' both match '" + witness + + "' with equal ranking; server routing would" + + " depend on registration order"); + } + } + } + return diagnostics; + } + + /** Security scheme names whose type is not apiKey/http (always-401 stubs). */ + private static Set collectUnsupportedSecuritySchemes(OpenAPI openAPI) { + Set unsupported = new LinkedHashSet<>(); + if (openAPI.getComponents() == null + || openAPI.getComponents().getSecuritySchemes() == null) { + return unsupported; + } + for (Map.Entry entry + : openAPI.getComponents().getSecuritySchemes().entrySet()) { + SecurityScheme scheme = entry.getValue(); + if (scheme == null || scheme.getType() == null) { + continue; + } + SecurityScheme.Type type = scheme.getType(); + if (type != SecurityScheme.Type.APIKEY + && type != SecurityScheme.Type.HTTP) { + unsupported.add(entry.getKey() + " (" + type + ")"); + } + } + return unsupported; + } + + /** Media types the generated runtime can serve as JSON: exact JSON, + * {+json} structured suffixes, and the wildcard. */ + static boolean isSupportedMediaType(String mediaType) { + String normalized = normalizeMediaType(mediaType); + return isJsonMediaType(normalized) || "*/*".equals(normalized); + } + + /** Media types the generated runtime can DECODE a request body from. + * Unlike responses (where everything is serialized as JSON anyway), a + * request body must be parsed, and the wildcard {@code *\/​*} declares no + * JSON-specific representation: admitting it would make the handler + * answer a JSON parse error (400) to every non-JSON representation it + * cannot decode. Request media types are therefore narrowed to JSON + * (with a warning); everything else gets a clean 415. */ + static boolean isSupportedRequestMediaType(String mediaType) { + return isJsonMediaType(normalizeMediaType(mediaType)); + } + + /** True for exact application/json and {+json} structured suffixes. */ + static boolean isJsonMediaType(String normalizedMediaType) { + return "application/json".equals(normalizedMediaType) + || normalizedMediaType.endsWith("+json"); + } + + /** Lowercase the media type and strip {@code ;parameter} suffixes. */ + static String normalizeMediaType(String mediaType) { + String normalized = mediaType == null ? "" : mediaType.trim().toLowerCase(Locale.ROOT); + int semicolon = normalized.indexOf(';'); + if (semicolon >= 0) { + normalized = normalized.substring(0, semicolon).trim(); + } + return normalized; + } + + /** Styles whose wire format the generated codecs reproduce exactly. */ + static boolean isStyleAllowedForLocation(String in, String style) { + switch (in) { + case "header": + return "simple".equals(style); + case "cookie": + return "form".equals(style); + case "query": + return "form".equals(style) || "spaceDelimited".equals(style) + || "pipeDelimited".equals(style) || "deepObject".equals(style); + case "path": + return "simple".equals(style) || "label".equals(style) + || "matrix".equals(style); + default: + return true; + } + } + + /** Whether a C++ dataType is one of the plain scalar parseScalar + * overloads (nullable and optional wrappers are NOT: the codec decodes + * into plain scalars and models carry nulls through their own field + * decoders instead). */ + static boolean isPlainScalarDataType(String dataType) { + return "std::string".equals(dataType) || "bool".equals(dataType) + || "std::int32_t".equals(dataType) || "std::int64_t".equals(dataType) + || "float".equals(dataType) || "double".equals(dataType); + } + + /** Names enums the constraint templates cannot render faithfully: null + * members and mixes of string with numeric/boolean members (integer + + * number mixes stay valid; both render into the long double + * allow-list). Returns a short reason (grammar: "") or + * null for uniform enums. Shared with the assembler. */ + static String heterogeneousEnumIssue(Schema schema) { + if (schema == null || schema.getEnum() == null || schema.getEnum().isEmpty()) { + return null; + } + boolean hasNull = false; + boolean hasString = false; + boolean hasNumber = false; + boolean hasBoolean = false; + for (Object value : schema.getEnum()) { + if (value == null) { + hasNull = true; + } else if (value instanceof String) { + hasString = true; + } else if (value instanceof Number) { + hasNumber = true; + } else if (value instanceof Boolean) { + hasBoolean = true; + } + } + if (hasNull + || (hasString && (hasNumber || hasBoolean)) + || (hasBoolean && (hasString || hasNumber))) { + return "has heterogeneous enum members; the generated allow-list" + + " renders uniform string, numeric, or boolean enums only"; + } + return null; + } + + /** Routing-shape diagnostics for one path template, or null when the + * template is well-formed. Mirrors Router::tokenizeSegment: expressions + * must be balanced, non-nested, and non-empty; everything else (a stray + * '{', a nested '{', an empty '{}') would be treated as literal text by + * the router while the parameter extraction expects an expression, so + * the route could never fill its parameters. */ + static String pathTemplateIssue(String pathTemplate) { + int start = 0; + while (true) { + int open = pathTemplate.indexOf('{', start); + if (open < 0) { + int stray = pathTemplate.indexOf('}', start); + return stray < 0 ? null + : "unbalanced '}' outside an expression"; + } + int close = pathTemplate.indexOf('}', open + 1); + if (close < 0) { + return "unclosed '{'"; + } + int inner = pathTemplate.indexOf('{', open + 1); + if (inner >= 0 && inner < close) { + return "nested '{' inside an expression"; + } + if (close == open + 1) { + return "empty '{}' expression"; + } + if (close + 1 < pathTemplate.length() + && pathTemplate.charAt(close + 1) == '{') { + return "adjacent expressions without literal text between them"; + } + start = close + 1; + } + } + + /** Canonical routing shape: per segment, literal text stays, each + * expression contributes a "{}" marker. Mirrors Router::splitPath + + * Router::tokenizeSegment so templates the router can confuse (two + * token streams that match the same inputs, e.g. '/a/{x}-{y}' vs + * '/a/{z}-{w}' ordering ambiguities and '/x/{a}' vs '/x/{b}') are + * detected at generation time. Whole-segment and embedded expressions + * share one shape space: '/pets/{id}' and '/pets/p{id}' differ (extra + * literal), '/reports/{y}-{m}' and '/reports/{a}-{b}' collide. + * + *

The query string is stripped first, exactly as Router::splitPath + * does at registration: '/responses?beta=true' registers as the route + * '/responses', so it must hash to the SAME shape as '/responses' — + * otherwise the duplicate slips past this gate into the pairwise + * witness probe, which reports it as a ranking ambiguity instead of + * the literal route duplication it is. */ + private static String routeShapeKey(String pathTemplate) { + int query = pathTemplate.indexOf('?'); + if (query >= 0) { + pathTemplate = pathTemplate.substring(0, query); + } + StringBuilder key = new StringBuilder(); + int start = pathTemplate.startsWith("/") ? 1 : 0; + while (start <= pathTemplate.length()) { + int slash = pathTemplate.indexOf('/', start); + String segment = slash < 0 + ? pathTemplate.substring(start) + : pathTemplate.substring(start, slash); + int cursor = 0; + while (cursor < segment.length()) { + int open = segment.indexOf('{', cursor); + if (open < 0) { + key.append(segment, cursor, segment.length()); + break; + } + key.append(segment, cursor, open); + int close = segment.indexOf('}', open); + int inner = segment.indexOf('{', open + 1); + if (close < 0 || (inner >= 0 && inner < close) || close == open + 1) { + // Malformed remainder: literal, exactly as the router does. + key.append(segment, open, segment.length()); + break; + } + key.append("{}"); + cursor = close + 1; + } + key.append('/'); + if (slash < 0) { + break; + } + start = slash + 1; + } + return key.length() == 0 ? "/" : key.toString(); + } + + /** One router token mirror: literal text or a named capture. */ + private static final class RouteToken { + private final String literal; + private final String param; + + private RouteToken(String literal, String param) { + this.literal = literal; + this.param = param; + } + + private boolean isParam() { + return !param.isEmpty(); + } + } + + /** Mirror of Router::splitPath. */ + private static List splitPathSegments(String path) { + String s = path; + int query = s.indexOf('?'); + if (query >= 0) { + s = s.substring(0, query); + } + List segments = new ArrayList<>(); + int start = (!s.isEmpty() && s.charAt(0) == '/') ? 1 : 0; + while (start <= s.length()) { + int slash = s.indexOf('/', start); + if (slash < 0) { + segments.add(s.substring(start)); + break; + } + segments.add(s.substring(start, slash)); + start = slash + 1; + } + return segments; + } + + /** Mirror of Router::tokenizeSegment (well-formed templates only). */ + private static List tokenizeSegment(String segment) { + List tokens = new ArrayList<>(); + int start = 0; + while (start < segment.length()) { + int open = segment.indexOf('{', start); + if (open < 0) { + tokens.add(new RouteToken(segment.substring(start), "")); + break; + } + int close = segment.indexOf('}', open); + int inner = segment.indexOf('{', open + 1); + if (close < 0 || (inner >= 0 && inner < close) || close == open + 1) { + tokens.add(new RouteToken(segment.substring(start), "")); + break; + } + if (open > start) { + tokens.add(new RouteToken(segment.substring(start, open), "")); + } + tokens.add(new RouteToken("", segment.substring(open + 1, close))); + start = close + 1; + } + return tokens; + } + + /** Mirror of Router::matches for one segment, including the non-greedy + * find-anchored capture semantics of the generated runtime. */ + private static boolean segmentMatches(List tokens, String text, + Map captures) { + if (tokens.isEmpty()) { + return text.isEmpty(); + } + int position = 0; + for (int t = 0; t < tokens.size(); t++) { + RouteToken token = tokens.get(t); + if (!token.isParam()) { + if (!text.startsWith(token.literal, position)) { + return false; + } + position += token.literal.length(); + continue; + } + int begin = position; + int end = text.length(); + boolean anchored = false; + if (t + 1 < tokens.size()) { + anchored = true; + int hit = text.indexOf(tokens.get(t + 1).literal, begin); + if (hit < 0) { + return false; + } + end = hit; + } + if (tokens.size() == 1 && begin == end) { + return false; // a whole-segment {param} may not be empty + } + captures.put(token.param, text.substring(begin, end)); + position = anchored ? end : text.length(); + } + return position == text.length(); + } + + /** Mirror of Router::match's per-template shape test for a concrete path. */ + private static boolean matchesPathTemplate(String template, String target, + Map params) { + List targetSegments = splitPathSegments(target); + List templateSegments = splitPathSegments(template); + if (templateSegments.size() != targetSegments.size()) { + return false; + } + for (int i = 0; i < templateSegments.size(); i++) { + if (!segmentMatches(tokenizeSegment(templateSegments.get(i)), + targetSegments.get(i), params)) { + return false; + } + } + return true; + } + + /** Literal-token ranking weight of a template, mirroring Router::add. */ + private static int routeLiteralTokens(String pathTemplate) { + int count = 0; + for (String segment : splitPathSegments(pathTemplate)) { + for (RouteToken token : tokenizeSegment(segment)) { + if (!token.isParam()) { + count++; + } + } + } + return count; + } + + /** A letter absent from both templates, so synthesized captures cannot + * accidentally complete a find-anchored literal early. */ + private static char spareCaptureChar(String first, String second) { + for (char c = 'a'; c <= 'z'; c++) { + if (first.indexOf(c) < 0 && second.indexOf(c) < 0) { + return c; + } + } + return 'z'; + } + + /** Flattens a token list to chars: literal text stays, each capture + * expression becomes one NUL wildcard. */ + private static String flattenSegmentTokens(List tokens) { + StringBuilder flat = new StringBuilder(); + for (RouteToken token : tokens) { + flat.append(token.isParam() ? '\u0000' : token.literal); + } + return flat.toString(); + } + + /** + * Enumerates up to {@code budget} concrete strings that BOTH flattened + * segment patterns can generate, shortest-first, or an empty list when + * the intersection is PROVEN empty. Replaces a step-budgeted + * depth-first merge whose cap could exhaust on branchy-but-intersecting + * pairs and silently under-report a real route collision. + * + *

Each side is a linear token pattern: a literal position only + * advances on its own char, a wildcard position absorbs any letter for + * free or ends without consuming (epsilon). Trie level L holds every + * product-NFA state (i, j) reachable by SOME string of length L + * (epsilon-closed); each state is discovered once per level with a + * parent pointer, so every emitted candidate is a real generated string. + * A step where BOTH sides merely absorb a char must still be taken: + * when a capture occupies a whole path segment the router forbids an + * empty capture, so a witness can need a char neither side consumes to + * make every whole-segment capture non-empty (e.g. '{p3}' vs '{q3}'). + * Such a step leaves the product state unchanged but advances the level, + * and one suffices — the same char stretches every absorbing wildcard — + * so levels are capped at n + m + 1 (n + m chars to align and consume + * both patterns, plus one both-absorb char). Finishing every level is a + * disjointness PROOF, not a timeout. + * Letters are limited to the spare filler plus + * literals present in the templates; none is '/'. + * + *

A level-0 witness may be the empty string (both segments can match + * an empty path segment); suppressing it would prove disjointness for a + * pair that genuinely collides on '//'. Empty candidates are verified by + * the caller's whole-path Router::matches mirror like any other. + * + *

Sound only as a CANDIDATE list — callers verify against the exact + * Router::matches mirror before rejecting. + */ + private static List segmentWitnesses(List a, List b, + char filler, int budget) { + String flatA = flattenSegmentTokens(a); + String flatB = flattenSegmentTokens(b); + int n = flatA.length(); + int m = flatB.length(); + int width = m + 1; + int states = (n + 1) * width; + // The +1 level exists solely to host ONE both-absorb char from the + // root state, and that step is only possible when BOTH patterns can + // absorb a char at index 0 (a wildcard; advanceSide never stays on + // a literal or past the end). Without a root absorb the level is + // unreachable — skip allocating it. Coverage is pinned to the + // pre-root-absorb budget by the guard below, so no pair that was + // searchable before the level existed stops being searchable. + int maxLevel = (n > 0 && m > 0 + && flatA.charAt(0) == '\u0000' && flatB.charAt(0) == '\u0000') + ? n + m + 1 : n + m; + // Coverage is pinned to the PRE-root-absorb budget: a pair that was + // searchable before the root-absorb level existed must stay + // searchable after it. The optional extra level adds one `states` + // row to the allocation; near the cap that is a few percent over + // the base, and the skewed shapes that could inflate it further + // are already refused here. + long baseCells = (long) (n + 1) * (m + 1) * (n + m + 1); + if (baseCells > 4_000_000) { + // Pathological template: bail out with an under-report (never a + // false reject), matching the old budgeted search's behavior. + return new ArrayList<>(); + } + // Letters a witness ever needs: the filler (captures) plus every + // literal char either side may have to align on. + List letters = new ArrayList<>(); + letters.add(filler); + for (int k = 0; k < n; k++) { + char c = flatA.charAt(k); + if (c != '\u0000' && !letters.contains(c)) { + letters.add(c); + } + } + for (int k = 0; k < m; k++) { + char c = flatB.charAt(k); + if (c != '\u0000' && !letters.contains(c)) { + letters.add(c); + } + } + // Flat index = level * states + state. parent[] names the predecessor + // flat index (-1 for the root); parentChar[] is the emitted char, + // '\u0000' marking an in-level epsilon (wildcard end). + int levels = maxLevel + 1; + int[] parent = new int[levels * states]; + char[] parentChar = new char[levels * states]; + java.util.Arrays.fill(parent, Integer.MIN_VALUE); + parent[0] = -1; + List current = new ArrayList<>(); + current.add(0); // state (0, 0) + List results = new ArrayList<>(); + int accept = n * width + m; + for (int level = 0; level <= maxLevel; level++) { + // Epsilon-close the level in place: a wildcard may end without + // consuming, cascading over consecutive wildcards. Within a + // level every epsilon strictly increases (i, j), so this + // terminates. + boolean[] inLevel = new boolean[states]; + for (int state : current) { + inLevel[state] = true; + } + for (int head = 0; head < current.size(); head++) { + int state = current.get(head); + int i = state / width; + int j = state % width; + if (i < n && flatA.charAt(i) == '\u0000' && !inLevel[state + width]) { + inLevel[state + width] = true; + parent[level * states + state + width] = level * states + state; + parentChar[level * states + state + width] = '\u0000'; + current.add(state + width); + } + if (j < m && flatB.charAt(j) == '\u0000' && !inLevel[state + 1]) { + inLevel[state + 1] = true; + parent[level * states + state + 1] = level * states + state; + parentChar[level * states + state + 1] = '\u0000'; + current.add(state + 1); + } + } + if (inLevel[accept]) { + // The level-0 candidate may reconstruct to the empty string + // (both segments match an empty path segment); it is a + // genuine witness candidate and the caller's whole-path + // Router::matches mirror decides it. + results.add(reconstructSegmentWitness( + parent, parentChar, level * states + accept)); + if (results.size() >= budget) { + return results; + } + } + if (level == maxLevel) { + break; + } + // Emit one char: each side absorbs it (wildcard stays), consumes + // it (matching literal), or the path dies. A step that leaves + // BOTH positions unchanged is normally skipped (it only + // lengthens the witness), EXCEPT from the root state at level 0: + // a whole-segment capture cannot be empty (Router::matches + // refuses that), it absorbs only at index 0, and one root char + // stretches every whole-segment capture at once — without it, + // '{p3}' vs '{q3}' would be "proven" disjoint. + List next = new ArrayList<>(); + boolean[] inNext = new boolean[states]; + for (int state : current) { + int i = state / width; + int j = state % width; + for (char c : letters) { + int ni = advanceSide(flatA, i, c); + if (ni < 0) { + continue; + } + int nj = advanceSide(flatB, j, c); + if (nj < 0 || ((ni == i && nj == j) + && !(level == 0 && state == 0))) { + continue; + } + int key = ni * width + nj; + if (inNext[key]) { + continue; + } + inNext[key] = true; + parent[(level + 1) * states + key] = level * states + state; + parentChar[(level + 1) * states + key] = c; + next.add(key); + } + } + current = next; + } + return results; + } + + /** Advances one pattern by char {@code c}: a wildcard absorbs it (stay), + * a matching literal consumes it (step), anything else kills the path. + * Returns the new index or -1. */ + private static int advanceSide(String flat, int i, char c) { + if (i < flat.length()) { + char at = flat.charAt(i); + if (at == '\u0000') { + return i; + } + if (at == c) { + return i + 1; + } + } + return -1; + } + + /** Rebuilds the emitted string by walking parent pointers to the root; + * every step either decreases the level or (within a level) strictly + * increases (i, j), so the walk terminates. */ + private static String reconstructSegmentWitness(int[] parent, char[] parentChar, + int flat) { + StringBuilder reversed = new StringBuilder(); + int cursor = flat; + while (parent[cursor] >= 0) { + if (parentChar[cursor] != '\u0000') { + reversed.append(parentChar[cursor]); + } + cursor = parent[cursor]; + } + return reversed.reverse().toString(); + } + + /** A concrete target path BOTH templates' router shapes match, verified + * against the exact Router::matches mirror, or null when no collision + * could be PROVEN. The merge can only under-report: generation is never + * rejected on an unverified suspicion. */ + private static String overlappingPathWitness(String first, String second) { + List aSegments = splitPathSegments(first); + List bSegments = splitPathSegments(second); + if (aSegments.size() != bSegments.size()) { + return null; + } + List> perSegment = new ArrayList<>(); + char filler = spareCaptureChar(first, second); + for (int i = 0; i < aSegments.size(); i++) { + List options = segmentWitnesses( + tokenizeSegment(aSegments.get(i)), + tokenizeSegment(bSegments.get(i)), filler, 6); + if (options.isEmpty()) { + return null; + } + perSegment.add(options); + } + int[] picks = new int[perSegment.size()]; + // Lexicographic walk over the (bounded) candidate product. + for (int guard = 0; guard < 512; guard++) { + StringBuilder witness = new StringBuilder(first.startsWith("/") ? "/" : ""); + for (int i = 0; i < perSegment.size(); i++) { + if (i > 0) { + witness.append('/'); + } + witness.append(perSegment.get(i).get(picks[i])); + } + String candidate = witness.toString(); + if (matchesPathTemplate(first, candidate, new HashMap<>()) + && matchesPathTemplate(second, candidate, new HashMap<>())) { + return candidate; + } + int slot = picks.length - 1; + while (slot >= 0) { + picks[slot]++; + if (picks[slot] < perSegment.get(slot).size()) { + break; + } + picks[slot] = 0; + slot--; + } + if (slot < 0) { + break; + } + } + return null; + } + + /** True when the `{baseName}` expression is the whole path segment (the + * only place label/matrix styles can appear per the OAS grammar). */ + static boolean isWholeSegmentPathParameter(String pathTemplate, String baseName) { + String token = "{" + baseName + "}"; + int start = 0; + while (start <= pathTemplate.length()) { + int slash = pathTemplate.indexOf('/', start); + String segment = slash < 0 + ? pathTemplate.substring(start) + : pathTemplate.substring(start, slash); + if (segment.equals(token)) { + return true; + } + if (slash < 0) { + return false; + } + start = slash + 1; + } + return false; + } +} diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/CppBoostBeastServerTemplateModelAssembler.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/CppBoostBeastServerTemplateModelAssembler.java new file mode 100644 index 000000000000..8230272d7db8 --- /dev/null +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/CppBoostBeastServerTemplateModelAssembler.java @@ -0,0 +1,1303 @@ +/* + * 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 io.swagger.v3.oas.models.Operation; +import io.swagger.v3.oas.models.PathItem; +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.openapitools.codegen.CodegenOperation; +import org.openapitools.codegen.CodegenParameter; +import org.openapitools.codegen.CodegenResponse; +import org.openapitools.codegen.model.ModelMap; +import org.openapitools.codegen.model.OperationsMap; +import org.openapitools.codegen.utils.ModelUtils; + +import java.util.ArrayList; +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.function.Function; + +/** + * Template-model assembly for the Boost.Beast server generator: converts each + * {@link CodegenOperation} into the vendor-extension facts consumed by the + * server api-header/api-source templates (route table, typed request structs, + * responder methods, security groups, parameter validation constraints). + */ +final class CppBoostBeastServerTemplateModelAssembler { + + private final org.slf4j.Logger LOGGER = + org.slf4j.LoggerFactory.getLogger( + CppBoostBeastServerTemplateModelAssembler.class); + + /** Types the generated runtime declares in the API namespace. A model + * with one of these names would shadow the runtime type wherever the + * generated code references it unqualified, so those references get an + * explicit api-namespace prefix and the model references get a + * model-namespace prefix. */ + static final Set RUNTIME_TYPE_NAMES = Set.of( + "HttpServer", "Problem", "ProblemError", "ResponderCore", "Responder", + "RequestContext", "Router", "RouteMatch", "Handler", "SecurityGroups", + "SchemeRequirement", "Authorizer", "AuthCredentials", "ServerOptions", + "ParamCodecs"); + private final OpenAPI sourceOpenApi; + private final String modelNamespace; + private final String apiNamespace; + private final Function modelImportFunction; + /** Whether generated models decode through the schema-IR validator (the + * `validateOnDecode` option). Body schema validation on the request path + * is only generated when the registry exists. */ + private final boolean schemaRegistryAvailable; + + CppBoostBeastServerTemplateModelAssembler( + OpenAPI sourceOpenApi, String modelNamespace, String apiNamespace, + Function modelImportFunction, + boolean schemaRegistryAvailable) { + this.sourceOpenApi = sourceOpenApi; + this.modelNamespace = modelNamespace; + this.apiNamespace = apiNamespace; + this.modelImportFunction = modelImportFunction; + this.schemaRegistryAvailable = schemaRegistryAvailable; + } + + OperationsMap assemble(OperationsMap objs, List allModels) { + if (objs == null || objs.getOperations() == null) { + return objs; + } + Set modelClassNames = new HashSet<>(); + Map modelDataTypes = new HashMap<>(); + Map modelSchemaIds = new HashMap<>(); + for (ModelMap modelMap : allModels) { + if (modelMap != null && modelMap.getModel() != null + && modelMap.getModel().classname != null) { + modelClassNames.add(modelMap.getModel().classname); + modelDataTypes.put(modelMap.getModel().classname, + modelMap.getModel().dataType); + Object schemaId = modelMap.getModel().vendorExtensions == null ? null + : modelMap.getModel().vendorExtensions.get("x-cpp-component-schema-id"); + if (schemaId != null && !schemaId.toString().isEmpty()) { + modelSchemaIds.put(modelMap.getModel().classname, schemaId.toString()); + } + } + } + // The API classes live in the same namespace as the runtime types + // (Router/Problem/HttpServer/...). The api header emits + // `using namespace ;`, so a generated MODEL named like a + // runtime type makes every unqualified mention of that name + // ambiguous. Fix both sides explicitly: templates prefix runtime + // references with `{{apiNsQualified}}` (the api namespace), and model + // references (field/body/response types) are rewritten to their + // model-namespace spelling here. With no collision the prefix is + // empty and generated code stays readable. + Set collidingModels = new HashSet<>(modelClassNames); + collidingModels.retainAll(RUNTIME_TYPE_NAMES); + // apiNamespace is constructor-injected: DefaultGenerator merges + // additionalProperties into the operations map only AFTER + // postProcessOperationsWithModels runs, so reading it from objs here + // would always yield null and make this guard inert. + objs.put("apiNsQualified", + !collidingModels.isEmpty() && !apiNamespace.isEmpty() + ? apiNamespace + "::" : ""); + Set emittedModelImports = new HashSet<>(); + for (Map existing : objs.getImports()) { + if (existing != null && existing.get("classname") != null) { + emittedModelImports.add(existing.get("classname")); + } + } + // Names of the nested contract types declared by THIS API class. A + // generated model with one of these names is shadowed inside the + // class scope, so every token spelling of it in a body/response type + // must be model-namespace qualified, not just whole-type matches. + Set contractNames = new HashSet<>(); + for (CodegenOperation op : objs.getOperations().getOperation()) { + if (op == null) { + continue; + } + String pascal = pascalCase( + op.operationId != null ? op.operationId : op.operationIdLowerCase); + contractNames.add(pascal + "Request"); + contractNames.add(pascal + "Responder"); + } + for (CodegenOperation op : objs.getOperations().getOperation()) { + if (op == null) { + continue; + } + Operation raw = CppBoostBeastOperationFacts.operationFor(sourceOpenApi, op); + + // Contract types are declared INSIDE the API class (see + // api-header.mustache), so the same operation shared across two + // tags yields two independently-scoped definitions instead of + // duplicate namespace-scope types. + String pascal = pascalCase( + op.operationId != null ? op.operationId : op.operationIdLowerCase); + op.vendorExtensions.put("x-server-operation-pascal", pascal); + // C++-escaped renderings for every place the raw spec text lands + // inside a generated string literal ({{...}} would HTML-escape the + // C++ escapes; {{{...}}} inserts them exactly once). + op.vendorExtensions.put("x-server-operation-id-literal", + CppBoostBeastModelCodegen.escapeCppStringContent( + op.operationId == null ? "" : op.operationId)); + op.vendorExtensions.put("x-server-path-literal", + CppBoostBeastModelCodegen.escapeCppStringContent( + op.path == null ? "" : op.path)); + + op.vendorExtensions.put("x-server-params", + serverParams(op, raw)); + + Map body = requestBodyFacts( + op, raw, pascal, modelClassNames, modelDataTypes, + collidingModels, modelSchemaIds, contractNames); + op.vendorExtensions.put("x-server-has-request-body", body.get("hasBody")); + op.vendorExtensions.put("x-server-request-model", body.get("model")); + op.vendorExtensions.put("x-server-request-field-type", + body.get("fieldType")); + op.vendorExtensions.put("x-server-request-model-collides", + body.get("modelCollides")); + op.vendorExtensions.put("x-server-request-media-types", + body.get("mediaTypes")); + op.vendorExtensions.put("x-server-request-body-required", + body.get("required")); + // Only present when non-empty: jmustache renders {{#section}} + // for an empty STRING value, and the validation branch must not + // be emitted against a missing/unknown registry entry (that + // covers compileWithValidation=false and non-registered bodies, + // which stay on the decode-shape-only fromJsonBody path). + Object bodySchemaId = body.get("schemaId"); + if (bodySchemaId != null && !bodySchemaId.toString().isEmpty()) { + op.vendorExtensions.put("x-server-request-body-schema-id", + bodySchemaId.toString()); + } + // Mixed bodies (multipart + JSON): the JSON member's model is not in + // DefaultCodegen's op.imports (it flattened the form payload instead), + // so the recovered typed field would reference an un-included header. + // Append its include to the frozen operations import list. + String bodyModel = (String) body.get("model"); + if (Boolean.TRUE.equals(body.get("hasBody")) && bodyModel != null + && modelClassNames.contains(bodyModel) + && emittedModelImports.add(bodyModel)) { + Map im = new LinkedHashMap<>(); + im.put("import", modelImportFunction.apply(bodyModel)); + im.put("classname", bodyModel); + objs.getImports().add(im); + } + + op.vendorExtensions.put("x-server-responses", + serverResponses(op, raw, pascal, collidingModels, modelClassNames, + contractNames)); + + op.vendorExtensions.put("x-server-security-groups", + CppBoostBeastOperationFacts.effectiveSecurityGroups(sourceOpenApi, op)); + } + boolean anyModelUse = false; + for (CodegenOperation op : objs.getOperations().getOperation()) { + if (op == null) { + continue; + } + if (op.imports != null) { + for (String imported : op.imports) { + if (imported != null && modelClassNames.contains(imported)) { + anyModelUse = true; + break; + } + } + } + // A recovered mixed-body model (JSON member of a multipart body) is + // not in op.imports; its unqualified field still needs the model + // using-directive unless it was namespace-qualified for a collision. + if (Boolean.TRUE.equals(op.vendorExtensions.get("x-server-has-request-body")) + && !Boolean.TRUE.equals( + op.vendorExtensions.get("x-server-request-model-collides"))) { + String model = (String) op.vendorExtensions.get("x-server-request-model"); + if (model != null && modelClassNames.contains(model)) { + anyModelUse = true; + } + } + if (anyModelUse) { + break; + } + } + objs.put("x-server-has-model-use", anyModelUse); + // The OAS 3.1 schema registry (Oas31SchemaRegistry.h et al.) is + // generated only when validateOnDecode is on; the body-validation + // wiring in the API templates gates on this. + objs.put("x-server-schema-validation", schemaRegistryAvailable); + return objs; + } + + // ------------------------------------------------------------------ + // Parameters + // ------------------------------------------------------------------ + + private List> serverParams(CodegenOperation op, Operation raw) { + List> params = new ArrayList<>(); + for (CodegenParameter param : op.allParams) { + if (param == null || param.isBodyParam) { + continue; + } + String in = param.isPathParam ? "path" + : param.isQueryParam ? "query" + : param.isHeaderParam ? "header" + : param.isCookieParam ? "cookie" : "body"; + Object style = param.vendorExtensions.get("x-codegen-param-style"); + String styleText = style == null ? "" : style.toString(); + if (styleText.isEmpty()) { + styleText = "query".equals(in) || "cookie".equals(in) + ? "form" : "simple"; + } + String dataType = param.dataType == null ? "" : param.dataType; + if (dataType.startsWith("std::shared_ptr<") && dataType.endsWith(">")) { + dataType = dataType.substring( + "std::shared_ptr<".length(), dataType.length() - 1).trim(); + } + boolean isContainer = Boolean.TRUE.equals(param.isContainer) + || Boolean.TRUE.equals(param.isArray); + + Parameter rawParam = findRawParameter(op, raw, param.baseName, in); + Schema rawSchema = rawParam == null || rawParam.getSchema() == null + ? null + : ModelUtils.getReferencedSchema(sourceOpenApi, rawParam.getSchema()); + String issue = parameterDecodeIssue( + param, in, styleText, dataType, isContainer, rawSchema); + if (issue == null && "path".equals(in) + && ("label".equals(styleText) || "matrix".equals(styleText)) + && !CppBoostBeastServerCodegen.isWholeSegmentPathParameter( + op.path, param.baseName)) { + // Label/matrix serialization prefixes the WHOLE segment with + // '.' or ';name='; when the template also has literal text in + // that segment, the prefix cannot be both at segment start and + // after the literal. The capture is the expression text only, + // indistinguishable from a simple-style value: drop it. + issue = "uses style=" + styleText + " inside a segment that also" + + " has literal text; the segment prefix cannot decode"; + } + if (issue != null) { + LOGGER.warn("cpp-boost-beast-server: operation '{}' parameter" + + " '{}' (in {}) {}; it is dropped from the" + + " generated handler and will not reach the" + + " service code", + op.operationId, param.baseName, in, issue); + continue; + } + + Map facts = new LinkedHashMap<>(); + facts.put("cppName", param.paramName != null ? param.paramName : param.baseName); + facts.put("baseName", param.baseName); + // C++-escaped baseName for every generated string literal that + // embeds the wire name ({{...}} HTML-escapes and corrupts the C++ + // escapes; the templates use {{{baseNameLiteral}}} for literals). + facts.put("baseNameLiteral", + CppBoostBeastModelCodegen.escapeCppStringContent( + param.baseName == null ? "" : param.baseName)); + facts.put("in", in); + facts.put("isPath", "path".equals(in)); + facts.put("isQuery", "query".equals(in)); + facts.put("isHeader", "header".equals(in)); + facts.put("isCookie", "cookie".equals(in)); + Object explode = param.vendorExtensions.get("x-codegen-param-explode"); + boolean explodeFlag = Boolean.TRUE.equals(explode); + if (explode == null) { + explodeFlag = "form".equals(styleText); + } + facts.put("style", styleText); + facts.put("styleSimple", "simple".equals(styleText)); + facts.put("styleLabel", "label".equals(styleText)); + facts.put("styleMatrix", "matrix".equals(styleText)); + facts.put("styleForm", "form".equals(styleText)); + facts.put("styleSpaceDelimited", "spaceDelimited".equals(styleText)); + facts.put("stylePipeDelimited", "pipeDelimited".equals(styleText)); + facts.put("styleDeepObject", "deepObject".equals(styleText)); + facts.put("explode", explodeFlag); + facts.put("required", param.required); + facts.put("isContainer", isContainer); + facts.put("dataType", dataType); + facts.put("innerType", innerTemplateArg(dataType)); + facts.put("stringKind", "std::string".equals(dataType)); + facts.put("integerKind", "std::int32_t".equals(dataType) + || "std::int64_t".equals(dataType)); + facts.put("numberKind", "float".equals(dataType) + || "double".equals(dataType)); + facts.put("boolKind", "bool".equals(dataType)); + facts.put("mapKind", dataType.startsWith("std::map<")); + facts.put("vectorKind", dataType.startsWith("std::vector<")); + // A plain-scalar default renders as a member initializer so an + // absent optional parameter reaches the service as the declared + // default, not the zero value. Containers/complex defaults stay + // brace-initialized (the codecs write them only when present). + String defaultValue = param.defaultValue; + boolean plainScalarDefault = !isContainer + && (CppBoostBeastServerCodegen.isPlainScalarDataType(dataType) + || "std::string".equals(dataType)) + && defaultValue != null && !defaultValue.isEmpty() + && !defaultValue.startsWith("std::"); + facts.put("hasDefaultInit", plainScalarDefault); + facts.put("defaultInit", plainScalarDefault ? defaultValue : ""); + Schema itemSchema = isContainer && rawSchema != null + ? ModelUtils.getReferencedSchema(sourceOpenApi, rawSchema.getItems()) + : null; + applySchemaConstraints(facts, rawSchema, itemSchema, dataType); + // The path-scalar branch declares `present` only when the shared + // constraint ladder actually reads it (a POD bool with no uses + // would trip -Wunused-variable under the generated -Werror). + // enumAlwaysInvalid counts: an integer parameter whose enum + // members were ALL unreachable (strings, fractional, or out of + // range) renders an empty allow-list, so hasEnum is false while + // the fail-closed check must still run. + facts.put("hasScalarConstraints", + Boolean.TRUE.equals(facts.get("hasEnum")) + || Boolean.TRUE.equals(facts.get("enumAlwaysInvalid")) + || Boolean.TRUE.equals(facts.get("hasPattern")) + || Boolean.TRUE.equals(facts.get("hasMinLength")) + || Boolean.TRUE.equals(facts.get("hasMaxLength")) + || Boolean.TRUE.equals(facts.get("hasMinimum")) + || Boolean.TRUE.equals(facts.get("hasMaximum")) + || Boolean.TRUE.equals(facts.get("hasMultipleOf")) + || Boolean.TRUE.equals(facts.get("minimumAlwaysInvalid")) + || Boolean.TRUE.equals(facts.get("maximumAlwaysInvalid"))); + // The container-constraints partial reads `present` for the + // size bounds; the partial is included only when it exists. + facts.put("hasContainerConstraints", + Boolean.TRUE.equals(facts.get("hasMinItems")) + || Boolean.TRUE.equals(facts.get("hasMaxItems")) + || Boolean.TRUE.equals(facts.get("uniqueItems")) + || Boolean.TRUE.equals(facts.get("itemEnumAlwaysInvalid")) + || Boolean.TRUE.equals(facts.get("itemMinimumAlwaysInvalid")) + || Boolean.TRUE.equals(facts.get("itemMaximumAlwaysInvalid")) + || Boolean.TRUE.equals(facts.get("itemHasEnum")) + || Boolean.TRUE.equals(facts.get("itemHasPattern")) + || Boolean.TRUE.equals(facts.get("itemHasMinLength")) + || Boolean.TRUE.equals(facts.get("itemHasMaxLength")) + || Boolean.TRUE.equals(facts.get("itemHasMinimum")) + || Boolean.TRUE.equals(facts.get("itemHasMaximum")) + || Boolean.TRUE.equals(facts.get("itemHasMultipleOf"))); + + params.add(facts); + } + return params; + } + + /** + * Single owner of parameter degradation: returns a short reason (grammar: + * "") why the codecs cannot faithfully decode this parameter, + * or null when they can. Rules mirror the generated decode paths exactly: + * content-style and flattened form fields never decode; scalars need a + * plain-scalar dataType (parseScalar has six overloads and no optional or + * nullable wrappers); containers need vector-of-plain-scalar, or — with + * style=deepObject — map; cookie containers have no codec; + * styles outside the per-location allow-list have no serializer; mixed + * enums have no renderable allow-list. + */ + private static String parameterDecodeIssue( + CodegenParameter param, String in, String styleText, + String dataType, boolean isContainer, Schema schema) { + if (param.getContent() != null) { + return "uses content-style serialization, which the JSON runtime" + + " cannot decode from a raw string"; + } + if ("body".equals(in)) { + return "is a form-field parameter; the runtime decodes JSON" + + " bodies, not form-encoded fields"; + } + if (dataType.isEmpty()) { + return "does not map to a generated C++ type"; + } + String enumIssue = CppBoostBeastServerCodegen.heterogeneousEnumIssue(schema); + if (enumIssue != null) { + return enumIssue; + } + if (!CppBoostBeastServerCodegen.isStyleAllowedForLocation(in, styleText)) { + return "uses style '" + styleText + "' for in='" + in + + "', which the runtime does not serialize"; + } + if (isContainer) { + if ("cookie".equals(in)) { + return "is an array cookie parameter; the cookie codec decodes" + + " scalars only"; + } + if (dataType.startsWith("std::map<")) { + if (!"deepObject".equals(styleText) + || !"std::string".equals(innerTemplateArg(dataType))) { + return "is a map-typed parameter; only query deepObject" + + " string maps are decoded"; + } + } else if (dataType.startsWith("std::vector<")) { + if (!CppBoostBeastServerCodegen.isPlainScalarDataType( + innerTemplateArg(dataType))) { + return "is an array with non-scalar items; the query codec" + + " splits plain scalar values only"; + } + } else { + return "is a container the codecs cannot build from dataType" + + " '" + dataType + "'"; + } + } else if (!CppBoostBeastServerCodegen.isPlainScalarDataType(dataType)) { + return "has dataType '" + dataType + "', which no parseScalar" + + " overload accepts"; + } + return null; + } + + private Parameter findRawParameter( + CodegenOperation op, Operation raw, String baseName, String in) { + if (raw != null) { + Parameter fromOperation = + findInParameterList(raw.getParameters(), baseName, in); + if (fromOperation != null) { + return fromOperation; + } + } + // Operation-level lookup misses parameters declared on the PATH ITEM + // (they reach op.allParams through DefaultCodegen's merge, but the raw + // Operation never lists them). Consult the path item so constraints on + // shared parameters are not silently dropped. + if (sourceOpenApi != null && sourceOpenApi.getPaths() != null + && op.path != null) { + PathItem pathItem = sourceOpenApi.getPaths().get(op.path); + if (pathItem != null) { + return findInParameterList(pathItem.getParameters(), baseName, in); + } + } + return null; + } + + private Parameter findInParameterList( + List candidates, String baseName, String in) { + if (candidates == null) { + return null; + } + for (Parameter candidate : candidates) { + if (candidate == null) { + continue; + } + // $ref parameters carry null name/in in the raw document; + // resolve to the target before comparing. + Parameter resolved = + ModelUtils.getReferencedParameter(sourceOpenApi, candidate); + Parameter effective = resolved != null ? resolved : candidate; + if (baseName.equals(effective.getName()) + && (effective.getIn() == null || effective.getIn().equals(in))) { + return effective; + } + } + return null; + } + + /** Applies scalar validation facts for a parameter schema (prefix ""), + * its item schema (prefix "item"), and the collection-level bounds. + * Bounds are resolved through ModelUtils so OAS 3.0 boolean + * exclusiveMinimum/Maximum and OAS 3.1 numeric forms agree. For integer + * kinds the comparison runs in the parameter's exact integer type: the + * bound is folded to the nearest violated integer (exclusive) or the + * ceiling/floor (fractional inclusive), so no value above 2^53 can be + * smuggled through a lossy long double conversion. */ + private void applySchemaConstraints( + Map facts, + io.swagger.v3.oas.models.media.Schema schema, + io.swagger.v3.oas.models.media.Schema itemSchema, + String dataType) { + applyScalarConstraints(facts, schema, dataType, ""); + String innerType = innerTemplateArg(dataType); + if (itemSchema != null && !innerType.isEmpty()) { + applyScalarConstraints(facts, itemSchema, innerType, "item"); + } + String minItems = schema != null && schema.getMinItems() != null + ? schema.getMinItems().toString() : ""; + facts.put("minItems", minItems); + facts.put("hasMinItems", !minItems.isEmpty()); + String maxItems = schema != null && schema.getMaxItems() != null + ? schema.getMaxItems().toString() : ""; + facts.put("maxItems", maxItems); + facts.put("hasMaxItems", !maxItems.isEmpty()); + facts.put("uniqueItems", schema != null + && Boolean.TRUE.equals(schema.getUniqueItems())); + } + + private void applyScalarConstraints( + Map facts, + io.swagger.v3.oas.models.media.Schema schema, + String dataType, + String prefix) { + boolean integerKind = "std::int32_t".equals(dataType) + || "std::int64_t".equals(dataType); + List enumValues = new ArrayList<>(); + String enumKind = ""; + boolean stringKind = "std::string".equals(dataType); + boolean boolKind = "bool".equals(dataType); + boolean numberKind = "float".equals(dataType) || "double".equals(dataType); + // Render an enum member only when the parameter's C++ codec can + // actually produce a JSON-equal value for it. JSON Schema `enum` + // compares by JSON equality, so a string member can never match an + // integer-typed parameter (parseScalar yields an int), a numeric + // member never matches a string parameter (the wire text is a std:: + // string, and `1` is ill-formed in a vector), etc. An + // integer codec additionally drops fractional / out-of-range numeric + // members. Skipping keeps the allow-list exact and compilable; if + // EVERY declared member is unreachable, enumAlwaysInvalid fails the + // check closed rather than silently permitting everything. + if (schema != null && schema.getEnum() != null && !schema.getEnum().isEmpty()) { + for (Object value : schema.getEnum()) { + if (value instanceof Boolean) { + if (!boolKind) { + continue; // bool member: only a bool codec matches it + } + enumValues.add(value.toString()); + if (enumKind.isEmpty()) { + enumKind = "bool"; + } + } else if (value instanceof Integer || value instanceof Long + || value instanceof Short || value instanceof Byte + || value instanceof Double || value instanceof Float + || value instanceof java.math.BigDecimal) { + java.math.BigDecimal decimal = new java.math.BigDecimal(value.toString()); + if (integerKind) { + // Only an integral member inside the long long range + // can arrive through parseScalar; others are + // unreachable and drop out. + java.math.BigDecimal integral = + decimal.setScale(0, java.math.RoundingMode.DOWN); + if (integral.compareTo(decimal) == 0 + && integral.compareTo(INT64_MIN) >= 0 + && integral.compareTo(INT64_MAX) <= 0) { + enumValues.add(longLongLiteral(integral.toPlainString())); + if (enumKind.isEmpty()) { + enumKind = "integer"; + } + } + continue; + } + if (!numberKind) { + continue; // numeric member matches only a number codec + } + enumValues.add(value.toString()); + if (enumKind.isEmpty() || "bool".equals(enumKind)) { + enumKind = "number"; + } + } else { + // String member (or null, which heterogeneousEnumIssue + // already rejected): reachable only for a string codec. + if (!stringKind) { + continue; + } + enumValues.add("\"" + + CppBoostBeastModelCodegen.escapeCppStringContent( + value == null ? "" : value.toString()) + + "\""); + if (enumKind.isEmpty()) { + enumKind = "string"; + } + } + } + } + + emit(facts, prefix, "stringKind", stringKind); + emit(facts, prefix, "boolKind", boolKind); + emit(facts, prefix, "integerKind", integerKind); + emit(facts, prefix, "numberKind", numberKind); + emit(facts, prefix, "enumValues", enumValues); + emit(facts, prefix, "enumKind", enumKind); + emit(facts, prefix, "hasEnum", !enumValues.isEmpty()); + // A parameter whose enum members were ALL unreachable for its C++ + // codec (strings against an integer, numbers against a bool, ...) can + // never decode to a permitted value. Fail closed instead of silently + // skipping the enum check (hasEnum false would let everything + // through). + emit(facts, prefix, "enumAlwaysInvalid", + schema != null && schema.getEnum() != null + && !schema.getEnum().isEmpty() && enumValues.isEmpty()); + String pattern = schema != null && schema.getPattern() != null + ? CppBoostBeastModelCodegen.escapeCppStringContent(schema.getPattern()) : ""; + emit(facts, prefix, "pattern", pattern); + emit(facts, prefix, "hasPattern", !pattern.isEmpty()); + String minLength = schema != null && schema.getMinLength() != null + ? schema.getMinLength().toString() : ""; + emit(facts, prefix, "minLength", minLength); + emit(facts, prefix, "hasMinLength", !minLength.isEmpty()); + String maxLength = schema != null && schema.getMaxLength() != null + ? schema.getMaxLength().toString() : ""; + emit(facts, prefix, "maxLength", maxLength); + emit(facts, prefix, "hasMaxLength", !maxLength.isEmpty()); + java.math.BigDecimal multipleOf = schema == null ? null : schema.getMultipleOf(); + emit(facts, prefix, "multipleOf", multipleOf == null ? "" : multipleOf.toString()); + emit(facts, prefix, "hasMultipleOf", + (integerKind || numberKind) && multipleOf != null); + + ModelUtils.ResolvedMinBound min = schema == null ? null + : ModelUtils.resolveMinimumBound(sourceOpenApi, schema); + ModelUtils.ResolvedMaxBound max = schema == null ? null + : ModelUtils.resolveMaximumBound(sourceOpenApi, schema); + if (integerKind) { + // Fold to an exclusive-integer threshold: reject x < t. The fold + // clamps at the PARAMETER's own range (parseScalar already rejects + // values outside it), so the generated comparison can never be + // provably constant (which -Wtype-limits would flag under -Werror). + java.math.BigDecimal typeMin = "std::int64_t".equals(dataType) + ? INT64_MIN : INT32_MIN; + java.math.BigDecimal typeMax = "std::int64_t".equals(dataType) + ? INT64_MAX : INT32_MAX; + java.math.BigDecimal minThreshold = null; + boolean minAlways = false; + if (min != null) { + minThreshold = min.exclusive + ? min.minBound.add(java.math.BigDecimal.ONE) + .setScale(0, java.math.RoundingMode.FLOOR) + : min.minBound.setScale(0, java.math.RoundingMode.CEILING); + if (minThreshold.compareTo(typeMax) > 0) { + minAlways = true; // no representable value satisfies it + minThreshold = null; + } else if (minThreshold.compareTo(typeMin) <= 0) { + minThreshold = null; // every value satisfies it + } + } + java.math.BigDecimal maxThreshold = null; // reject x > t + boolean maxAlways = false; + if (max != null) { + maxThreshold = max.exclusive + ? max.maxBound.subtract(java.math.BigDecimal.ONE) + .setScale(0, java.math.RoundingMode.CEILING) + : max.maxBound.setScale(0, java.math.RoundingMode.FLOOR); + if (maxThreshold.compareTo(typeMin) < 0) { + maxAlways = true; + maxThreshold = null; + } else if (maxThreshold.compareTo(typeMax) >= 0) { + maxThreshold = null; + } + } + emit(facts, prefix, "minimum", + minThreshold == null ? "" : longLongLiteral(minThreshold.toPlainString())); + emit(facts, prefix, "hasMinimum", minThreshold != null); + emit(facts, prefix, "maximum", + maxThreshold == null ? "" : longLongLiteral(maxThreshold.toPlainString())); + emit(facts, prefix, "hasMaximum", maxThreshold != null); + emit(facts, prefix, "minimumAlwaysInvalid", minAlways); + emit(facts, prefix, "maximumAlwaysInvalid", maxAlways); + } else { + emit(facts, prefix, "minimum", + min == null ? "" : toLongDoubleLiteral(min.minBound)); + emit(facts, prefix, "hasMinimum", min != null); + emit(facts, prefix, "minimumExclusive", min != null && min.exclusive); + emit(facts, prefix, "maximum", + max == null ? "" : toLongDoubleLiteral(max.maxBound)); + emit(facts, prefix, "hasMaximum", max != null); + emit(facts, prefix, "maximumExclusive", max != null && max.exclusive); + emit(facts, prefix, "minimumAlwaysInvalid", false); + emit(facts, prefix, "maximumAlwaysInvalid", false); + } + } + + /** Stores a constraint fact under its plain key (empty prefix) or a + * prefixed, camel-cased key (e.g. "item" -> itemMinimum). */ + private static void emit( + Map facts, String prefix, String name, Object value) { + if (prefix.isEmpty()) { + facts.put(name, value); + } else { + facts.put(prefix + Character.toUpperCase(name.charAt(0)) + name.substring(1), value); + } + } + + + /** Renders an integer (plain digits) as a long long literal that is also + * valid for INT64_MIN and negative bounds. */ + private static String longLongLiteral(String digits) { + if ("-9223372036854775808".equals(digits)) { + return "(-9223372036854775807LL - 1)"; + } + return "(" + digits + "LL)"; + } + + private static final java.math.BigDecimal INT64_MAX = + new java.math.BigDecimal(Long.MAX_VALUE); + private static final java.math.BigDecimal INT64_MIN = + new java.math.BigDecimal(Long.MIN_VALUE); + private static final java.math.BigDecimal INT32_MAX = + new java.math.BigDecimal(Integer.MAX_VALUE); + private static final java.math.BigDecimal INT32_MIN = + new java.math.BigDecimal(Integer.MIN_VALUE); + + // ------------------------------------------------------------------ + // Request body + // ------------------------------------------------------------------ + + private Map requestBodyFacts( + CodegenOperation op, Operation raw, String pascal, + Set modelClassNames, Map modelDataTypes, + Set collidingModels, Map modelSchemaIds, + Set contractNames) { + Map facts = new LinkedHashMap<>(); + List mediaTypes = new ArrayList<>(); + RequestBody body = raw != null + ? ModelUtils.getReferencedRequestBody(sourceOpenApi, raw.getRequestBody()) + : null; + String jsonModelRef = null; + boolean firstContentAccepted = true; + boolean firstContent = true; + if (body != null && body.getContent() != null) { + for (String mediaType : body.getContent().keySet()) { + // Degrade policy: the runtime parses request bodies as JSON + // only. Non-JSON declarations (XML, form, multipart) and the + // wildcard (which declares no JSON-specific representation) + // are dropped here and reported as warnings at preprocess + // time; an operation left with no JSON media type loses its + // typed body entirely rather than promising bytes it cannot + // parse. + boolean accepted = + CppBoostBeastServerCodegen.isSupportedRequestMediaType(mediaType); + if (firstContent) { + firstContentAccepted = accepted; + firstContent = false; + } + if (!accepted) { + continue; + } + Schema declared = body.getContent().get(mediaType) == null + ? null : body.getContent().get(mediaType).getSchema(); + String ref = declared == null ? "" : declared.get$ref(); + if (jsonModelRef == null) { + jsonModelRef = ref; + } else if (!equivalentBodySchema(ref, jsonModelRef)) { + // One typed body per operation: a second JSON media type + // declaring a DIFFERENT schema would be decoded into the + // first one's type, silently misreading one valid + // representation. Accept only the selected representation + // and let the rest answer 415. + LOGGER.warn("cpp-boost-beast-server: operation '{}' declares" + + " media type '{}' with a schema that differs" + + " from the selected request representation;" + + " the generated handler accepts only '{}'", + op.operationId, mediaType, mediaTypes.isEmpty() + ? "the first JSON media type" : mediaTypes.get(0)); + continue; + } + String normalized = CppBoostBeastServerCodegen.normalizeMediaType(mediaType); + if (!normalized.isEmpty() && !mediaTypes.contains(normalized)) { + mediaTypes.add(normalized); + } + } + } + String rendered = ""; + for (String mediaType : mediaTypes) { + if (!rendered.isEmpty()) { + rendered += ", "; + } + rendered += "\"" + CppBoostBeastModelCodegen.escapeCppStringContent(mediaType) + + "\""; + } + + String dataType = op.bodyParam != null && op.bodyParam.dataType != null + ? op.bodyParam.dataType : ""; + if (dataType.startsWith("std::shared_ptr<") && dataType.endsWith(">")) { + dataType = dataType.substring( + "std::shared_ptr<".length(), dataType.length() - 1).trim(); + } + if (!firstContentAccepted && jsonModelRef != null) { + // DefaultCodegen derived the body parameter from the FIRST content + // entry, which this runtime cannot parse (XML, form, multipart, or + // wildcard). When a JSON member exists, type the handler from THAT + // representation instead of the dropped one — otherwise the JSON + // wire bytes would be decoded into the other schema's model, the + // silent misread equivalentBodySchema guards against inside JSON. + // Anything not naming a generated model degrades to no body. + String simple = ModelUtils.getSimpleRef(jsonModelRef); + dataType = simple != null && modelClassNames.contains(simple) ? simple : ""; + } else if (dataType.isEmpty() && jsonModelRef != null) { + // Mixed bodies (e.g. multipart + JSON): DefaultCodegen flattens the + // form payload into parameters and leaves no body model, yet the + // JSON member still declares a schema. When that member names a + // generated model, the handler can type the JSON body exactly; + // anything else (inline objects, unions) degrades to no body. + String simple = ModelUtils.getSimpleRef(jsonModelRef); + dataType = simple != null && modelClassNames.contains(simple) ? simple : ""; + } + if (isUntypableBodyDataType(dataType, modelDataTypes)) { + // Composition unions arrive as std::variant, either directly or + // through a model that is an alias to one. Deserializing JSON into + // the right branch needs the schema-driven matcher, which the + // request path does not run, and fromJsonLeaf only reaches types + // that expose a member fromJsonValue (generated classes). Degrade + // rather than emit a call that cannot compile. std::optional is + // NOT untypable: fromJsonLeaf decodes it (JSON null = absent), so + // a nullable body schema keeps its typed handler. + dataType = ""; + } + boolean hasBody = !mediaTypes.isEmpty() && !dataType.isEmpty(); + // Nullable body schemas arrive as std::optional (OAS 3.1 + // ["X","null"]). fromJsonLeaf decodes that type (JSON null = absent), + // so the handler keeps a typed body; the header import and the + // per-token qualification resolve through Inner. The inner need not + // be a bare model or scalar: the BodyJson.h overload set decodes + // containers recursively (std::vector, std::map, + // std::shared_ptr, std::optional), so any composition over a + // generated model, a plain scalar, or boost::json::value is typable. + // Only an inner that no overload reaches degrades to no typed body + // (like the variant path). + String innerModel = dataType; + boolean optionalBody = dataType.startsWith("std::optional<") + && dataType.endsWith(">"); + if (optionalBody) { + innerModel = dataType.substring( + "std::optional<".length(), dataType.length() - 1).trim(); + if (!isDecodableBodyDataType(innerModel, modelClassNames, modelDataTypes)) { + dataType = ""; + innerModel = ""; + optionalBody = false; + hasBody = false; + } + } + if (!mediaTypes.isEmpty() && dataType.isEmpty()) { + LOGGER.warn("cpp-boost-beast-server: operation '{}' declares a JSON" + + " request body whose schema the runtime cannot type" + + "; the handler receives no typed body", + op.operationId); + } + facts.put("hasBody", hasBody); + facts.put("mediaTypes", hasBody ? rendered : ""); + // "model" names the generated class whose header the API source must + // include: the body model itself, or for an optional body the first + // model token found inside the (possibly container) inner type — + // std::optional> still needs Pet.h. "" for + // scalar/inline/degraded bodies. + facts.put("model", hasBody ? modelImportFor(innerModel, modelClassNames) : ""); + // The full field type as declared in the generated Request struct, + // with per-identifier qualification: a model token shadowed by one of + // this API class's nested contract types (XxxRequest/XxxResponder) or + // ambiguous with a runtime type under the model using-directive is + // model-namespace qualified — including inside std::optional<...>. + String fieldType = hasBody + ? qualifyContractTokens( + qualifyCollidingModels(dataType, collidingModels), + contractNames) + : ""; + facts.put("fieldType", fieldType); + // True when the field type was qualified away from its bare spelling, + // so the model using-directive is not relied upon for this field. + facts.put("modelCollides", hasBody && !fieldType.equals(dataType)); + // The generated schema-IR id of the body model, only when the schema + // registry exists (validateOnDecode) and the body is a plain model. + // The handler validates the raw JSON against that schema before + // decoding. Optional bodies are deliberately excluded: validating + // `null` against the INNER schema would reject a wire-valid null + // (the nullable wrapper is not itself a registry entry), so those + // bodies stay on the decode-shape-only path (documented policy). + String schemaId = ""; + if (hasBody && schemaRegistryAvailable && !optionalBody) { + schemaId = modelSchemaIds.getOrDefault(dataType, ""); + } + facts.put("schemaId", schemaId); + // OpenAPI request bodies are optional unless required: true. The + // handler must not reject an absent optional body as malformed JSON. + facts.put("required", body != null && Boolean.TRUE.equals(body.getRequired())); + return facts; + } + + /** + * Whether two JSON request media types declare the same body schema: both + * must be refs to the same component. Inline schemas are never treated as + * equivalent (structurally different objects read identically by name). + */ + private static boolean equivalentBodySchema(String ref, String selectedRef) { + if (ref == null || selectedRef == null) { + return false; + } + String left = ModelUtils.getSimpleRef(ref); + String right = ModelUtils.getSimpleRef(selectedRef); + return left != null && left.equals(right); + } + /** + * Whether a request body's C++ type is one {@code fromJsonLeaf} cannot + * decode: a composition union (std::variant, directly or through model + * aliases). std::optional is typable (the overload decodes JSON null as + * absent); whether its INNER type is decidable is a separate question the + * caller settles against the model/scalar tables. + */ + private static boolean isUntypableBodyDataType( + String dataType, Map modelDataTypes) { + String current = dataType; + // A body model may alias through a couple of names before reaching the + // concrete variant; bound the walk so a pathological cycle can't hang. + for (int hop = 0; hop < 4 && current != null && !current.isEmpty(); hop++) { + if (current.startsWith("std::variant<")) { + return true; + } + if (current.startsWith("std::optional<")) { + return false; + } + String resolved = modelDataTypes.get(current); + if (resolved == null || resolved.equals(current)) { + break; + } + current = resolved; + } + return false; + } + + /** + * Whether {@code fromJsonLeaf} reaches a C++ type recursively: the + * BodyJson.h overload set decodes generated models (member fromJsonValue), + * plain scalars, boost::json::value (the catch-all overload), and the + * container overloads std::optional / std::shared_ptr / std::vector / + * std::map over any decodable inner. std::variant is refused: the + * request path has no schema-driven branch matcher (documented policy, + * see isUntypableBodyDataType). Model aliases resolve through the + * className→dataType map so a model aliasing a variant stays refused. + */ + private static boolean isDecodableBodyDataType( + String type, Set modelClassNames, + Map modelDataTypes) { + return isDecodableBodyDataType(type, modelClassNames, modelDataTypes, 0); + } + + private static boolean isDecodableBodyDataType( + String type, Set modelClassNames, + Map modelDataTypes, int depth) { + String current = type == null ? "" : type.trim(); + // Bound the peel: legal schemas nest a handful of wrappers at most; + // a deeper walk means a pathological (or cyclic alias) type. + if (current.isEmpty() || depth > 8) { + return false; + } + for (String prefix : new String[] {"std::optional<", "std::shared_ptr<", + "std::vector<"}) { + if (current.startsWith(prefix) && current.endsWith(">")) { + return isDecodableBodyDataType( + current.substring(prefix.length(), current.length() - 1), + modelClassNames, modelDataTypes, depth + 1); + } + } + if (current.startsWith("std::map<") && current.endsWith(">")) { + String args = current.substring("std::map<".length(), current.length() - 1); + int depthOfAngle = 0; + for (int i = 0; i < args.length(); i++) { + char c = args.charAt(i); + if (c == '<') { + depthOfAngle++; + } else if (c == '>') { + depthOfAngle--; + } else if (c == ',' && depthOfAngle == 0) { + // The map KEY must be std::string for the overload + // (std::map) to match at all. + boolean stringKey = args.substring(0, i).trim().equals("std::string"); + return stringKey && isDecodableBodyDataType( + args.substring(i + 1), modelClassNames, modelDataTypes, + depth + 1); + } + } + return false; + } + if (current.startsWith("std::variant<")) { + return false; + } + if (CppBoostBeastServerCodegen.isPlainScalarDataType(current) + || "boost::json::value".equals(current)) { + return true; + } + // A model name that aliases a container/union: follow one hop per + // level (bounded by depth), exactly like the variant walk above. + String alias = modelDataTypes.get(current); + if (alias != null && !alias.equals(current)) { + return isDecodableBodyDataType( + alias, modelClassNames, modelDataTypes, depth + 1); + } + return modelClassNames.contains(current); + } + + + // ------------------------------------------------------------------ + // Responses + // ------------------------------------------------------------------ + + private List> serverResponses( + CodegenOperation op, Operation raw, String pascal, + Set collidingModels, Set modelClassNames, + Set contractNames) { + List> responses = new ArrayList<>(); + if (op.responses == null) { + return responses; + } + for (CodegenResponse response : op.responses) { + if (response == null) { + continue; + } + Map facts = new LinkedHashMap<>(); + boolean isDefault = Boolean.TRUE.equals(response.isDefault) + || (response.code != null && "default".equals(response.code)); + String code = response.code == null ? "default" : response.code; + facts.put("code", code); + facts.put("isDefault", isDefault); + facts.put("sendMethod", isDefault + ? "sendDefault" : "send" + sanitizeCode(code)); + // Serialize every modeled response as JSON and label it with the + // media type the document actually declares: prefer an exact JSON + // type, then a +json suffix, then a wildcard, then the first + // declared type. Responses are always JSON-serialized, so a + // non-JSON declaration here is a documented degrade (warned at + // preprocess time by collectSurfaceWarnings). + facts.put("contentType", responseContentType(raw, response)); + String dataType = response.dataType == null ? "" : response.dataType; + if (dataType.startsWith("std::shared_ptr<") && dataType.endsWith(">")) { + dataType = dataType.substring( + "std::shared_ptr<".length(), dataType.length() - 1).trim(); + } + facts.put("hasModel", !dataType.isEmpty()); + // Qualify model references that would otherwise be ambiguous or + // shadowed: a model named like this operation's nested Request/ + // Responder type (injected-class-name shadowing) or like a + // runtime type (ambiguous under the model using-directive). + // Shadowing applies per whole identifier token: a model named + // like ANY contract type of this API class (e.g. inside + // std::vector) resolves to the nested class, not the + // model, so every such token is namespace-qualified. + String rendered = qualifyContractTokens( + qualifyCollidingModels(dataType, collidingModels), + contractNames); + facts.put("sendType", rendered); + // The README quick-start declares the response value OUTSIDE the + // generated api header, so the header's `using namespace ;` + // is not in effect: rewrite whole tokens that name a generated + // model to the `model::` alias spelling the snippet declares. + // Tokens already preceded by ':' are qualified (or enum-scoped) + // and left alone. + facts.put("readmeSendType", + qualifyModelTokensForAlias(rendered, modelClassNames)); + responses.add(facts); + } + return responses; + } + + /** Rewrites whole identifier tokens that name a colliding model to a + * model-namespace-qualified spelling (handles containers such as + * std::vector<Problem>). Container keywords never collide with a + * model name, so the rewrite is safe inside template arguments. */ + String qualifyCollidingModels(String dataType, Set collidingModels) { + if (dataType.isEmpty() || collidingModels.isEmpty()) { + return dataType; + } + StringBuilder out = new StringBuilder(dataType.length()); + int i = 0; + while (i < dataType.length()) { + char c = dataType.charAt(i); + if (Character.isJavaIdentifierStart(c)) { + int j = i + 1; + while (j < dataType.length() && Character.isJavaIdentifierPart(dataType.charAt(j))) { + j++; + } + String token = dataType.substring(i, j); + if (collidingModels.contains(token)) { + out.append(modelNamespace.isEmpty() ? "" : modelNamespace + "::").append(token); + } else { + out.append(token); + } + i = j; + } else { + out.append(c); + i++; + } + } + return out.toString(); + } + + /** Rewrites whole identifier tokens that name one of this API class's + * nested contract types (XxxRequest / XxxResponder) to a model-namespace + * spelling. Inside the generated api class scope those names resolve to + * the nested contract type, shadowing a generated model with the same + * name, so a bare model token (even nested in a container type) would + * silently bind to the wrong class. */ + String qualifyContractTokens(String dataType, Set contractNames) { + if (dataType.isEmpty() || contractNames.isEmpty()) { + return dataType; + } + StringBuilder out = new StringBuilder(dataType.length()); + int i = 0; + while (i < dataType.length()) { + char c = dataType.charAt(i); + if (Character.isJavaIdentifierStart(c)) { + int j = i + 1; + while (j < dataType.length() && Character.isJavaIdentifierPart(dataType.charAt(j))) { + j++; + } + String token = dataType.substring(i, j); + // Skip tokens already qualified by a preceding '::' (they + // name the model explicitly). + boolean alreadyQualified = i >= 2 && dataType.startsWith("::", i - 2); + if (!alreadyQualified && contractNames.contains(token) + && !modelNamespace.isEmpty()) { + out.append(modelNamespace).append("::"); + } + out.append(token); + i = j; + } else { + out.append(c); + i++; + } + } + return out.toString(); + } + + /** Rewrites whole tokens that name a generated model to the `model::` + * alias spelling (the README quick-start declares + * `namespace model = {{modelNamespace}};` and its example class lives + * outside the generated api namespace, where the header's + * `using namespace ;` is not in effect). Tokens already preceded + * by ':' are qualified or enum-scoped and stay untouched. */ + String qualifyModelTokensForAlias(String dataType, Set modelClassNames) { + if (dataType.isEmpty() || modelClassNames.isEmpty()) { + return dataType; + } + StringBuilder out = new StringBuilder(dataType.length()); + int i = 0; + while (i < dataType.length()) { + char c = dataType.charAt(i); + if (Character.isJavaIdentifierStart(c)) { + int j = i + 1; + while (j < dataType.length() && Character.isJavaIdentifierPart(dataType.charAt(j))) { + j++; + } + String token = dataType.substring(i, j); + boolean alreadyQualified = i >= 2 && dataType.startsWith("::", i - 2); + if (!alreadyQualified && modelClassNames.contains(token)) { + out.append("model::"); + } + out.append(token); + i = j; + } else { + out.append(c); + i++; + } + } + return out.toString(); + } + + /** The first identifier inside a body type that names a generated model + * (the header the API source must include), or "" when the type embeds + * none (scalars, boost::json::value). Tokens are matched whole against + * the model-class table, so std::optional<std::vector<Pet>> + * yields Pet. */ + static String modelImportFor(String dataType, Set modelClassNames) { + if (dataType == null || dataType.isEmpty() || modelClassNames.isEmpty()) { + return ""; + } + if (modelClassNames.contains(dataType)) { + return dataType; + } + StringBuilder token = new StringBuilder(); + for (int i = 0; i <= dataType.length(); i++) { + char c = i < dataType.length() ? dataType.charAt(i) : '>'; + if (Character.isJavaIdentifierPart(c)) { + token.append(c); + continue; + } + if (modelClassNames.contains(token.toString())) { + return token.toString(); + } + token.setLength(0); + } + return ""; + } + + /** The Content-Type the generated responder labels this response with. + * The runtime always serializes the model as JSON, so a declared JSON + * family type is honored verbatim (exact application/json wins, then a + * {+json} suffix); a wildcard or a non-JSON declaration is served as + * application/json, which collectSurfaceWarnings has already reported. */ + private String responseContentType(Operation raw, CodegenResponse response) { + String code = response.code == null ? "default" : response.code; + ApiResponse apiResponse = raw != null && raw.getResponses() != null + ? raw.getResponses().get(code) : null; + if (apiResponse != null && apiResponse.getContent() != null) { + String plusJson = ""; + for (String mediaType : apiResponse.getContent().keySet()) { + String normalized = CppBoostBeastServerCodegen.normalizeMediaType(mediaType); + if ("application/json".equals(normalized)) { + return "application/json"; + } + if (plusJson.isEmpty() && normalized.endsWith("+json")) { + plusJson = normalized; + } + } + if (!plusJson.isEmpty()) { + return plusJson; + } + } + return "application/json"; + } + + /** Inner template argument for containers ("" for scalars). */ + static String innerTemplateArg(String dataType) { + if (dataType == null) { + return ""; + } + if (dataType.startsWith("std::vector<") && dataType.endsWith(">")) { + return dataType.substring("std::vector<".length(), dataType.length() - 1).trim(); + } + if (dataType.startsWith("std::map<") && dataType.endsWith(">")) { + String args = dataType.substring("std::map<".length(), dataType.length() - 1); + int depth = 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) { + return args.substring(i + 1).trim(); + } + } + } + return ""; + } + + /** Renders a numeric bound so the template's `{{minimum}}L` composes a + * valid long double literal: plain integers get a `.0` fraction, since + * `-9223372036854775808L` is not a literal (unary minus overflows the + * positive form) while `-9223372036854775808.0L` is well-formed. */ + private static String toLongDoubleLiteral(java.math.BigDecimal value) { + String text = value.toString(); + return text.indexOf('.') < 0 && text.indexOf('e') < 0 && text.indexOf('E') < 0 + ? text + ".0" : text; + } + + private static String sanitizeCode(String code) { + StringBuilder out = new StringBuilder(); + for (char c : code.toCharArray()) { + out.append(Character.isDigit(c) ? c : '_'); + } + return out.toString(); + } + + private static String pascalCase(String operationId) { + if (operationId == null || operationId.isEmpty()) { + return "Operation"; + } + StringBuilder out = new StringBuilder(); + boolean upperNext = true; + for (char c : operationId.toCharArray()) { + if (c == '_' || c == '-' || c == ' ' || c == '.') { + upperNext = true; + } else if (upperNext) { + out.append(Character.toUpperCase(c)); + upperNext = false; + } else { + out.append(c); + } + } + return out.toString(); + } +} 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 index 160891725c24..eb54cd3f8be0 100644 --- 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 @@ -170,104 +170,18 @@ private static String commentText(String value) { /** 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()); + return CppBoostBeastOperationFacts.isParserDefaultServerList(servers); } /** 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. */ + * groups (see {@link CppBoostBeastOperationFacts#effectiveSecurityGroups}). */ 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; + return CppBoostBeastOperationFacts.effectiveSecurityGroups(phaseOpenApi, op); } /** 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; + return CppBoostBeastOperationFacts.operationFor(phaseOpenApi, op); } /** Returns the effective operation server URL with first-level variables diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/templating/GeneratorTemplateContentLocator.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/templating/GeneratorTemplateContentLocator.java index 62b9ca91887a..5296cad89cc9 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/templating/GeneratorTemplateContentLocator.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/templating/GeneratorTemplateContentLocator.java @@ -119,6 +119,18 @@ private String resolveFullTemplatePath(String relativeTemplateFile) { return loc; } + // Finally, probe additional shared embedded template directories (in + // order) so related generators can reuse a common template root. + for (String additionalDir : config.additionalEmbeddedTemplateDirs()) { + if (additionalDir == null || additionalDir.isEmpty()) { + continue; + } + final String additional = additionalDir + File.separator + relativeTemplateFile; + if (embeddedTemplateExists(additional)) { + return additional; + } + } + return null; } } diff --git a/modules/openapi-generator/src/main/resources/META-INF/services/org.openapitools.codegen.CodegenConfig b/modules/openapi-generator/src/main/resources/META-INF/services/org.openapitools.codegen.CodegenConfig index 24b98443bca6..4a6fd4c244ee 100644 --- a/modules/openapi-generator/src/main/resources/META-INF/services/org.openapitools.codegen.CodegenConfig +++ b/modules/openapi-generator/src/main/resources/META-INF/services/org.openapitools.codegen.CodegenConfig @@ -14,6 +14,7 @@ org.openapitools.codegen.languages.ClojureClientCodegen org.openapitools.codegen.languages.ConfluenceWikiCodegen org.openapitools.codegen.languages.CppHttplibServerCodegen org.openapitools.codegen.languages.CppBoostBeastClientCodegen +org.openapitools.codegen.languages.CppBoostBeastServerCodegen org.openapitools.codegen.languages.CppOatppClientCodegen org.openapitools.codegen.languages.CppQtClientCodegen org.openapitools.codegen.languages.CppQtQHttpEngineServerCodegen 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-common/NullableField.h.mustache similarity index 100% rename from modules/openapi-generator/src/main/resources/cpp-boost-beast-client/NullableField.h.mustache rename to modules/openapi-generator/src/main/resources/cpp-boost-beast-common/NullableField.h.mustache 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-common/anytype-header.mustache similarity index 100% rename from modules/openapi-generator/src/main/resources/cpp-boost-beast-client/anytype-header.mustache rename to modules/openapi-generator/src/main/resources/cpp-boost-beast-common/anytype-header.mustache diff --git a/modules/openapi-generator/src/main/resources/cpp-boost-beast-common/licenseInfo.mustache b/modules/openapi-generator/src/main/resources/cpp-boost-beast-common/licenseInfo.mustache new file mode 100644 index 000000000000..e737bfd822a0 --- /dev/null +++ b/modules/openapi-generator/src/main/resources/cpp-boost-beast-common/licenseInfo.mustache @@ -0,0 +1,11 @@ +/** + * {{{appName}}} + * {{{appDescription}}} + * + * {{#version}}The version of the OpenAPI document: {{{.}}}{{/version}} +{{#infoEmail}} * Contact: {{{.}}} +{{/infoEmail}} * + * NOTE: This class is auto generated by OpenAPI-Generator {{{generatorVersion}}}. + * https://openapi-generator.tech + * Do not edit the class manually. + */ diff --git a/modules/openapi-generator/src/main/resources/cpp-boost-beast-client/model-header.mustache b/modules/openapi-generator/src/main/resources/cpp-boost-beast-common/model-header.mustache similarity index 100% rename from modules/openapi-generator/src/main/resources/cpp-boost-beast-client/model-header.mustache rename to modules/openapi-generator/src/main/resources/cpp-boost-beast-common/model-header.mustache diff --git a/modules/openapi-generator/src/main/resources/cpp-boost-beast-client/model-source.mustache b/modules/openapi-generator/src/main/resources/cpp-boost-beast-common/model-source.mustache similarity index 97% rename from modules/openapi-generator/src/main/resources/cpp-boost-beast-client/model-source.mustache rename to modules/openapi-generator/src/main/resources/cpp-boost-beast-common/model-source.mustache index 3e4562c9849b..4539859a7413 100644 --- a/modules/openapi-generator/src/main/resources/cpp-boost-beast-client/model-source.mustache +++ b/modules/openapi-generator/src/main/resources/cpp-boost-beast-common/model-source.mustache @@ -1174,6 +1174,32 @@ struct JsonValueConverter } }; +// Integer destinations honour the exact wire lexeme when the decode runs +// inside an ExactInstanceScope (see tryGetMathematicalInteger). Plain +// value_to would accept the ROUNDED double image of any +// integral token above 2^53 — 9007199254740993.0 arriving as ...92 — +// because its static_cast equality check agrees with the image. +template <> +struct JsonValueConverter +{ + static boost::json::value toJsonValue(const std::int64_t& sourceValue) + { + return boost::json::value_from(sourceValue); + } + + static std::int64_t fromJsonValue(const boost::json::value& jsonValue) + { + std::int64_t result = 0; + if (!tryGetMathematicalInteger(jsonValue, result)) { + throw std::invalid_argument( + "Decode failed: value not representable as int64 " + "(non-integral, out of range, or past the exact window " + "with no wire lexeme to recover from)"); + } + return result; + } +}; + template <> struct JsonValueConverter { @@ -1674,13 +1700,23 @@ void {{classname}}::fromJsonObject_internal(boost::json::object const& object) {{#vars}} {{^vendorExtensions.x-cpp-reject-if-present}} {{^vendorExtensions.x-cpp-const}} + {{! Reset every decodable member to its fresh-construction state before + reading JSON, so a decode into a reused instance cannot observe a + value left by a previous decode or setter. Required members included: + a required member whose JSON value is skipped by the null-tolerance + policy must show the declared default, not a stale one. The IsSet + bookkeeping line exists only for non-required plain members. }} {{^required}} {{^vendorExtensions.x-cpp-no-is-set}} m_{{name}}IsSet = false; {{/vendorExtensions.x-cpp-no-is-set}} + {{/required}} {{#vendorExtensions.x-cpp-has-explicit-default}} {{#parent}} if constexpr (!{{classname}}{{name}}PropertyIsInherited<{{{parent}}}>::value) { + // With a parent the member is the ModelPropertyStorage wrapper; the + // reset must write its .value (the wrapper itself has no assignment + // from ValueType). m_{{name}}.value = {{{defaultValue}}}; } {{/parent}} @@ -1698,8 +1734,8 @@ void {{classname}}::fromJsonObject_internal(boost::json::object const& object) {{^parent}} m_{{name}}.resetMissing(); {{/parent}} - {{/vendorExtensions.x-cpp-nullable-field}} - {{#vendorExtensions.x-cpp-no-is-set}} + {{/vendorExtensions.x-cpp-nullable-field}} + {{#vendorExtensions.x-cpp-no-is-set}} {{^vendorExtensions.x-cpp-nullable-field}} {{#parent}} if constexpr (!{{classname}}{{name}}PropertyIsInherited<{{{parent}}}>::value) { @@ -1709,10 +1745,21 @@ void {{classname}}::fromJsonObject_internal(boost::json::object const& object) {{^parent}} m_{{name}}.reset(); {{/parent}} - {{/vendorExtensions.x-cpp-nullable-field}} - {{/vendorExtensions.x-cpp-no-is-set}} + {{/vendorExtensions.x-cpp-nullable-field}} + {{/vendorExtensions.x-cpp-no-is-set}} + {{^vendorExtensions.x-cpp-nullable-field}} + {{^vendorExtensions.x-cpp-no-is-set}} + {{#parent}} + if constexpr (!{{classname}}{{name}}PropertyIsInherited<{{{parent}}}>::value) { + m_{{name}} = {}; + } + {{/parent}} + {{^parent}} + m_{{name}} = {}; + {{/parent}} + {{/vendorExtensions.x-cpp-no-is-set}} + {{/vendorExtensions.x-cpp-nullable-field}} {{/vendorExtensions.x-cpp-has-explicit-default}} - {{/required}} {{/vendorExtensions.x-cpp-const}} {{/vendorExtensions.x-cpp-reject-if-present}} {{/vars}} diff --git a/modules/openapi-generator/src/main/resources/cpp-boost-beast-client/oas31_deep_equal.mustache b/modules/openapi-generator/src/main/resources/cpp-boost-beast-common/oas31_deep_equal.mustache similarity index 100% rename from modules/openapi-generator/src/main/resources/cpp-boost-beast-client/oas31_deep_equal.mustache rename to modules/openapi-generator/src/main/resources/cpp-boost-beast-common/oas31_deep_equal.mustache diff --git a/modules/openapi-generator/src/main/resources/cpp-boost-beast-client/oas31_exact_json.mustache b/modules/openapi-generator/src/main/resources/cpp-boost-beast-common/oas31_exact_json.mustache similarity index 87% rename from modules/openapi-generator/src/main/resources/cpp-boost-beast-client/oas31_exact_json.mustache rename to modules/openapi-generator/src/main/resources/cpp-boost-beast-common/oas31_exact_json.mustache index 38dc19d37d86..5aede0ff3792 100644 --- a/modules/openapi-generator/src/main/resources/cpp-boost-beast-client/oas31_exact_json.mustache +++ b/modules/openapi-generator/src/main/resources/cpp-boost-beast-common/oas31_exact_json.mustache @@ -25,9 +25,12 @@ #include #include +#include +#include #include #include #include +#include #include #include #include @@ -392,6 +395,46 @@ inline InstanceLexemeTable const* activeInstanceLexemes( path = found->second; return context->lexemes; } + +/// Inside a live ExactInstanceScope the original wire lexeme is recoverable +/// for every numeric node of the parsed document. Returns nullptr when no +/// scope is active (plain parse, untouched DOM) or the node is not a +/// recorded number. +inline std::string const* exactNumericLexeme(boost::json::value const& json) { + std::string path; + auto const* table = activeInstanceLexemes(&json, path); + return table == nullptr ? nullptr : table->lexemeAt(path); +} + +/// Converts `lexeme` into `out` exactly when it names an integer within the +/// bounds of the destination type T. Works on the decimal text, never on a +/// binary-float image, so tokens the double cannot represent (integers +/// above 2^53 written with a fraction point or exponent) convert exactly. +/// A lexeme longer than the ExactNumber implementation limit throws +/// std::length_error; callers already treat that as a payload error. +template +bool exactLexemeToInteger(std::string const& lexeme, T& out) { + static_assert(std::is_integral_v && !std::is_same_v, + "exactLexemeToInteger requires an integral destination"); + ExactNumber const number = ExactNumber::parseLexeme(lexeme); + if (!number.isInteger()) { + return false; + } + using Big = ExactNumber::Integer; + ExactNumber const low(Big((std::numeric_limits::min)()), Big(0)); + ExactNumber const high(Big((std::numeric_limits::max)()), Big(0)); + if (number.compare(low) < 0 || number.compare(high) > 0) { + return false; + } + // The range check bounds the exponent (T's maximum has finitely many + // decimal digits), so this loop is finite and small by construction. + Big scaled = number.mantissa(); + for (Big e = number.exponent10(); e > 0; --e) { + scaled *= 10; + } + out = scaled.convert_to(); + return true; +} } // namespace {{schemaValidationNamespace}} #endif // {{schemaValidationHeaderGuardPrefix}}_OAS31_EXACT_JSON_H_ diff --git a/modules/openapi-generator/src/main/resources/cpp-boost-beast-client/oas31_exact_number.mustache b/modules/openapi-generator/src/main/resources/cpp-boost-beast-common/oas31_exact_number.mustache similarity index 100% rename from modules/openapi-generator/src/main/resources/cpp-boost-beast-client/oas31_exact_number.mustache rename to modules/openapi-generator/src/main/resources/cpp-boost-beast-common/oas31_exact_number.mustache diff --git a/modules/openapi-generator/src/main/resources/cpp-boost-beast-client/oas31_exact_number_source.mustache b/modules/openapi-generator/src/main/resources/cpp-boost-beast-common/oas31_exact_number_source.mustache similarity index 100% rename from modules/openapi-generator/src/main/resources/cpp-boost-beast-client/oas31_exact_number_source.mustache rename to modules/openapi-generator/src/main/resources/cpp-boost-beast-common/oas31_exact_number_source.mustache diff --git a/modules/openapi-generator/src/main/resources/cpp-boost-beast-client/oas31_schema_ir.mustache b/modules/openapi-generator/src/main/resources/cpp-boost-beast-common/oas31_schema_ir.mustache similarity index 100% rename from modules/openapi-generator/src/main/resources/cpp-boost-beast-client/oas31_schema_ir.mustache rename to modules/openapi-generator/src/main/resources/cpp-boost-beast-common/oas31_schema_ir.mustache diff --git a/modules/openapi-generator/src/main/resources/cpp-boost-beast-client/oas31_schema_ir_chunk0.mustache b/modules/openapi-generator/src/main/resources/cpp-boost-beast-common/oas31_schema_ir_chunk0.mustache similarity index 100% rename from modules/openapi-generator/src/main/resources/cpp-boost-beast-client/oas31_schema_ir_chunk0.mustache rename to modules/openapi-generator/src/main/resources/cpp-boost-beast-common/oas31_schema_ir_chunk0.mustache diff --git a/modules/openapi-generator/src/main/resources/cpp-boost-beast-client/oas31_schema_ir_chunk1.mustache b/modules/openapi-generator/src/main/resources/cpp-boost-beast-common/oas31_schema_ir_chunk1.mustache similarity index 100% rename from modules/openapi-generator/src/main/resources/cpp-boost-beast-client/oas31_schema_ir_chunk1.mustache rename to modules/openapi-generator/src/main/resources/cpp-boost-beast-common/oas31_schema_ir_chunk1.mustache diff --git a/modules/openapi-generator/src/main/resources/cpp-boost-beast-client/oas31_schema_ir_chunk10.mustache b/modules/openapi-generator/src/main/resources/cpp-boost-beast-common/oas31_schema_ir_chunk10.mustache similarity index 100% rename from modules/openapi-generator/src/main/resources/cpp-boost-beast-client/oas31_schema_ir_chunk10.mustache rename to modules/openapi-generator/src/main/resources/cpp-boost-beast-common/oas31_schema_ir_chunk10.mustache diff --git a/modules/openapi-generator/src/main/resources/cpp-boost-beast-client/oas31_schema_ir_chunk11.mustache b/modules/openapi-generator/src/main/resources/cpp-boost-beast-common/oas31_schema_ir_chunk11.mustache similarity index 100% rename from modules/openapi-generator/src/main/resources/cpp-boost-beast-client/oas31_schema_ir_chunk11.mustache rename to modules/openapi-generator/src/main/resources/cpp-boost-beast-common/oas31_schema_ir_chunk11.mustache diff --git a/modules/openapi-generator/src/main/resources/cpp-boost-beast-client/oas31_schema_ir_chunk12.mustache b/modules/openapi-generator/src/main/resources/cpp-boost-beast-common/oas31_schema_ir_chunk12.mustache similarity index 100% rename from modules/openapi-generator/src/main/resources/cpp-boost-beast-client/oas31_schema_ir_chunk12.mustache rename to modules/openapi-generator/src/main/resources/cpp-boost-beast-common/oas31_schema_ir_chunk12.mustache diff --git a/modules/openapi-generator/src/main/resources/cpp-boost-beast-client/oas31_schema_ir_chunk13.mustache b/modules/openapi-generator/src/main/resources/cpp-boost-beast-common/oas31_schema_ir_chunk13.mustache similarity index 100% rename from modules/openapi-generator/src/main/resources/cpp-boost-beast-client/oas31_schema_ir_chunk13.mustache rename to modules/openapi-generator/src/main/resources/cpp-boost-beast-common/oas31_schema_ir_chunk13.mustache diff --git a/modules/openapi-generator/src/main/resources/cpp-boost-beast-client/oas31_schema_ir_chunk14.mustache b/modules/openapi-generator/src/main/resources/cpp-boost-beast-common/oas31_schema_ir_chunk14.mustache similarity index 100% rename from modules/openapi-generator/src/main/resources/cpp-boost-beast-client/oas31_schema_ir_chunk14.mustache rename to modules/openapi-generator/src/main/resources/cpp-boost-beast-common/oas31_schema_ir_chunk14.mustache diff --git a/modules/openapi-generator/src/main/resources/cpp-boost-beast-client/oas31_schema_ir_chunk15.mustache b/modules/openapi-generator/src/main/resources/cpp-boost-beast-common/oas31_schema_ir_chunk15.mustache similarity index 100% rename from modules/openapi-generator/src/main/resources/cpp-boost-beast-client/oas31_schema_ir_chunk15.mustache rename to modules/openapi-generator/src/main/resources/cpp-boost-beast-common/oas31_schema_ir_chunk15.mustache diff --git a/modules/openapi-generator/src/main/resources/cpp-boost-beast-client/oas31_schema_ir_chunk2.mustache b/modules/openapi-generator/src/main/resources/cpp-boost-beast-common/oas31_schema_ir_chunk2.mustache similarity index 100% rename from modules/openapi-generator/src/main/resources/cpp-boost-beast-client/oas31_schema_ir_chunk2.mustache rename to modules/openapi-generator/src/main/resources/cpp-boost-beast-common/oas31_schema_ir_chunk2.mustache diff --git a/modules/openapi-generator/src/main/resources/cpp-boost-beast-client/oas31_schema_ir_chunk3.mustache b/modules/openapi-generator/src/main/resources/cpp-boost-beast-common/oas31_schema_ir_chunk3.mustache similarity index 100% rename from modules/openapi-generator/src/main/resources/cpp-boost-beast-client/oas31_schema_ir_chunk3.mustache rename to modules/openapi-generator/src/main/resources/cpp-boost-beast-common/oas31_schema_ir_chunk3.mustache diff --git a/modules/openapi-generator/src/main/resources/cpp-boost-beast-client/oas31_schema_ir_chunk4.mustache b/modules/openapi-generator/src/main/resources/cpp-boost-beast-common/oas31_schema_ir_chunk4.mustache similarity index 100% rename from modules/openapi-generator/src/main/resources/cpp-boost-beast-client/oas31_schema_ir_chunk4.mustache rename to modules/openapi-generator/src/main/resources/cpp-boost-beast-common/oas31_schema_ir_chunk4.mustache diff --git a/modules/openapi-generator/src/main/resources/cpp-boost-beast-client/oas31_schema_ir_chunk5.mustache b/modules/openapi-generator/src/main/resources/cpp-boost-beast-common/oas31_schema_ir_chunk5.mustache similarity index 100% rename from modules/openapi-generator/src/main/resources/cpp-boost-beast-client/oas31_schema_ir_chunk5.mustache rename to modules/openapi-generator/src/main/resources/cpp-boost-beast-common/oas31_schema_ir_chunk5.mustache diff --git a/modules/openapi-generator/src/main/resources/cpp-boost-beast-client/oas31_schema_ir_chunk6.mustache b/modules/openapi-generator/src/main/resources/cpp-boost-beast-common/oas31_schema_ir_chunk6.mustache similarity index 100% rename from modules/openapi-generator/src/main/resources/cpp-boost-beast-client/oas31_schema_ir_chunk6.mustache rename to modules/openapi-generator/src/main/resources/cpp-boost-beast-common/oas31_schema_ir_chunk6.mustache diff --git a/modules/openapi-generator/src/main/resources/cpp-boost-beast-client/oas31_schema_ir_chunk7.mustache b/modules/openapi-generator/src/main/resources/cpp-boost-beast-common/oas31_schema_ir_chunk7.mustache similarity index 100% rename from modules/openapi-generator/src/main/resources/cpp-boost-beast-client/oas31_schema_ir_chunk7.mustache rename to modules/openapi-generator/src/main/resources/cpp-boost-beast-common/oas31_schema_ir_chunk7.mustache diff --git a/modules/openapi-generator/src/main/resources/cpp-boost-beast-client/oas31_schema_ir_chunk8.mustache b/modules/openapi-generator/src/main/resources/cpp-boost-beast-common/oas31_schema_ir_chunk8.mustache similarity index 100% rename from modules/openapi-generator/src/main/resources/cpp-boost-beast-client/oas31_schema_ir_chunk8.mustache rename to modules/openapi-generator/src/main/resources/cpp-boost-beast-common/oas31_schema_ir_chunk8.mustache diff --git a/modules/openapi-generator/src/main/resources/cpp-boost-beast-client/oas31_schema_ir_chunk9.mustache b/modules/openapi-generator/src/main/resources/cpp-boost-beast-common/oas31_schema_ir_chunk9.mustache similarity index 100% rename from modules/openapi-generator/src/main/resources/cpp-boost-beast-client/oas31_schema_ir_chunk9.mustache rename to modules/openapi-generator/src/main/resources/cpp-boost-beast-common/oas31_schema_ir_chunk9.mustache diff --git a/modules/openapi-generator/src/main/resources/cpp-boost-beast-client/oas31_schema_ir_header.mustache b/modules/openapi-generator/src/main/resources/cpp-boost-beast-common/oas31_schema_ir_header.mustache similarity index 100% rename from modules/openapi-generator/src/main/resources/cpp-boost-beast-client/oas31_schema_ir_header.mustache rename to modules/openapi-generator/src/main/resources/cpp-boost-beast-common/oas31_schema_ir_header.mustache diff --git a/modules/openapi-generator/src/main/resources/cpp-boost-beast-client/oas31_schema_ir_source.mustache b/modules/openapi-generator/src/main/resources/cpp-boost-beast-common/oas31_schema_ir_source.mustache similarity index 100% rename from modules/openapi-generator/src/main/resources/cpp-boost-beast-client/oas31_schema_ir_source.mustache rename to modules/openapi-generator/src/main/resources/cpp-boost-beast-common/oas31_schema_ir_source.mustache diff --git a/modules/openapi-generator/src/main/resources/cpp-boost-beast-client/oas31_validator.mustache b/modules/openapi-generator/src/main/resources/cpp-boost-beast-common/oas31_validator.mustache similarity index 92% rename from modules/openapi-generator/src/main/resources/cpp-boost-beast-client/oas31_validator.mustache rename to modules/openapi-generator/src/main/resources/cpp-boost-beast-common/oas31_validator.mustache index 4925786090f3..13a781815e57 100644 --- a/modules/openapi-generator/src/main/resources/cpp-boost-beast-client/oas31_validator.mustache +++ b/modules/openapi-generator/src/main/resources/cpp-boost-beast-common/oas31_validator.mustache @@ -850,63 +850,102 @@ private: // ==================================================================== // ECMAScript-subset pattern engine // - // The implementation decodes code points before translating supported - // Unicode letter escapes to wide-regex ranges. Matching is unanchored unless - // the pattern supplies anchors; unsupported constructs fail closed. + // The implementation decodes code points before compiling a wide regex. + // Matching is unanchored unless the pattern supplies anchors; unsupported + // constructs fail closed. // ==================================================================== - /// Letter ranges approximated from the Unicode Letter categories for the - /// corpus surface (Latin, Greek, Cyrillic, Armenian, Hebrew, Arabic, - /// Indic, CJK, Hangul). Documented approximation: not exhaustive. - static char const* letterRanges() { - return "a-zA-Z" - "\\u00C0-\\u00FF\\u0100-\\u024F" - "\\u0370-\\u03FF\\u0400-\\u04FF" - "\\u0500-\\u052F\\u0531-\\u058F" - "\\u0591-\\u05FF\\u0600-\\u06FF" - "\\u0900-\\u097F\\u0A00-\\u0A7F" - "\\u0B00-\\u0B7F\\u0C00-\\u0C7F" - "\\u0D00-\\u0D7F\\u1E00-\\u1EFF" - "\\u3041-\\u3096\\u30A1-\\u30FA" - "\\u3400-\\u4DBF\\u4E00-\\u9FFF" - "\\uAC00-\\uD7A3"; - } - - static std::string replaceAll(std::string s, std::string const& from, - std::string const& to) { - std::size_t pos = 0; - while ((pos = s.find(from, pos)) != std::string::npos) { - s.replace(pos, from.size(), to); - pos += to.size(); + /// True when `pattern` contains a real Unicode property escape + /// (\\p{...} / \\P{...}). std::regex's ECMAScript subset has no complete + /// representation for these, and a hand-maintained code-point range list + /// can only ever approximate a property like Letter — silently rejecting + /// valid letters outside the listed scripts. Such patterns are therefore + /// reported as unsupported (fail closed) rather than approximated: the + /// validator answers with an explicit "unsupported pattern expression" + /// error instead of a wrong accept/reject. Only a backslash starting an + /// ODD-length run escapes the next character; in "\\\\p{L}" the doubled + /// backslashes are literals and the following p is ordinary text. + static bool hasUnicodePropertyEscape(std::string const& pattern) { + for (std::size_t i = 0; i < pattern.size(); ++i) { + if (pattern[i] != '\\') { + continue; + } + std::size_t slashes = 0; + while (i + slashes < pattern.size() && pattern[i + slashes] == '\\') { + ++slashes; + } + std::size_t const after = i + slashes; + if (after >= pattern.size()) { + break; // run ends the pattern; nothing to test + } + if ((slashes % 2) == 0) { + // Even run: every backslash is itself escaped, the following + // character is a literal; loop ++i lands right after the run. + i = after - 1; + continue; + } + if ((pattern[after] == 'p' || pattern[after] == 'P') + && after + 1 < pattern.size() && pattern[after + 1] == '{') { + return true; + } + // Odd run that is not a property escape: the escaped character + // is a literal; consume it so its bytes are not re-scanned. + i = after; } - return s; + return false; } - /// Translate \p{...} / \P{...} letter escapes into explicit ranges. static std::wstring normalizeEcmaPattern(std::string p) { - p = replaceAll(std::move(p), "\\p{Letter}", - "[" + std::string(letterRanges()) + "]"); - p = replaceAll(std::move(p), "\\p{L}", - "[" + std::string(letterRanges()) + "]"); - p = replaceAll(std::move(p), "\\P{Letter}", - "[^" + std::string(letterRanges()) + "]"); - p = replaceAll(std::move(p), "\\P{L}", - "[^" + std::string(letterRanges()) + "]"); return utf8ToWide(std::move(p)); } - /// UTF-8 code-point count: skips continuation bytes; invalid sequences - /// degrade to a byte count (never a crash). + /// UTF-8 code-point count: a complete valid sequence counts once; any + /// byte not part of one (invalid lead, truncated sequence, overlong, + /// surrogate, orphaned continuation) counts as one, so malformed input + /// degrades to a byte count and can never UNDERCOUNT — same grammar as + /// the parameter codecs' utf8CodepointCount. static std::size_t countCodePoints(std::string const& s) { - std::size_t n = 0; - for (unsigned char c : s) { - if ((c & 0xC0) != 0x80) ++n; + std::size_t count = 0; + std::size_t i = 0; + while (i < s.size()) { + unsigned char const lead = static_cast(s[i]); + std::size_t extra; + if (lead < 0x80) { + extra = 0; + } else if (lead >= 0xC2 && lead <= 0xDF) { + extra = 1; + } else if (lead >= 0xE0 && lead <= 0xEF) { + extra = 2; + } else if (lead >= 0xF0 && lead <= 0xF4) { + extra = 3; + } else { + extra = static_cast(-1); // C0/C1/F5+/stray cont + } + bool whole = extra != static_cast(-1) + && i + extra < s.size(); + for (std::size_t k = 1; whole && k <= extra; ++k) { + unsigned char const next = static_cast(s[i + k]); + if (next < 0x80 || next > 0xBF + || (k == 1 && ( + (lead == 0xE0 && next < 0xA0) + || (lead == 0xED && next > 0x9F) + || (lead == 0xF0 && next < 0x90) + || (lead == 0xF4 && next > 0x8F)))) { + whole = false; + } + } + ++count; + i += whole ? extra + 1 : 1; } - return n; + return count; } - /// Decode UTF-8 into code-point values stored in wchar_t (32-bit on - /// macOS/Linux). Invalid bytes pass through verbatim. + /// Decode UTF-8 into code-point values stored in wchar_t. On 32-bit + /// wchar_t (macOS/Linux) each element is one scalar; on 16-bit wchar_t + /// (Windows) scalars above U+FFFF are encoded as UTF-16 surrogate pairs, + /// which is what std::wregex there matches against — storing the bare + /// code point would silently truncate it to its low 16 bits. Invalid + /// bytes pass through verbatim. static std::wstring utf8ToWide(std::string const& s) { std::wstring out; out.reserve(s.size()); @@ -930,7 +969,13 @@ private: cp = (cp << 6) | (cc & 0x3F); } if (!ok) { out.push_back(static_cast(c)); ++i; continue; } - out.push_back(static_cast(cp)); + if (sizeof(wchar_t) == 2 && cp > 0xFFFF) { + std::uint32_t v = cp - 0x10000; + out.push_back(static_cast(0xD800 + (v >> 10))); + out.push_back(static_cast(0xDC00 + (v & 0x3FF))); + } else { + out.push_back(static_cast(cp)); + } i += extra + 1; } return out; @@ -941,9 +986,14 @@ private: bool matched; }; - /// Unanchored ECMAScript-subset search on code points. + /// Unanchored ECMAScript-subset search on code points. Property escapes + /// are refused before compilation (see hasUnicodePropertyEscape); no + /// approximation is attempted. static RegexMatch ecmaRegexSearch(std::string const& pattern, std::string const& key) { + if (hasUnicodePropertyEscape(pattern)) { + return {false, false}; + } try { std::wregex re(normalizeEcmaPattern(pattern), std::regex_constants::ECMAScript); diff --git a/modules/openapi-generator/src/main/resources/cpp-boost-beast-client/validation-types.mustache b/modules/openapi-generator/src/main/resources/cpp-boost-beast-common/validation-types.mustache similarity index 80% rename from modules/openapi-generator/src/main/resources/cpp-boost-beast-client/validation-types.mustache rename to modules/openapi-generator/src/main/resources/cpp-boost-beast-common/validation-types.mustache index bc1bd51ba461..8ad726c3123f 100644 --- a/modules/openapi-generator/src/main/resources/cpp-boost-beast-client/validation-types.mustache +++ b/modules/openapi-generator/src/main/resources/cpp-boost-beast-common/validation-types.mustache @@ -14,6 +14,7 @@ #include +#include #include #include #include @@ -23,6 +24,10 @@ #include #include #include +// The exact-JSON runtime lives beside this header and supplies the wire +// lexeme recovery used by tryGetMathematicalInteger's double branch. There +// is no cycle: Oas31ExactJson.h does not include this header. +#include "Oas31ExactJson.h" {{#modelNamespaceDeclarations}} namespace {{this}} { @@ -161,6 +166,25 @@ inline bool isJsonInteger(boost::json::value const& v) { /// Attempts to extract a mathematical integer into a checked destination. /// Returns false when the value is not integral or exceeds destination bounds. +/// A double is a trustworthy integer image only inside the doubles' own +/// exact window, |value| <= 2^53. The upper edge stays open: +2^53 is also +/// the ties-to-even image of the token 9007199254740993, so accepting it +/// would decode a different wire integer as the image. The lower edge is +/// open only for a destination reaching the precision boundary (int64), +/// whose -2^53 image is likewise ambiguous with -9007199254740993; for a +/// narrower signed destination the full range lies inside the exact +/// window, so the destination's own minimum image is accepted. +/// (For an unsigned destination the window's lower edge is zero, and zero +/// is never the image of a different integral token — +0.0 and -0.0 both +/// name the integer 0 — so zero stays accepted there.) Above the window +/// every double IS integral (modf says 0) yet the token that produced it may +/// already have been rounded at parse time, and every cast-based exactness +/// check then agrees with the rounded image. Inside an ExactInstanceScope +/// the wire lexeme is authoritative and is consulted FIRST, before any +/// image-based check: it converts exactly (9007199254740993.0 names an +/// integer the binary image cannot; 1.0000000000000001 names a +/// non-integer the image cannot see). Outside any scope the window is +/// enforced and a value beyond it is refused rather than silently corrupted. template bool tryGetMathematicalInteger(boost::json::value const& v, T& out) { static_assert(std::is_integral_v, @@ -191,6 +215,17 @@ bool tryGetMathematicalInteger(boost::json::value const& v, T& out) { return true; } case boost::json::kind::double_: { + // The wire lexeme outranks the binary image whenever it exists: + // an image check would accept 1.0000000000000001 (which rounds to + // the integer 1.0) and reject 9223372036854775807.0 (which rounds + // past int64's maximum), inverting both verdicts. A lexeme beyond + // the ExactNumber limit throws std::length_error; callers map + // that to a payload error. + if (std::string const* lexeme = + {{schemaValidationNamespace}}::exactNumericLexeme(v)) { + return {{schemaValidationNamespace}}::exactLexemeToInteger( + *lexeme, out); + } double const value = v.as_double(); double integralPart; if (!std::isfinite(value) @@ -205,8 +240,33 @@ bool tryGetMathematicalInteger(boost::json::value const& v, T& out) { || integralPart >= upperExclusive) { return false; } - out = static_cast(integralPart); - return true; + int const trustDigits = (std::min)( + std::numeric_limits::digits, + std::numeric_limits::digits); + double const trustUpper = std::ldexp(1.0, trustDigits); + // Inside the exact window every double image names exactly one + // integer, so the window may span the destination's own bounds. + // Upper stays open: images at or past 2^digits are ambiguous + // once the destination reaches the precision boundary (int64's + // 2^63 edge). Lower closes to -2^digits only when the signed + // destination's full range fits inside the window — otherwise + // int32 would refuse its very own minimum, -2^31, whose image + // is unambiguous. Unsigned: the zero edge is unambiguous, so + // 0.0 / -0.0 stay accepted. + bool const rangeFitsExactly = std::is_signed_v + && std::numeric_limits::digits + < std::numeric_limits::digits; + double const trustLower = std::is_signed_v + ? (rangeFitsExactly ? -trustUpper : -std::nextafter(trustUpper, 0.0)) + : 0.0; + if (integralPart >= trustLower && integralPart < trustUpper) { + out = static_cast(integralPart); + return true; + } + // At or past the trust window with no lexeme to recover the + // token from: fail closed rather than return a possibly rounded + // image. + return false; } default: return false; diff --git a/modules/openapi-generator/src/main/resources/cpp-boost-beast-server/CMakeLists.txt.mustache b/modules/openapi-generator/src/main/resources/cpp-boost-beast-server/CMakeLists.txt.mustache new file mode 100644 index 000000000000..1c447247b74b --- /dev/null +++ b/modules/openapi-generator/src/main/resources/cpp-boost-beast-server/CMakeLists.txt.mustache @@ -0,0 +1,141 @@ +cmake_minimum_required(VERSION 3.14) +project({{{packageName}}} VERSION 1.0.0 LANGUAGES CXX) + +include(GNUInstallDirs) + +if (POLICY CMP0167) + cmake_policy(SET CMP0167 OLD) +endif () + +set(BOOST_BOOST_TARGET_PREDEFINED FALSE) +set(BOOST_JSON_TARGET_PREDEFINED FALSE) +set(BOOST_URL_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 () +if (TARGET Boost::url) + set(BOOST_URL_TARGET_PREDEFINED TRUE) +endif () + +# Boost.URL is linked as a compiled component (Boost 1.81+). +find_package(Boost 1.81 REQUIRED COMPONENTS json url) +# 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 () +if (NOT BOOST_URL_TARGET_PREDEFINED) + set_property(TARGET Boost::url PROPERTY IMPORTED_GLOBAL TRUE) +endif () +set(THREADS_TARGET_PREDEFINED FALSE) +if (TARGET Threads::Threads) + set(THREADS_TARGET_PREDEFINED TRUE) +endif () +set(THREADS_PREFER_PTHREAD_FLAG TRUE) +find_package(Threads REQUIRED) +if (NOT THREADS_TARGET_PREDEFINED) + set_property(TARGET Threads::Threads PROPERTY IMPORTED_GLOBAL TRUE) +endif () + +# Generated code is held warning-clean. GCC/Clang use -Wall/-Wextra and +# MSVC uses /W4 with conforming language mode; the default WERROR option +# promotes those warnings to errors on every compiler. +option(CPP_BOOST_BEAST_SERVER_WERROR "Treat compiler warnings as errors" ON) + +if (MSVC) + add_compile_options(/W4 /permissive-) + if (CPP_BOOST_BEAST_SERVER_WERROR) + add_compile_options(/WX) + endif () +else () + set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -fPIC -Wall -Wextra") + if (CPP_BOOST_BEAST_SERVER_WERROR) + set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -Werror") + endif () +endif () + + +add_library({{{packageName}}} STATIC) + +set_property(TARGET {{{packageName}}} PROPERTY CXX_STANDARD 17) +set_property(TARGET {{{packageName}}} PROPERTY CXX_STANDARD_REQUIRED ON) +set_property(TARGET {{{packageName}}} PROPERTY CXX_EXTENSIONS OFF) + +target_sources({{{packageName}}} PRIVATE +# models +{{#models}} +{{#model}} + model/{{classname}}.cpp + model/{{classname}}.h +{{/model}} +{{/models}} +# apis +{{#apiInfo}} +{{#apis}} +{{#operations}} + api/{{classname}}.cpp + api/{{classname}}.h +{{/operations}} +{{/apis}} +{{/apiInfo}} +# server runtime + server/Authorizer.h + server/BodyJson.h + server/HttpServer.cpp + server/HttpServer.h + server/ParamCodecs.h + server/Problem.h + server/Responder.h + server/Router.h +# shared model/validation support + model/AnyType.h + model/NullableField.h + model/Oas31DeepEqual.h + model/Oas31ExactNumber.cpp + model/Oas31ExactNumber.h + model/Oas31SchemaIr.h + model/Oas31ExactJson.h + model/Oas31Validator.h + model/ValidationTypes.h +{{#validateOnDecode}} + model/schema_ir.generated.cpp +{{#oas31SchemaIrChunkFiles}} + model/{{filename}} +{{/oas31SchemaIrChunkFiles}} + model/Oas31SchemaRegistry.h +{{/validateOnDecode}} +) + +target_link_libraries({{{packageName}}} + PUBLIC Boost::boost Boost::json Boost::url Threads::Threads) + +target_include_directories({{{packageName}}} PUBLIC + $ + $ + $ + $ + $ + $ + $ + $) + +install(TARGETS {{{packageName}}} + ARCHIVE DESTINATION "${CMAKE_INSTALL_LIBDIR}") + +install(DIRECTORY api model server + DESTINATION "${CMAKE_INSTALL_INCLUDEDIR}/${PROJECT_NAME}" + FILES_MATCHING PATTERN "*.h") + +{{#addApiImplStubs}} +add_executable(${PROJECT_NAME}_main main.cpp) +set_property(TARGET ${PROJECT_NAME}_main PROPERTY CXX_STANDARD 17) +set_property(TARGET ${PROJECT_NAME}_main PROPERTY CXX_STANDARD_REQUIRED ON) +set_property(TARGET ${PROJECT_NAME}_main PROPERTY CXX_EXTENSIONS OFF) +target_link_libraries(${PROJECT_NAME}_main PRIVATE {{{packageName}}}) +{{/addApiImplStubs}} diff --git a/modules/openapi-generator/src/main/resources/cpp-boost-beast-server/README.mustache b/modules/openapi-generator/src/main/resources/cpp-boost-beast-server/README.mustache new file mode 100644 index 000000000000..808bd4075ea4 --- /dev/null +++ b/modules/openapi-generator/src/main/resources/cpp-boost-beast-server/README.mustache @@ -0,0 +1,195 @@ +{{>licenseInfo}} +# {{packageName}} — Boost.Beast server + +{{#appDescription}} +{{{appDescription}}} +{{/appDescription}} + +Generated from an OpenAPI document by **openapi-generator** (`cpp-boost-beast-server`). + +## Requirements + +- C++17 compiler +- CMake ≥ 3.14 +- Boost ≥ 1.81 (headers, `json`, and URL — Beast/Asio are header-only) + +## Layout + +- `api/` — generated service interfaces, typed request structs, per-operation + responders, and route registration (`Api::attach`) +- `model/` — generated model types plus the shared OAS 3.1 exact-validation + runtime (`Oas31*`) and schema registry +- `server/` — HTTP/1.1 runtime: `HttpServer`, `Router`, `Responder`, + `Problem` (RFC 9457), `Authorizer`, parameter codecs, JSON body conversion + +## Building + +```sh +cmake -S . -B build +cmake --build build +``` + +Warnings are errors by default (GCC/Clang `-Wall -Wextra -Werror`, MSVC +`/W4`); configure with `-DCPP_BOOST_BEAST_SERVER_WERROR=OFF` to relax this. + +## Using + +Each API class declares its per-operation contract types (the Request struct +and the Responder) as nested types. Implement the interface — the nested types +resolve unqualified inside the derived class — and attach it: + +{{#apiInfo}}{{#apis}}{{#-first}}{{#operations}} +```cpp +namespace api = {{apiNamespace}}; +namespace model = {{modelNamespace}}; + +class My{{classname}} : public api::{{classname}} { +{{#operation}}{{#-first}} + void {{nickname}}({{vendorExtensions.x-server-operation-pascal}}Request request, + std::shared_ptr context, + {{vendorExtensions.x-server-operation-pascal}}Responder responder) override { + (void)context; // heap-owned; keep the shared_ptr to read it later +{{#vendorExtensions.x-server-responses}}{{#-first}} +{{#hasModel}} + {{{readmeSendType}}} value{}; +{{#isDefault}} + responder.{{sendMethod}}(std::move(value), 200); +{{/isDefault}} +{{^isDefault}} + responder.{{sendMethod}}(std::move(value)); +{{/isDefault}} +{{/hasModel}} +{{^hasModel}} +{{#isDefault}} + responder.sendDefault(200); +{{/isDefault}} +{{^isDefault}} + responder.{{sendMethod}}(); +{{/isDefault}} +{{/hasModel}} +{{/-first}}{{/vendorExtensions.x-server-responses}} + } +{{/-first}}{{/operation}} +}; +``` + +Define one implementation per API class the same way. +{{/operations}}{{/-first}}{{/apis}}{{/apiInfo}} + +```cpp +int main() { + boost::asio::io_context ioc; + auto router = std::make_shared(); + api::ServerOptions options; + options.authorizer = std::make_shared(); + // HttpServer routes its own lifecycle through enable_shared_from_this, so + // it can only be built through create() (which also rejects a null router). + auto server = api::HttpServer::create(ioc, router, options); +{{#apiInfo}}{{#apis}}{{#operations}} + api::{{classname}}::attach(*server, std::make_shared()); +{{/operations}}{{/apis}}{{/apiInfo}} + server->listen(boost::asio::ip::tcp::endpoint{ + boost::asio::ip::make_address("0.0.0.0"), 8080}); + ioc.run(); +} +``` + +`ServerOptions` tuning: + +| Option | Default | Behavior | +| --- | --- | --- | +| `readTimeoutSeconds` | 30 | Read deadline per request AND response-write deadline. Re-armed when each response is written, so handlers may complete asynchronously (e.g. from a worker thread) long after the read deadline expired. | +| `bodyLimitBytes` | 8 MiB | Request bodies larger than this are rejected with 413 and the connection is closed (the unread remainder cannot be skipped safely on a keep-alive stream). | +| `authorizer` | none | Deny-by-default gate for declared security requirements (see below). | + +Requests are fully decoded and schema-validated before the service method +runs; serialization failures, malformed input, unknown routes (404), wrong +methods (405 + `Allow`), unsupported media types (415), oversized bodies +(413), and security denials (401) produce RFC 9457 `application/problem+json` +responses without application code. An absent optional query/header/cookie +parameter reaches the service as its declared OpenAPI `default` (or the type +zero value when none is declared); an optional request body is decoded only +when bytes arrive. + +HTTP/1.1 requests must carry exactly one syntactically valid `Host` field; +HTTP/1.0 keeps `Host` optional. Both origin-form and absolute-form HTTP(S) +targets are accepted (the absolute URI authority is authoritative), then +normalized to origin-form before routing. `HEAD` handlers produce the same +status and headers, including the GET-equivalent `Content-Length`, but never +write response body bytes. + +With `addApiImplStubs=true` a `main.cpp` and stub services (501 responses) +are generated for quick start. + +## Validation semantics + +Parameter and body validation follows these documented rules: + +- **String length** (`minLength` / `maxLength`) counts Unicode code points, + not bytes, matching JSON Schema, on both surfaces. Malformed UTF-8 + degrades to a byte count (a value the schema could not have produced), + which only shifts the length upward and never lets `maxLength` pass. +- **`pattern`** is evaluated UNANCHORED (`std::regex_search`), per JSON + Schema, but its grammar surface differs by place: + - *Parameters* (path/query/header/cookie): `std::regex` in its ECMAScript + grammar over the UTF-8 BYTES, so `\w`, `[^…]`, and `.` treat a + multi-byte character as its constituent bytes. + - *Request/response bodies* (the model validator): the value is decoded to + code points and matched with `std::wregex`, so `.` and character classes + advance one Unicode scalar at a time on platforms with 32-bit `wchar_t` + (macOS/Linux); on Windows (`wchar_t` is 16-bit) the value is encoded as + UTF-16 and `.` matches one UTF-16 code unit, which can be half of a + surrogate pair. + A pattern relying on Unicode word/property semantics (`\p{…}`) is outside + the supported subset on both surfaces and answers 400 rather than matching + approximately; patterns the grammar cannot compile are refused the same + way. Keep ASCII-only patterns unless you intend code-point (`body`) rather + than byte (`parameter`) semantics. +- **Enum reachability**: an enum member is validated only when the + parameter's C++ codec can produce a JSON-equal value for it (a string + member can never match an integer parameter). When no declared member is + reachable, the parameter fails closed with 400 instead of skipping the + check. +- **Absent optional collections** skip `minItems`/`maxItems`/`uniqueItems` + and every item-level check: the constraint applies to the array instance, + which is not on the wire. +- **Numeric grammar** is strict and locale-independent: parameters must be + exact JSON number text (`1.5` yes; `+1.5`, `.5`, `1.`, `1,5`, `0x10`, + leading/trailing space, `inf`, `nan` no). +- **Query form decoding** translates `+` to space. Array style delimiters are + identified on the encoded value before element decoding, so an escaped + comma (`%2C`) remains data inside one form-array element while literal `,` + separates elements. +- **`multipleOf`** uses exact decimal arithmetic over the parameter wire + lexeme for both scalar numbers/integers and array items; it does not apply + a binary floating-point tolerance. +- **Request bodies** are validated against their declared component schema + BEFORE decoding, so an invalid payload answers 400 instead of reaching the + service with silently defaulted fields. The exact-number-preserving parser + is shared with the model path, so numeric lexemes are compared exactly. + Bodies whose schema is inline, a composition union, or nullable are + decode-shape-only (no schema gate) — the generated decode still rejects + structurally wrong payloads. +- **Numeric representability is a whole-payload gate**: a JSON number the + finite-double domain cannot hold (e.g. `1e400`) answers 400 for the whole + body, even if it sits in a member the model ignores. This is deliberate. + Schema validity and model representability are different questions: an + unbounded `number`-typed schema admits `1e400`, but no generated C++ + field can hold it, and the shared exact-JSON parser would otherwise decode + the sanitized placeholder (0) into observable model state. Rejecting the + payload at the boundary is the only way to guarantee no silently corrupted + number reaches the service through either a declared field or preserved + additional properties. +- **Model decoding stays tolerant** (client-compatibility policy): unknown + members are ignored, and the server gate above is what enforces the + schema. Do not rely on the model constructor to reject invalid input. +- **401/404/405 responses never echo** request query strings, and credential + values are not logged. + +## Security + +Declared OpenAPI security requirements are enforced before dispatch: +credentials are extracted per scheme (API keys by location, raw +`Authorization` for HTTP schemes) and handed to your `Authorizer`. Without +an authorizer, secured operations deny by default. Credential values are +never logged. diff --git a/modules/openapi-generator/src/main/resources/cpp-boost-beast-server/api-header.mustache b/modules/openapi-generator/src/main/resources/cpp-boost-beast-server/api-header.mustache new file mode 100644 index 000000000000..2fc460a9fafc --- /dev/null +++ b/modules/openapi-generator/src/main/resources/cpp-boost-beast-server/api-header.mustache @@ -0,0 +1,155 @@ +{{>licenseInfo}} +{{#operations}}/* + * {{classname}}.h + * + * {{description}} + */ + +#ifndef {{apiHeaderGuardPrefix}}_{{classname}}_H_ +#define {{apiHeaderGuardPrefix}}_{{classname}}_H_ + +#include +#include +#include +#include +#include + +{{! Runtime headers are included through the server/ directory: the model/ + directory precedes server/ on the include search path, so an unqualified + #include "Problem.h" from api/ could be hijacked by a model named like + a runtime type. }} +#include "server/HttpServer.h" +#include "server/Problem.h" +#include "server/Responder.h" +#include "server/Router.h" + +{{#imports}}{{{import}}} +{{/imports}} +{{#apiNamespaceDeclarations}} +namespace {{this}} { +{{/apiNamespaceDeclarations}} + + +{{#x-server-has-model-use}}using namespace {{modelNamespace}}; +{{/x-server-has-model-use}} + +/** + * Service interface for {{description}}. Implementations receive fully + * decoded, validated requests and own their response completion. The + * request context is heap-owned: implementations may keep the shared_ptr + * and read the request data after this call returns. + * + * The per-operation contract types are nested inside this class so an + * operation tagged under several groups produces one definition per API + * class instead of duplicate namespace-scope types. + */ +class {{classname}} { +public: +{{#operation}} + // ------------------------------------------------------------------ + + /// Fully decoded request data for {{operationId}}. + struct {{vendorExtensions.x-server-operation-pascal}}Request { + {{#vendorExtensions.x-server-params}} + {{#hasDefaultInit}} + {{{dataType}}} {{cppName}} = {{{defaultInit}}}; + {{/hasDefaultInit}} + {{^hasDefaultInit}} + {{{dataType}}} {{cppName}}{}; + {{/hasDefaultInit}} + {{/vendorExtensions.x-server-params}} + {{#vendorExtensions.x-server-has-request-body}} + // Fully-qualified field type: std::optional wrapper preserved, model + // tokens shadowed by this class's nested contract types or ambiguous + // with runtime types already model-namespace qualified by the assembler. + {{{vendorExtensions.x-server-request-field-type}}} body{}; + {{/vendorExtensions.x-server-has-request-body}} + }; + + /// Single-shot responder for {{operationId}}. Movable, thread-safe value + /// type; the second and later completions are ignored. + class {{vendorExtensions.x-server-operation-pascal}}Responder { + public: + explicit {{vendorExtensions.x-server-operation-pascal}}Responder( + std::shared_ptr<{{{apiNsQualified}}}ResponderCore> core) + : core_(std::move(core)) {} + + {{#vendorExtensions.x-server-responses}} + {{#isDefault}} + {{#hasModel}} + void sendDefault({{{sendType}}} value, unsigned status) const { + core_->sendJson(status, value, "{{{contentType}}}"); + } + {{/hasModel}} + {{^hasModel}} + void sendDefault(unsigned status) const { + core_->sendEmpty(status); + } + {{/hasModel}} + {{/isDefault}} + {{^isDefault}} + {{#hasModel}} + void {{sendMethod}}({{{sendType}}} value) const { + core_->sendJson({{code}}, value, "{{{contentType}}}"); + } + {{/hasModel}} + {{^hasModel}} + void {{sendMethod}}() const { + core_->sendEmpty({{code}}); + } + {{/hasModel}} + {{/isDefault}} + {{/vendorExtensions.x-server-responses}} + + void sendProblem({{{apiNsQualified}}}Problem problem) const { + core_->sendProblem(std::move(problem)); + } + + void sendNotImplemented(std::string const& operationId) const { + core_->sendNotImplemented(operationId); + } + + private: + std::shared_ptr<{{{apiNsQualified}}}ResponderCore> core_; + }; + +{{/operation}} + virtual ~{{classname}}() = default; + +{{#operation}} + virtual void {{nickname}}( + {{vendorExtensions.x-server-operation-pascal}}Request request, + std::shared_ptr<{{{apiNsQualified}}}RequestContext> context, + {{vendorExtensions.x-server-operation-pascal}}Responder responder) = 0; +{{/operation}} + + /// Registers every {{classname}} route on the server. + static void attach({{{apiNsQualified}}}HttpServer& server, std::shared_ptr<{{classname}}> impl); +}; + +{{#addApiImplStubs}} +/** + * Quick-start stub service: every operation answers 501 problem+json. + */ +class {{classname}}Stub : public {{classname}} { +public: +{{#operation}} + void {{nickname}}( + {{vendorExtensions.x-server-operation-pascal}}Request request, + std::shared_ptr<{{{apiNsQualified}}}RequestContext> context, + {{vendorExtensions.x-server-operation-pascal}}Responder responder) override { + (void)request; + (void)context; + responder.sendNotImplemented("{{{vendorExtensions.x-server-operation-id-literal}}}"); + } +{{/operation}} +}; +{{/addApiImplStubs}} + + +{{#apiNamespaceDeclarations}} +} +{{/apiNamespaceDeclarations}} + +#endif // {{apiHeaderGuardPrefix}}_{{classname}}_H_ +{{/operations}} diff --git a/modules/openapi-generator/src/main/resources/cpp-boost-beast-server/api-source.mustache b/modules/openapi-generator/src/main/resources/cpp-boost-beast-server/api-source.mustache new file mode 100644 index 000000000000..46f4ae48235c --- /dev/null +++ b/modules/openapi-generator/src/main/resources/cpp-boost-beast-server/api-source.mustache @@ -0,0 +1,439 @@ +{{>licenseInfo}} +{{#operations}}/* + * {{classname}}.cpp + */ + +#include "{{classname}}.h" + +#include "server/BodyJson.h" +#include "server/ParamCodecs.h" +#include "server/Problem.h" +#include "server/Responder.h" +#include "server/Router.h" +{{#x-server-schema-validation}} +// Body pre-validation evaluates the declared schema IR before decoding. +#include "model/Oas31ExactJson.h" +#include "model/Oas31SchemaRegistry.h" +#include "model/Oas31Validator.h" +{{/x-server-schema-validation}} + +#include +#include +#include +#include +#include +#include +#include + +{{#apiNamespaceDeclarations}} +namespace {{this}} { +{{/apiNamespaceDeclarations}} + +void {{classname}}::attach({{{apiNsQualified}}}HttpServer& server, std::shared_ptr<{{classname}}> impl) { + auto router = server.routerPtr(); + +{{#operation}} + // ------------------------------------------------------------------ + // {{httpMethod}} {{{vendorExtensions.x-server-path-literal}}} ({{{vendorExtensions.x-server-operation-id-literal}}}) + // ------------------------------------------------------------------ + { + {{{apiNsQualified}}}SecurityGroups security; +{{#vendorExtensions.x-server-security-groups}} + { + std::vector<{{{apiNsQualified}}}SchemeRequirement> group; +{{#.}} + // Values are C++-escaped by CppBoostBeastOperationFacts; triple + // braces insert them verbatim (double braces would HTML-escape). + group.push_back({{{apiNsQualified}}}SchemeRequirement{ + "{{{name}}}", "{{{type}}}", "{{{in}}}", "{{{paramName}}}", "{{{httpScheme}}}" }); +{{/.}} + security.push_back(std::move(group)); + } +{{/vendorExtensions.x-server-security-groups}} + + router->add( + "{{httpMethod}}", + "{{{vendorExtensions.x-server-path-literal}}}", + [impl](std::shared_ptr<{{{apiNsQualified}}}RequestContext> ctx, + std::shared_ptr<{{{apiNsQualified}}}ResponderCore> responderCore) { + {{vendorExtensions.x-server-operation-pascal}}Request request; + {{{apiNsQualified}}}Problem problem; + bool invalid = false; + +{{#vendorExtensions.x-server-params}} + // ---- parameter {{{baseNameLiteral}}} ({{in}}, {{style}}) ---- + { +{{#isPath}} +{{^isContainer}} + auto rawSegment = ctx->pathParams.find("{{{baseNameLiteral}}}"); + std::string encoded = + rawSegment != ctx->pathParams.end() ? rawSegment->second : std::string(); +{{#hasScalarConstraints}} bool present = true; +{{/hasScalarConstraints}} + bool malformed = false; + std::string text; +{{#styleSimple}} text = percentDecode(encoded); +{{/styleSimple}} +{{#styleLabel}} malformed = !decodeLabel(encoded, text); + if (!malformed) { + text = percentDecode(text); + } +{{/styleLabel}} +{{#styleMatrix}} malformed = !decodeMatrix(encoded, "{{{baseNameLiteral}}}", text); + if (!malformed) { + text = percentDecode(text); + } +{{/styleMatrix}} + if (malformed) { + problem.withError("{{{baseNameLiteral}}}", "path parameter is not {{style}}-encoded"); + invalid = true; + } else if (text.empty()) { + problem.withError("{{{baseNameLiteral}}}", "path parameter is missing or empty"); + invalid = true; + } else if (!parseScalar(text, request.{{cppName}})) { + problem.withError("{{{baseNameLiteral}}}", "path parameter is not a valid {{{dataType}}}"); + invalid = true; + } +{{>param-constraints}} +{{/isContainer}} +{{#isContainer}} + auto rawSegment = ctx->pathParams.find("{{{baseNameLiteral}}}"); + std::string encoded = + rawSegment != ctx->pathParams.end() ? rawSegment->second : std::string(); + bool malformed = false; +{{#styleLabel}} std::string segmentValue; +{{/styleLabel}}{{#styleMatrix}}{{^explode}} std::string segmentValue; +{{/explode}}{{/styleMatrix}} + std::vector elements; +{{#styleSimple}} elements = splitSimple(encoded); +{{/styleSimple}} +{{#styleLabel}} malformed = !decodeLabel(encoded, segmentValue); +{{#explode}} if (!malformed) { + elements = splitOn(segmentValue, '.'); + } +{{/explode}}{{^explode}} if (!malformed) { + elements = splitSimple(segmentValue); + } +{{/explode}}{{/styleLabel}} +{{#styleMatrix}}{{#explode}} malformed = !splitMatrixExploded(encoded, "{{{baseNameLiteral}}}", elements); +{{/explode}}{{^explode}} malformed = !decodeMatrix(encoded, "{{{baseNameLiteral}}}", segmentValue); + if (!malformed) { + elements = splitSimple(segmentValue); + } +{{/explode}}{{/styleMatrix}} + if (malformed) { + problem.withError("{{{baseNameLiteral}}}", "path parameter is not {{style}}-encoded"); + invalid = true; + } else { + for (std::string& element : elements) { + std::string text = percentDecode(element); + {{{innerType}}} value; + if (!parseScalar(text, value)) { + problem.withError("{{{baseNameLiteral}}}", "path parameter element is not a valid {{{innerType}}}"); + invalid = true; + break; + } +{{#itemHasMultipleOf}} if (!isExactMultipleOf(text, "{{{itemMultipleOf}}}")) { + problem.withError("{{{baseNameLiteral}}}", "path parameter element is not a multiple of {{{itemMultipleOf}}}"); + invalid = true; + break; + } +{{/itemHasMultipleOf}} request.{{cppName}}.push_back(std::move(value)); + } + } +{{#hasContainerConstraints}} bool present = true; +{{/hasContainerConstraints}} +{{>param-container-constraints}} +{{/isContainer}} +{{/isPath}} +{{#isQuery}} +{{^isContainer}} + auto values = ctx->encodedQuery.equal_range("{{{baseNameLiteral}}}"); + bool present = values.first != values.second; + std::string text; + if (!present) { +{{#required}} + problem.withError("{{{baseNameLiteral}}}", "required query parameter is missing"); + invalid = true; +{{/required}} +{{^required}} + // absent optional query parameter keeps its default value +{{/required}} + } else { + text = percentDecodeQuery(values.first->second); + if (!parseScalar(text, request.{{cppName}})) { + problem.withError("{{{baseNameLiteral}}}", "query parameter is not a valid {{{dataType}}}"); + invalid = true; + } + } +{{>param-constraints}} +{{/isContainer}} +{{#isContainer}} +{{^styleDeepObject}} + auto values = ctx->encodedQuery.equal_range("{{{baseNameLiteral}}}"); + bool present = values.first != values.second; + std::vector elements; + if (present) { +{{#styleForm}}{{#explode}} + for (auto it = values.first; it != values.second; ++it) { + elements.push_back(percentDecodeQuery(it->second)); + } +{{/explode}}{{^explode}} + elements = splitQueryParameter(values.first->second, ','); +{{/explode}}{{/styleForm}} +{{#stylePipeDelimited}} + elements = splitQueryParameter(values.first->second, '|'); +{{/stylePipeDelimited}} +{{#styleSpaceDelimited}} + elements = splitQueryParameter(values.first->second, ' '); +{{/styleSpaceDelimited}} + } + if (!present) { +{{#required}} + problem.withError("{{{baseNameLiteral}}}", "required query parameter is missing"); + invalid = true; +{{/required}} +{{^required}} + // absent optional query parameter keeps its default value +{{/required}} + } else { + for (std::string const& decodedElement : elements) { + {{{innerType}}} value; + if (!parseScalar(decodedElement, value)) { + problem.withError("{{{baseNameLiteral}}}", "query parameter element is not a valid {{{innerType}}}"); + invalid = true; + break; + } +{{#itemHasMultipleOf}} if (!isExactMultipleOf(decodedElement, "{{{itemMultipleOf}}}")) { + problem.withError("{{{baseNameLiteral}}}", "query parameter element is not a multiple of {{{itemMultipleOf}}}"); + invalid = true; + break; + } +{{/itemHasMultipleOf}} request.{{cppName}}.push_back(std::move(value)); + } + } +{{>param-container-constraints}} +{{/styleDeepObject}} +{{#styleDeepObject}} + bool present = false; + for (auto const& entry : ctx->encodedQuery) { + std::string const prefix = "{{{baseNameLiteral}}}["; + if (entry.first.size() > prefix.size() + && entry.first.compare(0, prefix.size(), prefix) == 0 + && entry.first.back() == ']') { + present = true; + std::string key = entry.first.substr( + prefix.size(), entry.first.size() - prefix.size() - 1); + request.{{cppName}}[key] = percentDecodeQuery(entry.second); + } + } + if (!present) { +{{#required}} + problem.withError("{{{baseNameLiteral}}}", "required query parameter is missing"); + invalid = true; +{{/required}}{{^required}} + // absent optional query parameter keeps its default value +{{/required}} + } +{{/styleDeepObject}} +{{/isContainer}} +{{/isQuery}} +{{#isHeader}} +{{^isContainer}} + auto values = ctx->headers.equal_range(lowercaseHeaderName("{{{baseNameLiteral}}}")); + bool present = values.first != values.second; + std::string text; + if (!present) { +{{#required}} + problem.withError("{{{baseNameLiteral}}}", "required header parameter is missing"); + invalid = true; +{{/required}} +{{^required}} + // absent optional header parameter keeps its default value +{{/required}} + } else { + text = values.first->second; + if (!parseScalar(text, request.{{cppName}})) { + problem.withError("{{{baseNameLiteral}}}", "header parameter is not a valid {{{dataType}}}"); + invalid = true; + } + } +{{>param-constraints}} +{{/isContainer}} +{{#isContainer}} + auto values = ctx->headers.equal_range(lowercaseHeaderName("{{{baseNameLiteral}}}")); + bool present = values.first != values.second; + if (!present) { +{{#required}} + problem.withError("{{{baseNameLiteral}}}", "required header parameter is missing"); + invalid = true; +{{/required}} + } else { + for (std::string& element : splitSimple(values.first->second)) { + {{{innerType}}} value; + if (!parseScalar(element, value)) { + problem.withError("{{{baseNameLiteral}}}", "header parameter element is not a valid {{{innerType}}}"); + invalid = true; + break; + } +{{#itemHasMultipleOf}} if (!isExactMultipleOf(element, "{{{itemMultipleOf}}}")) { + problem.withError("{{{baseNameLiteral}}}", "header parameter element is not a multiple of {{{itemMultipleOf}}}"); + invalid = true; + break; + } +{{/itemHasMultipleOf}} request.{{cppName}}.push_back(std::move(value)); + } + } +{{>param-container-constraints}} +{{/isContainer}} +{{/isHeader}} +{{#isCookie}} + auto values = ctx->cookies.equal_range("{{{baseNameLiteral}}}"); + bool present = values.first != values.second; + std::string text; + if (!present) { +{{#required}} + problem.withError("{{{baseNameLiteral}}}", "required cookie parameter is missing"); + invalid = true; +{{/required}} +{{^required}} + // absent optional cookie parameter keeps its default value +{{/required}} + } else { + text = values.first->second; + if (!parseScalar(text, request.{{cppName}})) { + problem.withError("{{{baseNameLiteral}}}", "cookie parameter is not a valid {{{dataType}}}"); + invalid = true; + } + } +{{>param-constraints}} +{{/isCookie}} + } +{{/vendorExtensions.x-server-params}} + +{{#vendorExtensions.x-server-has-request-body}} + // ---- request body ({{vendorExtensions.x-server-request-model}}) ---- + { +{{^vendorExtensions.x-server-request-body-required}} + // An optional body decodes only when bytes arrive; an + // empty body leaves the default-constructed value. + bool skipOptionalEmptyBody = ctx->body.empty(); + if (!skipOptionalEmptyBody) { +{{/vendorExtensions.x-server-request-body-required}} + auto contentTypeEntry = ctx->headers.find("content-type"); + std::string contentType = + contentTypeEntry != ctx->headers.end() + ? contentTypeEntry->second : std::string(); + std::size_t semicolon = contentType.find(';'); + if (semicolon != std::string::npos) { + contentType.resize(semicolon); + } + // Media types are case-insensitive (RFC 9110 8.3.1). + for (char& c : contentType) { + c = static_cast(std::tolower( + static_cast(c))); + } + // RFC 9110 5.6.3 allows whitespace around the type/ + // subtype and between parameters; trim it on both sides + // (' ' and HTAB) before comparing. + while (!contentType.empty() + && (contentType.front() == ' ' || contentType.front() == '\t')) { + contentType.erase(contentType.begin()); + } + while (!contentType.empty() + && (contentType.back() == ' ' || contentType.back() == '\t')) { + contentType.pop_back(); + } + static std::vector const kMediaTypes = { + {{{vendorExtensions.x-server-request-media-types}}} }; + bool supported = !contentType.empty() + ? std::find(kMediaTypes.begin(), kMediaTypes.end(), contentType) != kMediaTypes.end() + : kMediaTypes.size() == 1; + if (!supported) { + responderCore->sendProblem({{{apiNsQualified}}}Problem::unsupportedMediaType(contentType)); + return; + } + try { +{{#vendorExtensions.x-server-request-body-schema-id}} + // Validate the raw payload against the declared + // component schema BEFORE decoding. The model decode + // is deliberately tolerant (client-compat policy: + // nulls on non-nullable fields are skipped, unknown + // members ignored); the server gate enforces the + // schema so a sloppy payload cannot reach the service + // with silently defaulted fields. Exact-JSON parsing + // preserves numeric lexemes, so multipleOf and + // magnitude checks see the wire text, and the decode + // inside the scope converts numbers exactly too. + {{{schemaValidationNamespace}}}::ExactJsonValue exactJson = + {{{schemaValidationNamespace}}}::parseExactJson(ctx->body); + {{{schemaValidationNamespace}}}::requireModelConvertibleJson(exactJson); + {{{schemaValidationNamespace}}}::ExactInstanceScope exactScope(exactJson); + {{{schemaValidationNamespace}}}::SchemaIndex const schemaIndex = + {{{schemaValidationNamespace}}}::schemaNodeFor("{{{vendorExtensions.x-server-request-body-schema-id}}}"); + if (schemaIndex == {{{schemaValidationNamespace}}}::kNoSchema) { + throw std::invalid_argument( + "request body schema id is not in the generated registry"); + } + { + {{{schemaValidationNamespace}}}::RawInstance instance(&exactJson.value); + {{{schemaValidationNamespace}}}::ValidationPath validationPath; + {{{schemaValidationNamespace}}}::ValidationContext context; + {{{schemaValidationNamespace}}}::ValidationResult const result = + {{{schemaValidationNamespace}}}::sharedSchemaEvaluator().validate( + schemaIndex, instance, validationPath, context); + if (!result.success) { + std::string message = "request body failed schema validation"; + if (!result.failurePath.empty()) { + message += " at '" + result.failurePath + "'"; + } + if (!result.failureMessage.empty()) { + message += ": " + result.failureMessage; + } + throw std::invalid_argument(message); + } + } + fromJsonLeaf(exactJson.value, request.body); +{{/vendorExtensions.x-server-request-body-schema-id}} +{{^vendorExtensions.x-server-request-body-schema-id}} + fromJsonBody(ctx->body, request.body); +{{/vendorExtensions.x-server-request-body-schema-id}} + } catch (std::invalid_argument const& error) { + {{{apiNsQualified}}}Problem parseProblem = {{{apiNsQualified}}}Problem::badRequest(error.what()); + parseProblem.withError("body", error.what()); + responderCore->sendProblem(std::move(parseProblem)); + return; + } catch (std::length_error const& error) { + // A numeric lexeme beyond the implementation limit is + // a payload problem, not a server fault: answer 400. + {{{apiNsQualified}}}Problem parseProblem = {{{apiNsQualified}}}Problem::badRequest(error.what()); + parseProblem.withError("body", error.what()); + responderCore->sendProblem(std::move(parseProblem)); + return; + } +{{^vendorExtensions.x-server-request-body-required}} + } +{{/vendorExtensions.x-server-request-body-required}} + } +{{/vendorExtensions.x-server-has-request-body}} + + if (invalid) { + problem.status = 400; + problem.title = "Bad Request"; + responderCore->sendProblem(std::move(problem)); + return; + } + + {{vendorExtensions.x-server-operation-pascal}}Responder responder(responderCore); + impl->{{nickname}}(std::move(request), ctx, std::move(responder)); + }, + std::move(security), + "{{{vendorExtensions.x-server-operation-id-literal}}}"); + } +{{/operation}} +} + +{{#apiNamespaceDeclarations}} +} +{{/apiNamespaceDeclarations}} +{{/operations}} diff --git a/modules/openapi-generator/src/main/resources/cpp-boost-beast-server/authorizer-header.mustache b/modules/openapi-generator/src/main/resources/cpp-boost-beast-server/authorizer-header.mustache new file mode 100644 index 000000000000..eeaf8474b34c --- /dev/null +++ b/modules/openapi-generator/src/main/resources/cpp-boost-beast-server/authorizer-header.mustache @@ -0,0 +1,37 @@ +{{>licenseInfo}} +// ============================================================================ +// Authorizer.h - security enforcement seam. The runtime extracts credentials +// from the request per the operation's declared security schemes; verifying +// them is the application's job. Deny by default when no authorizer is set. +// ============================================================================ +#ifndef {{apiHeaderGuardPrefix}}_SERVER_AUTHORIZER_H_ +#define {{apiHeaderGuardPrefix}}_SERVER_AUTHORIZER_H_ + +#include +#include + +namespace {{apiNamespace}} { + +/// Credentials extracted from an incoming request, keyed by scheme name. +struct AuthCredentials { + /// API-key values by declared parameter location and name + /// ("header:X-API-KEY", "query:token", "cookie:session"). + std::map apiKeyValues; + /// Raw Authorization header value (empty when absent). Never logged. + std::string httpAuthorization; +}; + +/// Application-provided authorization decision point. +class Authorizer { +public: + virtual ~Authorizer() = default; + + /// Return true to allow the operation to proceed. Called only after the + /// request structurally satisfied at least one declared security group. + virtual bool authorize(std::string const& operationId, + AuthCredentials const& credentials) = 0; +}; + +} // namespace {{apiNamespace}} + +#endif // {{apiHeaderGuardPrefix}}_SERVER_AUTHORIZER_H_ diff --git a/modules/openapi-generator/src/main/resources/cpp-boost-beast-server/body-json-header.mustache b/modules/openapi-generator/src/main/resources/cpp-boost-beast-server/body-json-header.mustache new file mode 100644 index 000000000000..86148512094b --- /dev/null +++ b/modules/openapi-generator/src/main/resources/cpp-boost-beast-server/body-json-header.mustache @@ -0,0 +1,376 @@ +{{>licenseInfo}} +// ============================================================================ +// BodyJson.h - generic typed body conversion between generated model types +// and JSON bodies. Uses the generated models' toJsonValue/fromJsonValue +// member API, so models, containers, maps, shared_ptrs, and primitives all +// convert without per-operation code. Header-only. +// ============================================================================ +#ifndef {{apiHeaderGuardPrefix}}_SERVER_BODY_JSON_H_ +#define {{apiHeaderGuardPrefix}}_SERVER_BODY_JSON_H_ + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// CompositionBranchValue lives here; the response path unwraps it below. +// The include is directory-qualified so a generated model header cannot +// hijack it through the model/ search path (same policy as api-header). +// ValidationTypes.h carries the numeric decode contract — its +// tryGetMathematicalInteger consults the exact wire lexeme inside a +// handler decode (it pulls Oas31ExactJson.h with it), so integer leaves +// share the model pipeline's judgment verbatim. +#include "model/ValidationTypes.h" + +namespace {{apiNamespace}} { + +template +struct HasToJsonValue : std::false_type {}; +template +struct HasToJsonValue().toJsonValue())>> : std::true_type {}; + +template +struct HasFromJsonValue : std::false_type {}; +template +struct HasFromJsonValue().fromJsonValue(std::declval()))>> + : std::true_type {}; + +// --------------------------------------------------------------------------- +// Serialization: typed value -> JSON body string. +// --------------------------------------------------------------------------- + +inline boost::json::value bodyLeaf(std::string const& value) { + return boost::json::value(value); +} + +inline boost::json::value bodyLeaf(const char* value) { + return boost::json::value(std::string(value == nullptr ? "" : value)); +} + +inline boost::json::value bodyLeaf(bool value) { + return boost::json::value(value); +} + +inline boost::json::value bodyLeaf(std::int32_t value) { + return boost::json::value(value); +} + +inline boost::json::value bodyLeaf(std::int64_t value) { + return boost::json::value(value); +} + +inline boost::json::value bodyLeaf(float value) { + return boost::json::value(static_cast(value)); +} + +inline boost::json::value bodyLeaf(double value) { + return boost::json::value(value); +} + +template +std::enable_if_t::value, boost::json::value> +bodyLeaf(T const& value) { + return value.toJsonValue(); +} + +inline boost::json::value bodyLeaf(boost::json::value value) { + return value; +} +template +boost::json::value bodyLeaf(std::shared_ptr const& value) { + if (!value) return boost::json::value(nullptr); + return bodyLeaf(*value); +} + +template +boost::json::value bodyLeaf(std::vector const& values) { + boost::json::array array; + array.reserve(values.size()); + for (T const& value : values) { + array.push_back(bodyLeaf(value)); + } + return array; +} + +template +boost::json::value bodyLeaf(std::map const& values) { + boost::json::object object; + for (auto const& entry : values) { + object[entry.first] = bodyLeaf(entry.second); + } + return object; +} + +// Composition unions (oneOf/anyOf responses) arrive as std::variant; the +// active branch alone is serializable, so std::visit dispatches to the +// overload set above. monostate (present-null branches) serializes as null; +// std::optional serializes its value or null when disengaged. Duplicate +// C++ branch types are preserved as tagged CompositionBranchValue wrappers +// (model namespace, found by ADL on the dependent call below); unwrapping +// serializes exactly the branch's value. + +template +boost::json::value bodyLeaf( + {{{modelNamespace}}}::CompositionBranchValue< + BranchIndex, ValueType> const& value) { + return bodyLeaf(value.value); +} + +inline boost::json::value bodyLeaf(std::monostate) { + return nullptr; +} + +template +boost::json::value bodyLeaf( + std::variant const& value) { + return std::visit([](auto const& active) { + return bodyLeaf(active); + }, value); +} + +template +boost::json::value bodyLeaf(std::optional const& value) { + if (!value.has_value()) { + return nullptr; + } + return bodyLeaf(*value); +} + +template +std::string toJsonBody(T const& value) { + return boost::json::serialize(bodyLeaf(value)); +} + +// --------------------------------------------------------------------------- +// Deserialization: parsed JSON document -> typed value. Throws +// std::invalid_argument on shape mismatches. +// --------------------------------------------------------------------------- + +inline void fromJsonLeaf(boost::json::value const& json, std::string& out) { + if (!json.is_string()) { + throw std::invalid_argument("expected a JSON string"); + } + out.assign(json.as_string().data(), json.as_string().size()); +} + +inline void fromJsonLeaf(boost::json::value const& json, bool& out) { + if (!json.is_bool()) { + throw std::invalid_argument("expected a JSON boolean"); + } + out = json.as_bool(); +} + +inline void fromJsonLeaf(boost::json::value const& json, std::int32_t& out) { + if (json.is_int64()) { + std::int64_t value = json.as_int64(); + if (value < std::numeric_limits::min() + || value > std::numeric_limits::max()) { + throw std::invalid_argument("integer out of int32 range"); + } + out = static_cast(value); + return; + } + if (json.is_uint64()) { + std::uint64_t value = json.as_uint64(); + if (value > static_cast( + std::numeric_limits::max())) { + throw std::invalid_argument("integer out of int32 range"); + } + out = static_cast(value); + return; + } + // JSON makes no integer/real distinction: 1.0 IS the integer 1 (RFC + // 8259 6.1, JSON Schema type "integer" admits integral reals). Boost + // stores any fraction-carrying token as double, so an integral double + // in range decodes exactly. + if (json.is_double()) { + double value = json.as_double(); + double integral; + if (std::modf(value, &integral) == 0.0 + && value >= static_cast(std::numeric_limits::min()) + && value <= static_cast(std::numeric_limits::max())) { + out = static_cast(integral); + return; + } + } + throw std::invalid_argument("expected a JSON integer"); +} + +inline void fromJsonLeaf(boost::json::value const& json, std::int64_t& out) { + // One judgment with the model pipeline: integral kinds convert directly; + // a double converts while it is a trustworthy image (|value| <= 2^53) + // or while the handler's ExactInstanceScope still holds the wire lexeme + // (9007199254740993.0 names an integer exactly from its text); past the + // window with no lexeme it fails closed — 400, never a corrupted int64. + if (!{{{modelNamespace}}}::tryGetMathematicalInteger(json, out)) { + throw std::invalid_argument("expected a JSON integer"); + } +} + +inline void fromJsonLeaf(boost::json::value const& json, float& out) { + if (json.is_double()) { + out = static_cast(json.as_double()); + return; + } + if (json.is_int64()) { + out = static_cast(json.as_int64()); + return; + } + if (json.is_uint64()) { + // as_uint64 + cast: to_number throws (not system_error- + // catchable as invalid_argument) for values above INT64_MAX. + out = static_cast(json.as_uint64()); + return; + } + throw std::invalid_argument("expected a JSON number"); +} + +inline void fromJsonLeaf(boost::json::value const& json, double& out) { + if (json.is_double()) { + out = json.as_double(); + return; + } + if (json.is_int64()) { + out = static_cast(json.as_int64()); + return; + } + if (json.is_uint64()) { + out = static_cast(json.as_uint64()); + return; + } + throw std::invalid_argument("expected a JSON number"); +} + +template +std::enable_if_t::value, void> +fromJsonLeaf(boost::json::value const& json, T& out) { + out.fromJsonValue(json); +} + +inline void fromJsonLeaf(boost::json::value const& json, boost::json::value& out) { + out = json; +} + + +template +void fromJsonLeaf(boost::json::value const& json, std::shared_ptr& out) { + if (json.is_null()) { + out.reset(); + return; + } + if (!out) { + out = std::make_shared(); + } + fromJsonLeaf(json, *out); +} + +template +void fromJsonLeaf(boost::json::value const& json, std::vector& out) { + if (!json.is_array()) { + throw std::invalid_argument("expected a JSON array"); + } + out.clear(); + out.reserve(json.as_array().size()); + for (boost::json::value const& element : json.as_array()) { + T value; + fromJsonLeaf(element, value); + out.push_back(std::move(value)); + } +} + +template +void fromJsonLeaf(boost::json::value const& json, std::map& out) { + if (!json.is_object()) { + throw std::invalid_argument("expected a JSON object"); + } + out.clear(); + for (auto const& member : json.as_object()) { + T value; + fromJsonLeaf(member.value(), value); + out.emplace(std::string(member.key().data(), member.key().size()), + std::move(value)); + } +} + +inline void fromJsonLeaf(boost::json::value const& json, std::nullptr_t& out) { + if (!json.is_null()) { + throw std::invalid_argument("expected JSON null"); + } + out = nullptr; +} + +// std::optional decodes null as "absent" and any other value as "present". +// This collapses the JSON null-vs-missing distinction for body members; +// models needing the difference use NullableField, which decodes both. +template +void fromJsonLeaf(boost::json::value const& json, std::optional& out) { + if (json.is_null()) { + out.reset(); + return; + } + T value; + fromJsonLeaf(json, value); + out = std::move(value); +} + +// Composition unions (oneOf/anyOf bodies) decode structurally: branches are +// tried in declaration order and the first that parses wins. monostate +// matches JSON null. When no branch matches, the value is rejected. A union +// with structurally overlapping branches (e.g. two plain objects) is +// inherently ambiguous; the first orderally-matching branch wins +// deterministically. +template +bool decodeVariantAlternative(boost::json::value const& json, Union& out) { + if constexpr (Index < std::variant_size_v) { + using Alternative = std::variant_alternative_t; + if constexpr (std::is_same_v) { + if (json.is_null()) { + out = Alternative{}; + return true; + } + } else { + Alternative candidate; + try { + fromJsonLeaf(json, candidate); + out = std::move(candidate); + return true; + } catch (std::exception const&) { + // branch mismatch: try the next one + } + } + return decodeVariantAlternative(json, out); + } + return false; +} + +template +void fromJsonLeaf(boost::json::value const& json, std::variant& out) { + if (!decodeVariantAlternative<>(json, out)) { + throw std::invalid_argument("JSON value matches no union branch"); + } +} + +/// Parses a JSON request body and decodes it into a typed value. +/// Throws std::invalid_argument on malformed JSON or shape mismatch. +template +void fromJsonBody(std::string const& body, T& out) { + boost::system::error_code error; + boost::json::value parsed = boost::json::parse(body, error); + if (error) { + throw std::invalid_argument("malformed JSON body: " + error.message()); + } + fromJsonLeaf(parsed, out); +} + +} // namespace {{apiNamespace}} + +#endif // {{apiHeaderGuardPrefix}}_SERVER_BODY_JSON_H_ diff --git a/modules/openapi-generator/src/main/resources/cpp-boost-beast-server/http-server-header.mustache b/modules/openapi-generator/src/main/resources/cpp-boost-beast-server/http-server-header.mustache new file mode 100644 index 000000000000..51287d57f091 --- /dev/null +++ b/modules/openapi-generator/src/main/resources/cpp-boost-beast-server/http-server-header.mustache @@ -0,0 +1,87 @@ +{{>licenseInfo}} +// ============================================================================ +// HttpServer.h - asynchronous HTTP/1.1 listener and connection sessions on +// Boost.Beast + Boost.Asio. The caller owns the io_context; the server owns +// its acceptor and stops gracefully on demand. +// ============================================================================ +#ifndef {{apiHeaderGuardPrefix}}_SERVER_HTTP_SERVER_H_ +#define {{apiHeaderGuardPrefix}}_SERVER_HTTP_SERVER_H_ + +#include "Authorizer.h" +#include "Router.h" + +#include +#include + +#include +#include +#include +#include +#include + +namespace {{apiNamespace}} { + +struct ServerOptions { + unsigned readTimeoutSeconds = 30; + std::size_t bodyLimitBytes = 8u * 1024 * 1024; + std::shared_ptr authorizer; +}; + +/// Shared-ownership server: listen()/stop()/acceptNext() route through +/// enable_shared_from_this, so instances must come from create(). The +/// constructor is private to make stack allocation (which would throw +/// bad_weak_ptr on first use) impossible, and copies/moves are deleted +/// because a duplicated handle would double-drive one acceptor. +class HttpServer : public std::enable_shared_from_this { +public: + /// Builds a server bound to no address yet. Throws std::invalid_argument + /// when `router` is null: the router() accessor dereferences it and the + /// accept loop would answer nothing, so a null router is rejected at + /// construction instead of failing per request. + static std::shared_ptr create( + boost::asio::io_context& ioc, + std::shared_ptr router, + ServerOptions options = {}) { + if (router == nullptr) { + throw std::invalid_argument( + "HttpServer requires a non-null router"); + } + return std::shared_ptr( + new HttpServer(ioc, std::move(router), std::move(options))); + } + + HttpServer(HttpServer const&) = delete; + HttpServer& operator=(HttpServer const&) = delete; + HttpServer(HttpServer&&) = delete; + HttpServer& operator=(HttpServer&&) = delete; + + /// Opens, binds (SO_REUSEADDR), and listens. Throws on failure. + void listen(boost::asio::ip::tcp::endpoint endpoint); + + /// The bound endpoint (useful after listening on port 0). + boost::asio::ip::tcp::endpoint localEndpoint() const; + + /// Graceful stop: closes the acceptor. Open sessions finish their + /// current exchange or terminate with the io_context. + void stop(); + + Router& router() { return *router_; } + std::shared_ptr routerPtr() const { return router_; } + +private: + HttpServer(boost::asio::io_context& ioc, + std::shared_ptr router, + ServerOptions options); + + struct ListenerState; + void acceptNext(); + + boost::asio::io_context& ioc_; + std::shared_ptr router_; + ServerOptions options_; + std::shared_ptr listener_; +}; + +} // namespace {{apiNamespace}} + +#endif // {{apiHeaderGuardPrefix}}_SERVER_HTTP_SERVER_H_ diff --git a/modules/openapi-generator/src/main/resources/cpp-boost-beast-server/http-server-source.mustache b/modules/openapi-generator/src/main/resources/cpp-boost-beast-server/http-server-source.mustache new file mode 100644 index 000000000000..0801467755e3 --- /dev/null +++ b/modules/openapi-generator/src/main/resources/cpp-boost-beast-server/http-server-source.mustache @@ -0,0 +1,661 @@ +{{>licenseInfo}} +// ============================================================================ +// HttpServer.cpp - listener, session lifecycle, request decoding, security +// enforcement, and response writing. This is the single translation unit +// that compiles Boost.URL (header-only mode). +// ============================================================================ +#include "HttpServer.h" +#include "ParamCodecs.h" +#include "Problem.h" +#include "Responder.h" + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace {{apiNamespace}} { + +namespace { + +namespace http = boost::beast::http; +namespace net = boost::asio; +using tcp = boost::asio::ip::tcp; + +void failLog(boost::beast::error_code ec, char const* what) { + std::cerr << "cpp-boost-beast-server " << what << ": " << ec.message() << "\n"; +} + +std::string lowercased(std::string text) { + std::transform(text.begin(), text.end(), text.begin(), + [](unsigned char c) { + return static_cast(std::tolower(c)); + }); + return text; +} + +bool validHostField(std::string_view value) { + auto parsed = boost::urls::parse_authority( + boost::core::string_view(value.data(), value.size())); + return parsed.has_value() + && !parsed->has_userinfo() + && !parsed->encoded_host().empty(); +} + +/// Extracts credentials per the route's declared schemes. +AuthCredentials collectCredentials( + RequestContext const& ctx, SecurityGroups const& groups) { + AuthCredentials credentials; + for (std::vector const& group : groups) { + for (SchemeRequirement const& scheme : group) { + if (scheme.type == "apiKey") { + std::string key = scheme.in + ":" + scheme.paramName; + if (credentials.apiKeyValues.count(key) != 0) { + continue; + } + if (scheme.in == "header") { + auto range = ctx.headers.equal_range( + lowercased(scheme.paramName)); + for (auto it = range.first; it != range.second; ++it) { + if (!it->second.empty()) { + credentials.apiKeyValues.emplace(key, it->second); + break; + } + } + } else if (scheme.in == "query") { + auto range = ctx.query.equal_range(scheme.paramName); + for (auto it = range.first; it != range.second; ++it) { + if (!it->second.empty()) { + credentials.apiKeyValues.emplace(key, it->second); + break; + } + } + } else if (scheme.in == "cookie") { + auto range = ctx.cookies.equal_range(scheme.paramName); + for (auto it = range.first; it != range.second; ++it) { + if (!it->second.empty()) { + credentials.apiKeyValues.emplace(key, it->second); + break; + } + } + } + } else if (scheme.type == "http") { + auto range = ctx.headers.equal_range("authorization"); + for (auto it = range.first; it != range.second; ++it) { + if (!it->second.empty()) { + credentials.httpAuthorization = it->second; + break; + } + } + } + } + } + return credentials; +} + +/// True when at least one OR-alternative is structurally satisfied. +bool structurallySatisfied( + SecurityGroups const& groups, AuthCredentials const& credentials) { + for (std::vector const& group : groups) { + if (group.empty()) { + return true; // anonymous alternative + } + bool allPresent = true; + for (SchemeRequirement const& scheme : group) { + if (scheme.type == "apiKey") { + if (credentials.apiKeyValues.count( + scheme.in + ":" + scheme.paramName) == 0) { + allPresent = false; + break; + } + } else if (scheme.type == "http") { + if (credentials.httpAuthorization.empty()) { + allPresent = false; + break; + } + } else { + allPresent = false; + break; + } + } + if (allPresent) { + return true; + } + } + return false; +} + +// --------------------------------------------------------------------------- +// session - one HTTP/1.1 connection. +// --------------------------------------------------------------------------- +class session : public std::enable_shared_from_this { +public: + session(tcp::socket&& socket, + std::shared_ptr router, + ServerOptions options) + : stream_(std::move(socket)) + , router_(std::move(router)) + , options_(std::move(options)) {} + + void run() { + net::dispatch(stream_.get_executor(), + boost::beast::bind_front_handler( + &session::do_read, shared_from_this())); + } + +private: + void do_read() { + stream_.expires_after( + std::chrono::seconds(options_.readTimeoutSeconds)); + parser_.emplace(); + requestIsHead_ = false; + parser_->body_limit(options_.bodyLimitBytes); + // Read the head first: a client that sent Expect: 100-continue holds + // the body back until the interim response arrives, so a combined + // head+body read would deadlock against the read timeout. Head-level + // errors (400/431) also get answered before any body byte is spent. + http::async_read_header( + stream_, buffer_, *parser_, + boost::beast::bind_front_handler( + &session::on_read_header, shared_from_this())); + } + + void on_read_header(boost::beast::error_code ec, std::size_t) { + if (ec == http::error::end_of_stream) { + return do_close(); + } + if (ec == http::error::header_limit) { + // The head overflowed its limits; nothing usable was parsed. + // Answer 431 and close (the remainder cannot be skipped safely). + forceClose_ = true; + return send_response( + toProblemResponse(Problem::requestHeaderFieldsTooLarge())); + } + if (ec == http::error::body_limit) { + // The declared Content-Length (or chunked stream) exceeds the + // configured body limit; the parser refuses it while finishing + // the head, before any body byte is spent. The head itself was + // parsed, so mirror its version/keep-alive (RFC 9110 6.7), then + // answer 413 and close: the client's unsent body bytes cannot + // be skipped safely on a keep-alive stream. + requestVersion_ = static_cast(parser_->get().version()); + requestKeepAlive_ = parser_->get().keep_alive(); + requestIsHead_ = parser_->get().method() == http::verb::head; + forceClose_ = true; + return send_response( + toProblemResponse(Problem::payloadTooLarge())); + } + if (ec) { + // HTTP parser errors (bad Content-Length/Transfer-Encoding, + // malformed request line) live in the beast.http category and + // answer 400 before closing. Timeouts and transport failures + // carry no usable framing; log those quietly and drop. The + // category is probed through a representative error code — + // Beast exposes no error_category() accessor for http::error. + static boost::beast::error_code const httpSample = + http::make_error_code(http::error::end_of_stream); + if (ec.category() == httpSample.category()) { + forceClose_ = true; + return send_response(toProblemResponse( + Problem::badRequest("malformed HTTP request: " + + ec.message()))); + } + return failLog(ec, "read header"); + } + requestVersion_ = static_cast(parser_->get().version()); + requestKeepAlive_ = parser_->get().keep_alive(); + forceClose_ = false; + auto const& head = parser_->get(); + requestIsHead_ = head.method() == http::verb::head; + if (head.version() >= 11) { + auto const hosts = head.equal_range(http::field::host); + std::size_t hostCount = 0; + std::string_view hostValue; + for (auto it = hosts.first; it != hosts.second; ++it) { + ++hostCount; + hostValue = std::string_view( + it->value().data(), it->value().size()); + } + if (hostCount != 1 || !validHostField(hostValue)) { + forceClose_ = true; + return send_response(toProblemResponse( + Problem::badRequest("HTTP/1.1 requires exactly one valid Host field"))); + } + } + // Expect handling (RFC 9110 10.1.1) only matters when a body + // follows (Content-Length or chunked); a bodiless request must not + // be made to wait. count() rather than contains(): contains() + // exists only since Boost 1.85 and the Linux CI image ships an + // older Beast. The expectations form a comma-separated list, and + // RFC 9110 5.3 additionally allows the list across repeated field + // lines — every Expect line is tokenized, not just the first. The + // interim response is sent only when every token is 100-continue; + // an unrecognized expectation the server cannot verify answers 417 + // (and closes, since the unread body bytes cannot be skipped on a + // keep-alive stream). + if (head.version() >= 11) { + bool sawContinue = false; + bool sawUnsupported = false; + auto const expectRange = head.equal_range(http::field::expect); + for (auto it = expectRange.first; it != expectRange.second; ++it) { + std::string_view const expectField = it->value(); + std::size_t tokenStart = 0; + while (tokenStart <= expectField.size()) { + std::size_t const comma = expectField.find(',', tokenStart); + std::size_t const tokenEnd = + comma == std::string_view::npos ? expectField.size() : comma; + std::size_t beg = tokenStart; + std::size_t end = tokenEnd; + while (beg < end && (expectField[beg] == ' ' || expectField[beg] == '\t')) { + ++beg; + } + while (end > beg && (expectField[end - 1] == ' ' || expectField[end - 1] == '\t')) { + --end; + } + if (beg != end) { // an empty element (trailing comma) is slack + std::string token(expectField.substr(beg, end - beg)); + for (char& ch : token) { + ch = static_cast(std::tolower(static_cast(ch))); + } + if (token == "100-continue") { + sawContinue = true; + } else { + sawUnsupported = true; + } + } + if (comma == std::string_view::npos) { + break; + } + tokenStart = comma + 1; + } + } + bool mayHaveBody = head.count(http::field::content_length) > 0 + || head.chunked(); + if (sawUnsupported) { + forceClose_ = true; + return send_response( + toProblemResponse(Problem::expectationFailed())); + } + if (sawContinue && mayHaveBody) { + // Send the interim response now so the client releases the + // body. An oversized declared length never reaches here — + // the parser already answered 413 at header finish above. + // http::async_write serializes the message lazily and the + // composed operation references it until the handler runs, + // so the interim response must be heap-owned and captured, + // exactly like send_response's message_generator. A stack + // local here would dangle the moment this returns. + auto interim = std::make_shared>( + http::status::continue_, 11); + interim->set(http::field::server, + "openapi-generator-cpp-boost-beast-server"); + http::async_write( + stream_, *interim, + [self = shared_from_this(), interim]( + boost::beast::error_code writeEc, std::size_t) { + self->on_interim_sent(writeEc); + }); + return; + } + } + read_body(); + } + + void on_interim_sent(boost::beast::error_code ec) { + if (ec) { + return failLog(ec, "write 100-continue"); + } + read_body(); + } + + void read_body() { + http::async_read( + stream_, buffer_, *parser_, + boost::beast::bind_front_handler( + &session::on_read_body, shared_from_this())); + } + + void on_read_body(boost::beast::error_code ec, std::size_t) { + if (ec == http::error::end_of_stream) { + return do_close(); + } + if (ec == http::error::body_limit) { + // The unread remainder of the oversized body cannot be skipped + // safely: answer 413 and close the connection. The parser head + // was fully read before the body overflowed, so mirror its + // version/keep-alive (RFC 9110 6.7) before responding. + forceClose_ = true; + return send_response( + toProblemResponse(Problem::payloadTooLarge())); + } + if (ec) { + static boost::beast::error_code const httpSample = + http::make_error_code(http::error::end_of_stream); + if (ec.category() == httpSample.category()) { + forceClose_ = true; + return send_response(toProblemResponse( + Problem::badRequest("malformed HTTP request: " + + ec.message()))); + } + return failLog(ec, "read body"); + } + handle_request(parser_->release()); + } + + void handle_request(http::request&& request) { + // The context is heap-owned and shared with the handler: responders + // may legitimately complete from a worker thread after this function + // returns, and a service implementation that defers work must be able + // to read the request data it was handed. + auto ctx = std::make_shared(); + ctx->method = std::string(request.method_string()); + ctx->body = request.body(); + + // RFC 9112 requires servers to accept both origin-form and + // absolute-form. Normalize an absolute HTTP(S) target to origin-form + // before routing; its authority, not Host, is authoritative, though + // HTTP/1.1 still requires one syntactically valid Host field. + auto parsedOrigin = boost::urls::parse_origin_form(request.target()); + if (parsedOrigin.has_value()) { + ctx->target.assign(request.target().data(), request.target().size()); + } else { + auto absolute = boost::urls::parse_absolute_uri(request.target()); + if (!absolute.has_value() + || (absolute->scheme_id() != boost::urls::scheme::http + && absolute->scheme_id() != boost::urls::scheme::https) + || !absolute->has_authority() + || absolute->authority().has_userinfo() + || absolute->encoded_host().empty()) { + return send_response(toProblemResponse( + Problem::badRequest("malformed request target"))); + } + auto path = absolute->encoded_path(); + if (path.empty()) { + ctx->target = "/"; + } else { + ctx->target.assign(path.data(), path.size()); + } + if (absolute->has_query()) { + auto query = absolute->encoded_query(); + ctx->target.push_back('?'); + ctx->target.append(query.data(), query.size()); + } + } + + auto parsedUrl = boost::urls::parse_origin_form(ctx->target); + if (!parsedUrl.has_value()) { + return send_response(toProblemResponse( + Problem::badRequest("malformed request target"))); + } + for (auto const& param : parsedUrl->encoded_params()) { + std::string const encodedKey(param.key.data(), param.key.size()); + std::string const encodedValue(param.value.data(), param.value.size()); + std::string key = percentDecodeQuery(encodedKey); + ctx->encodedQuery.emplace(key, encodedValue); + ctx->query.emplace(std::move(key), percentDecodeQuery(encodedValue)); + } + + for (auto const& field : request) { + std::string name = lowercased(std::string(field.name_string())); + std::string value(field.value().data(), field.value().size()); + if (name == "cookie") { + parseCookieHeader(value, ctx->cookies); + } + ctx->headers.emplace(std::move(name), std::move(value)); + } + + RouteMatch match = router_->match(ctx->method, ctx->target); + if (!match.handler) { + std::string allowed = router_->allowedMethods(ctx->target); + if (!allowed.empty()) { + http::response res = + toProblemResponse(Problem::methodNotAllowed(allowed)); + res.set(http::field::allow, allowed); + return send_response(std::move(res)); + } + // Echo the path WITHOUT the query string: a credential smuggled + // into an unknown route's query (api_key=...) would otherwise be + // reflected verbatim into the problem document's detail and + // instance, leaking it to logs and any intermediary. + std::string echoedTarget(ctx->target); + if (auto question = echoedTarget.find('?'); + question != std::string::npos) { + echoedTarget.resize(question); + } + if (auto hash = echoedTarget.find('#'); + hash != std::string::npos) { + echoedTarget.resize(hash); + } + return send_response(toProblemResponse( + Problem::notFound(echoedTarget))); + } + + ctx->pathParams = match.pathParams; + ctx->operationId = match.operationId; + + auto responder = std::make_shared( + [self = shared_from_this()]( + http::response&& response) { + net::post(self->stream_.get_executor(), + [self, response = std::move(response)]() mutable { + self->send_response(std::move(response)); + }); + }); + responder->setOperationId(ctx->operationId); + + try { + // Skip the security gate entirely when any declared + // OR-alternative is anonymous (empty group, including + // `security: []`): anonymous access is a valid alternative and + // needs no credentials or authorizer. Authorization runs inside + // the same exception boundary as dispatch: an application + // authorizer that throws must answer 500, not unwind ioc.run(). + bool anonymousAllowed = false; + for (std::vector const& group : match.security) { + if (group.empty()) { + anonymousAllowed = true; + break; + } + } + if (!match.security.empty() && !anonymousAllowed) { + AuthCredentials credentials = + collectCredentials(*ctx, match.security); + bool allowed = structurallySatisfied(match.security, credentials) + && options_.authorizer + && options_.authorizer->authorize(ctx->operationId, credentials); + if (!allowed) { + http::response res = + toProblemResponse(Problem::unauthorized()); + // RFC 9110 11.6.1: 401 MUST carry a WWW-Authenticate + // challenge. http-scheme requirements yield the scheme + // name; API keys have no standardized challenge form. + for (std::vector const& group : match.security) { + for (SchemeRequirement const& scheme : group) { + if (scheme.type == "http" && !scheme.httpScheme.empty()) { + res.set(http::field::www_authenticate, + scheme.httpScheme + " realm=\"api\""); + break; + } + } + if (res.count(http::field::www_authenticate) != 0) { + break; + } + } + return send_response(std::move(res)); + } + } + match.handler(ctx, responder); + } catch (std::exception const& error) { + std::cerr << "cpp-boost-beast-server: handler exception for " + << ctx->operationId << ": " << error.what() << "\n"; + // Route through the core so the single-completion guard applies + // even when the handler had already queued an async completion. + responder->sendProblem(Problem::internal()); + } + } + + + void send_response(http::response&& response) { + // Arm a fresh response deadline before writing. Handlers may complete + // asynchronously (after the read timer has already expired); an + // expired Beast stream timer would abort this async_write instantly + // with operation_aborted and no response would ever reach the client. + stream_.expires_after( + std::chrono::seconds(options_.readTimeoutSeconds)); + // Mirror the request's HTTP version and keep-alive preference + // (RFC 9110 6.7): responses must not keep a 1.0 connection alive + // that did not opt in, and version should not exceed the request's. + response.version(requestVersion_); + bool keepAlive = forceClose_ ? false : requestKeepAlive_; + response.keep_alive(keepAlive); + if (requestIsHead_) { + // Keep the GET-equivalent headers (including Content-Length), but + // use empty_body so Beast's writer has no body octets to frame. + http::response headResponse( + std::move(response.base())); + return write_response(std::move(headResponse), keepAlive); + } + write_response(std::move(response), keepAlive); + } + + template + void write_response(http::response&& response, bool keepAlive) { + auto message = std::make_shared( + std::move(response)); + boost::beast::async_write( + stream_, std::move(*message), + [self = shared_from_this(), message, keepAlive]( + boost::beast::error_code ec, std::size_t) { + self->on_write(keepAlive, message, ec, 0); + }); + } + + void on_write(bool keepAlive, + std::shared_ptr message, + boost::beast::error_code ec, + std::size_t) { + boost::ignore_unused(message); + if (ec) { + return failLog(ec, "write"); + } + if (!keepAlive) { + return do_close(); + } + do_read(); + } + + void do_close() { + boost::beast::error_code ec; + stream_.socket().shutdown(tcp::socket::shutdown_send, ec); + } + + unsigned requestVersion_ = 11; + bool requestKeepAlive_ = false; + bool requestIsHead_ = false; + bool forceClose_ = false; + boost::beast::tcp_stream stream_; + boost::beast::flat_buffer buffer_; + std::shared_ptr router_; + ServerOptions options_; + std::optional> parser_; +}; + +} // namespace + +// --------------------------------------------------------------------------- +// HttpServer +// --------------------------------------------------------------------------- + +struct HttpServer::ListenerState { + tcp::acceptor acceptor; + + explicit ListenerState(net::io_context& ioc) + : acceptor(net::make_strand(ioc)) {} +}; + +HttpServer::HttpServer(net::io_context& ioc, + std::shared_ptr router, + ServerOptions options) + : ioc_(ioc) + , router_(std::move(router)) + , options_(std::move(options)) + , listener_(std::make_shared(ioc)) {} + +void HttpServer::listen(tcp::endpoint endpoint) { + boost::beast::error_code ec; + auto& acceptor = listener_->acceptor; + acceptor.open(endpoint.protocol(), ec); + if (ec) { + throw std::runtime_error("open: " + ec.message()); + } + acceptor.set_option(net::socket_base::reuse_address(true), ec); + if (ec) { + throw std::runtime_error("set_option: " + ec.message()); + } + acceptor.bind(endpoint, ec); + if (ec) { + throw std::runtime_error("bind: " + ec.message()); + } + acceptor.listen(net::socket_base::max_listen_connections, ec); + if (ec) { + throw std::runtime_error("listen: " + ec.message()); + } + acceptNext(); +} + +tcp::endpoint HttpServer::localEndpoint() const { + return listener_->acceptor.local_endpoint(); +} + +void HttpServer::stop() { + // The acceptor lives on a strand with a pending async_accept: close it + // there so stop() is safe to call from any thread. + net::post(listener_->acceptor.get_executor(), + [self = shared_from_this()] { + boost::beast::error_code ec; + self->listener_->acceptor.close(ec); + }); +} + +void HttpServer::acceptNext() { + auto self = shared_from_this(); + listener_->acceptor.async_accept( + net::make_strand(ioc_), + [self](boost::beast::error_code ec, tcp::socket socket) { + if (ec == net::error::operation_aborted) { + return; // the acceptor was closed by stop() + } + if (ec) { + // Transient OS errors (EMFILE, connection_aborted, ...) must + // not permanently stop the listener: log and retry after a + // short backoff. The retry runs on the acceptor's own + // executor and checks is_open(), so stop() ends the loop. + failLog(ec, "accept"); + auto timer = std::make_shared( + self->listener_->acceptor.get_executor()); + timer->expires_after(std::chrono::seconds(1)); + timer->async_wait([self, timer](boost::beast::error_code waitEc) { + if (waitEc == net::error::operation_aborted + || !self->listener_->acceptor.is_open()) { + return; // server stopped + } + self->acceptNext(); + }); + return; + } + std::make_shared( + std::move(socket), self->router_, self->options_)->run(); + self->acceptNext(); + }); +} + +} // namespace {{apiNamespace}} diff --git a/modules/openapi-generator/src/main/resources/cpp-boost-beast-server/licenseInfo.mustache b/modules/openapi-generator/src/main/resources/cpp-boost-beast-server/licenseInfo.mustache new file mode 100644 index 000000000000..e737bfd822a0 --- /dev/null +++ b/modules/openapi-generator/src/main/resources/cpp-boost-beast-server/licenseInfo.mustache @@ -0,0 +1,11 @@ +/** + * {{{appName}}} + * {{{appDescription}}} + * + * {{#version}}The version of the OpenAPI document: {{{.}}}{{/version}} +{{#infoEmail}} * Contact: {{{.}}} +{{/infoEmail}} * + * NOTE: This class is auto generated by OpenAPI-Generator {{{generatorVersion}}}. + * https://openapi-generator.tech + * Do not edit the class manually. + */ diff --git a/modules/openapi-generator/src/main/resources/cpp-boost-beast-server/main.mustache b/modules/openapi-generator/src/main/resources/cpp-boost-beast-server/main.mustache new file mode 100644 index 000000000000..61d0bebc2040 --- /dev/null +++ b/modules/openapi-generator/src/main/resources/cpp-boost-beast-server/main.mustache @@ -0,0 +1,91 @@ +{{>licenseInfo}} +// ============================================================================ +// main.cpp - quick-start server entry point (generated with +// addApiImplStubs=true). Every operation answers 501 problem+json until you +// provide a real service implementation. +// ============================================================================ +#include + +#include +#include +#include +#include + +#include "server/HttpServer.h" +#include "server/Router.h" + +{{#apiInfo}} +{{#apis}} +{{#operations}} +#include "api/{{classname}}.h" +{{/operations}} +{{/apis}} +{{/apiInfo}} +{{#apiNamespaceDeclarations}} +using namespace {{this}}; +{{/apiNamespaceDeclarations}} + +{{#apiInfo}} +{{#apis}} +{{#operations}} +static void attach{{classname}}(HttpServer& server) { + {{classname}}::attach(server, std::make_shared<{{classname}}Stub>()); +} +{{/operations}} +{{/apis}} +{{/apiInfo}} + +int main() { + unsigned port = 8080; +#if defined(_MSC_VER) + char* rawPortText = nullptr; + std::size_t rawPortSize = 0; + if (_dupenv_s(&rawPortText, &rawPortSize, "PORT") != 0) { + std::cerr << "fatal: could not read PORT from the environment\n"; + return EXIT_FAILURE; + } + std::unique_ptr portTextOwner( + rawPortText, &std::free); + char const* portText = portTextOwner.get(); +#else + char const* portText = std::getenv("PORT"); +#endif + if (portText != nullptr) { + // Validate the whole value: a partial parse (e.g. "80x") or an + // out-of-range number must fail loudly rather than silently bind a + // different port than the operator intended. Port 0 would bind an + // ephemeral port whose number the log line below cannot know. + char* end = nullptr; + errno = 0; + unsigned long parsed = std::strtoul(portText, &end, 10); + if (errno != 0 || end == portText || *end != '\0' + || parsed < 1 || parsed > 65535) { + std::cerr << "fatal: invalid PORT value '" << portText + << "' (expected an integer between 1 and 65535)\n"; + return EXIT_FAILURE; + } + port = static_cast(parsed); + } + + try { + boost::asio::io_context ioc; + auto router = std::make_shared(); + auto server = HttpServer::create(ioc, router); +{{#apiInfo}} +{{#apis}} +{{#operations}} + attach{{classname}}(*server); +{{/operations}} +{{/apis}} +{{/apiInfo}} + server->listen(boost::asio::ip::tcp::endpoint{ + boost::asio::ip::make_address("0.0.0.0"), + static_cast(port)}); + std::cout << "{{packageName}} listening on 0.0.0.0:" << port << "\n"; + ioc.run(); + } catch (std::exception const& error) { + std::cerr << "fatal: " << error.what() << "\n"; + return EXIT_FAILURE; + } + return EXIT_SUCCESS; +} diff --git a/modules/openapi-generator/src/main/resources/cpp-boost-beast-server/param-codecs-header.mustache b/modules/openapi-generator/src/main/resources/cpp-boost-beast-server/param-codecs-header.mustache new file mode 100644 index 000000000000..9c26aa000b74 --- /dev/null +++ b/modules/openapi-generator/src/main/resources/cpp-boost-beast-server/param-codecs-header.mustache @@ -0,0 +1,653 @@ +{{>licenseInfo}} +// ============================================================================ +// ParamCodecs.h - OAS parameter deserialization primitives: percent +// decoding, strict scalar parsing, simple/label/matrix and form-style +// splitting, and cookie parsing. Header-only. +// ============================================================================ +#ifndef {{apiHeaderGuardPrefix}}_SERVER_PARAM_CODECS_H_ +#define {{apiHeaderGuardPrefix}}_SERVER_PARAM_CODECS_H_ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace {{apiNamespace}} { + +/// Lowercases an HTTP field name for RequestContext header lookups. +inline std::string lowercaseHeaderName(std::string name) { + for (char& c : name) { + c = static_cast(std::tolower(static_cast(c))); + } + return name; +} + +/// Returns the numeric value of one hexadecimal digit, or -1. +inline int hexDigitValue(char c) { + if (c >= '0' && c <= '9') return c - '0'; + if (c >= 'a' && c <= 'f') return c - 'a' + 10; + if (c >= 'A' && c <= 'F') return c - 'A' + 10; + return -1; +} + +/// Percent-decodes a URI component (%XX sequences). Invalid escapes pass +/// through unchanged. `plusAsSpace` implements application/x-www-form-urlencoded +/// query decoding; path and cookie callers leave it false. +inline std::string percentDecode( + std::string_view encoded, bool plusAsSpace = false) { + std::string out; + out.reserve(encoded.size()); + for (std::size_t i = 0; i < encoded.size(); ++i) { + if (plusAsSpace && encoded[i] == '+') { + out.push_back(' '); + continue; + } + if (encoded[i] == '%' && i + 2 < encoded.size()) { + int const high = hexDigitValue(encoded[i + 1]); + int const low = hexDigitValue(encoded[i + 2]); + if (high >= 0 && low >= 0) { + out.push_back(static_cast((high << 4) | low)); + i += 2; + continue; + } + } + out.push_back(encoded[i]); + } + return out; +} + +inline std::string percentDecodeQuery(std::string_view encoded) { + return percentDecode(encoded, true); +} + +/// Splits a simple-style (comma-delimited) list into raw elements. +inline std::vector splitSimple(std::string_view value) { + std::vector parts; + std::size_t start = 0; + while (true) { + std::size_t comma = value.find(',', start); + if (comma == std::string_view::npos) { + parts.emplace_back(value.substr(start)); + break; + } + parts.emplace_back(value.substr(start, comma - start)); + start = comma + 1; + } + return parts; +} + +/// Splits on an arbitrary single-character delimiter (pipe/space styles). +inline std::vector splitOn(std::string_view value, char delimiter) { + std::vector parts; + std::size_t start = 0; + while (true) { + std::size_t hit = value.find(delimiter, start); + if (hit == std::string_view::npos) { + parts.emplace_back(value.substr(start)); + break; + } + parts.emplace_back(value.substr(start, hit - start)); + start = hit + 1; + } + return parts; +} + +/// Splits an encoded query value on its style delimiter, then form-decodes +/// each element. Form commas are structural only when literal, so `%2C` +/// remains data. Pipe/space-delimited styles also accept their percent-encoded +/// delimiter spellings used by RFC 6570 clients; `+` is a space delimiter. +inline std::vector splitQueryParameter( + std::string_view encoded, char delimiter) { + std::vector parts; + std::size_t start = 0; + std::size_t i = 0; + while (i < encoded.size()) { + std::size_t width = 0; + if (encoded[i] == delimiter + || (delimiter == ' ' && encoded[i] == '+')) { + width = 1; + } else if (delimiter != ',' && encoded[i] == '%' + && i + 2 < encoded.size()) { + int const high = hexDigitValue(encoded[i + 1]); + int const low = hexDigitValue(encoded[i + 2]); + if (high >= 0 && low >= 0 + && static_cast((high << 4) | low) == delimiter) { + width = 3; + } + } + if (width == 0) { + ++i; + continue; + } + parts.push_back(percentDecodeQuery(encoded.substr(start, i - start))); + i += width; + start = i; + } + parts.push_back(percentDecodeQuery(encoded.substr(start))); + return parts; +} + +/// Decodes a label-style path segment: the OAS grammar makes the leading +/// "." part of the parameter serialization, so a segment without it is +/// malformed input (400), not a value. `out` receives the remainder after +/// the "." (possibly empty for explode lists; callers check element shape). +inline bool decodeLabel(std::string_view segment, std::string& out) { + if (segment.empty() || segment.front() != '.') { + return false; + } + out.assign(segment.substr(1)); + return true; +} + +/// Decodes a matrix-style path segment: it must start with ";name=" (the +/// parameter name is part of the serialization). `out` receives the value +/// text after the prefix. +inline bool decodeMatrix(std::string_view segment, std::string const& name, + std::string& out) { + std::string prefix = ";" + name + "="; + if (segment.size() < prefix.size() + || segment.compare(0, prefix.size(), prefix) != 0) { + return false; + } + out.assign(segment.substr(prefix.size())); + return true; +} + +/// Splits an exploded matrix segment (";id=3;id=4") into elements. Every +/// element must carry the repeated "name=" prefix (OAS 3.x style=matrix +/// explode=true); a segment or element without it is malformed (400). +inline bool splitMatrixExploded(std::string_view segment, std::string const& name, + std::vector& elements) { + std::string value; + if (!decodeMatrix(segment, name, value)) { + return false; + } + elements = splitOn(value, ';'); + std::string const prefix = name + "="; + for (std::size_t i = 1; i < elements.size(); ++i) { + if (elements[i].compare(0, prefix.size(), prefix) != 0) { + return false; + } + elements[i].erase(0, prefix.size()); + } + return true; +} + +/// Decodes a Cookie header value ("k=v; k2=v2") into decoded pairs. +inline void parseCookieHeader( + std::string_view header, + std::multimap& out) { + std::size_t start = 0; + while (start < header.size()) { + std::size_t semi = header.find(';', start); + std::string_view pair = semi == std::string_view::npos + ? header.substr(start) : header.substr(start, semi - start); + // RFC 6265 4.2 allows optional whitespace after the ';' separator. + while (!pair.empty() && (pair.front() == ' ' || pair.front() == '\t')) { + pair.remove_prefix(1); + } + std::size_t eq = pair.find('='); + if (eq != std::string_view::npos) { + std::string key = percentDecode(pair.substr(0, eq)); + while (!key.empty() && (key.back() == ' ' || key.back() == '\t')) { + key.pop_back(); + } + std::string_view valueText = pair.substr(eq + 1); + while (!valueText.empty() + && (valueText.front() == ' ' || valueText.front() == '\t')) { + valueText.remove_prefix(1); + } + // RFC 6265 4.1.1 cookie-av permits a quoted-string value + // (DQUOTE *cookie-octet DQUOTE). A user agent may send + // session="abc"; strip the wrapping quotes so the codec sees + // the cookie value, not its serialization. + if (valueText.size() >= 2 + && valueText.front() == '"' && valueText.back() == '"') { + valueText = valueText.substr(1, valueText.size() - 2); + } + std::string value = percentDecode(valueText); + if (!key.empty()) { + out.emplace(std::move(key), std::move(value)); + } + } + if (semi == std::string_view::npos) { + break; + } + start = semi + 1; + } +} + +/// Counts UTF-8 code points in a decoded value for minLength/maxLength +/// (JSON Schema counts Unicode scalars, not bytes). A complete valid +/// sequence counts once; any byte that is not part of one (invalid lead, +/// truncated sequence, orphaned continuation) counts as one, so malformed +/// percent-decoded data degrades to a byte count and can never make a +/// maxLength check pass by smuggling continuation bytes. +inline std::size_t utf8CodepointCount(std::string_view text) { + std::size_t count = 0; + std::size_t i = 0; + while (i < text.size()) { + unsigned char const lead = static_cast(text[i]); + std::size_t extra; + if (lead < 0x80) { + extra = 0; + } else if (lead >= 0xC2 && lead <= 0xDF) { + extra = 1; + } else if (lead >= 0xE0 && lead <= 0xEF) { + extra = 2; + } else if (lead >= 0xF0 && lead <= 0xF4) { + extra = 3; + } else { + extra = static_cast(-1); // C0/C1/F5+/stray continuation + } + bool const complete = extra != static_cast(-1) + && i + extra < text.size(); + bool whole = complete; + for (std::size_t k = 1; whole && k <= extra; ++k) { + unsigned char const next = static_cast(text[i + k]); + // Continuation range, plus the lead-specific second-byte bounds + // that forbid overlongs (E0 A0.., F0 90..), surrogates (ED 80.. + // refused), and out-of-range tails (F4 8F..). + if (next < 0x80 || next > 0xBF + || (k == 1 && ( + (lead == 0xE0 && next < 0xA0) + || (lead == 0xED && next > 0x9F) + || (lead == 0xF0 && next < 0x90) + || (lead == 0xF4 && next > 0x8F)))) { + whole = false; + } + } + ++count; + i += whole ? extra + 1 : 1; + } + return count; +} + +/// True when `pattern` contains a real Unicode property escape (\p{...} or +/// \P{...}). std::regex's ECMAScript grammar has no representation for +/// them, and some standard libraries silently compile `\p` as an identity +/// escape (matching the literal text "p"), so a trial construction cannot +/// detect every unsupported pattern; this scan refuses property escapes +/// explicitly before the regex is built. Only a backslash starting an +/// ODD-length run escapes the next character: in "\\p{L}" the doubled +/// backslashes are literals and the p is ordinary text. +inline bool hasUnicodePropertyEscape(std::string_view pattern) { + for (std::size_t i = 0; i < pattern.size(); ++i) { + if (pattern[i] != '\\') { + continue; + } + std::size_t slashes = 0; + while (i + slashes < pattern.size() && pattern[i + slashes] == '\\') { + ++slashes; + } + std::size_t const after = i + slashes; + if (after >= pattern.size()) { + break; // run ends the pattern; nothing to test + } + if ((slashes % 2) == 0) { + // Even run: the following character is a literal; the loop's + // ++i resumes right after the run. + i = after - 1; + continue; + } + if ((pattern[after] == 'p' || pattern[after] == 'P') + && after + 1 < pattern.size() && pattern[after + 1] == '{') { + return true; + } + // Odd run that is not a property escape: the escaped character is + // a literal; consume it so its bytes are not re-scanned. + i = after; + } + return false; +} + +// --------------------------------------------------------------------------- +// Strict scalar parsing: whole-input match, range-checked, no trailing junk. +// --------------------------------------------------------------------------- + +inline bool parseScalar(std::string_view text, std::string& out) { + out.assign(text); + return true; +} + +inline bool parseScalar(std::string_view text, bool& out) { + if (text == "true") { out = true; return true; } + if (text == "false") { out = false; return true; } + return false; +} + +/// True when `text` is exactly the JSON integer grammar +/// ( '-'? ( '0' | [1-9] [0-9]* ) ) with no surrounding whitespace, signless +/// forms aside. strtoll is locale-sensitive only for grouping (which JSON +/// forbids), so a grammar-checked scan is locale-independent. +inline bool isJsonIntegerGrammar(std::string_view text) { + if (text.empty()) return false; + std::size_t i = 0; + if (text[i] == '-') { + ++i; + if (i == text.size()) return false; // bare '-' + } + if (text[i] == '0') { + return i + 1 == text.size(); // leading zeros are not JSON + } + for (; i < text.size(); ++i) { + if (text[i] < '0' || text[i] > '9') return false; + } + return true; +} + +template +inline bool parseIntegerScalar(std::string_view text, T& out) { + if (!isJsonIntegerGrammar(text)) return false; + std::string storage(text); + char* end = nullptr; + errno = 0; + long long parsed = std::strtoll(storage.c_str(), &end, 10); + // Whole-input match: strtoll stops at NUL or junk; compare the consumed + // length so embedded NULs (via %00) cannot smuggle trailing bytes. + if (end == nullptr + || static_cast(end - storage.data()) != storage.size() + || errno == ERANGE) { + return false; + } + if (parsed < static_cast(std::numeric_limits::min()) + || parsed > static_cast(std::numeric_limits::max())) { + return false; + } + out = static_cast(parsed); + return true; +} + +inline bool parseScalar(std::string_view text, std::int32_t& out) { + return parseIntegerScalar(text, out); +} + +inline bool parseScalar(std::string_view text, std::int64_t& out) { + return parseIntegerScalar(text, out); +} +/// True when `text` is exactly the JSON number grammar +/// ( '-'? int ( '.' frac )? ( [eE] [+-]? frac )? ). Rejects the strtod +/// extensions (hex floats, "inf"/"nan", leading '+', leading zeros, "1.", +/// ".5") and any locale decimal comma. +inline bool isJsonNumberGrammar(std::string_view text) { + if (text.empty()) return false; + std::size_t i = text[0] == '-' ? 1 : 0; + if (i >= text.size()) return false; + if (text[i] == '0') { + ++i; + } else if (text[i] >= '1' && text[i] <= '9') { + while (i < text.size() && text[i] >= '0' && text[i] <= '9') ++i; + } else { + return false; + } + std::size_t digits = 0; + if (i < text.size() && text[i] == '.') { + ++i; + digits = i; + while (i < text.size() && text[i] >= '0' && text[i] <= '9') ++i; + if (i == digits) return false; // '.' requires digits + } + if (i < text.size() && (text[i] == 'e' || text[i] == 'E')) { + ++i; + if (i < text.size() && (text[i] == '+' || text[i] == '-')) ++i; + digits = i; + while (i < text.size() && text[i] >= '0' && text[i] <= '9') ++i; + if (i == digits) return false; // exponent requires digits + } + return i == text.size(); +} + +struct ExactDecimal { + boost::multiprecision::cpp_int coefficient; + boost::multiprecision::cpp_int exponent10; + bool zero = false; +}; + +/// Parses a JSON number into an exact decimal coefficient and base-10 +/// exponent. Trailing coefficient zeroes are folded into the exponent so a +/// negative exponent difference proves non-divisibility without constructing +/// an enormous power of ten. +inline bool parseExactDecimal(std::string_view text, ExactDecimal& out) { + if (!isJsonNumberGrammar(text)) return false; + std::size_t i = (!text.empty() && text.front() == '-') ? 1 : 0; + std::string digits; + digits.reserve(text.size()); + while (i < text.size() && text[i] >= '0' && text[i] <= '9') { + digits.push_back(text[i++]); + } + std::size_t fractionalDigits = 0; + if (i < text.size() && text[i] == '.') { + ++i; + while (i < text.size() && text[i] >= '0' && text[i] <= '9') { + digits.push_back(text[i++]); + ++fractionalDigits; + } + } + + boost::multiprecision::cpp_int exponent = 0; + if (i < text.size()) { + ++i; // e/E + bool const negativeExponent = i < text.size() && text[i] == '-'; + if (i < text.size() && (text[i] == '+' || text[i] == '-')) ++i; + for (; i < text.size(); ++i) { + exponent *= 10; + exponent += text[i] - '0'; + } + if (negativeExponent) exponent = -exponent; + } + exponent -= fractionalDigits; + + std::size_t first = digits.find_first_not_of('0'); + if (first == std::string::npos) { + out = ExactDecimal{}; + out.zero = true; + return true; + } + digits.erase(0, first); + while (digits.size() > 1 && digits.back() == '0') { + digits.pop_back(); + ++exponent; + } + boost::multiprecision::cpp_int coefficient = 0; + for (char digit : digits) { + coefficient *= 10; + coefficient += digit - '0'; + } + out.coefficient = std::move(coefficient); + out.exponent10 = std::move(exponent); + out.zero = false; + return true; +} + +/// JSON Schema `multipleOf` is exact decimal arithmetic, not a tolerance test +/// over the rounded float/double destination. The wire lexeme is therefore +/// checked before its typed value is used. Arbitrarily large exponents remain +/// bounded by modular exponentiation rather than materializing 10^exponent. +inline bool isExactMultipleOf( + std::string_view valueText, std::string_view divisorText) { + ExactDecimal value; + ExactDecimal divisor; + if (!parseExactDecimal(valueText, value) + || !parseExactDecimal(divisorText, divisor) + || divisor.zero + || (!divisorText.empty() && divisorText.front() == '-')) { + return false; + } + if (value.zero) return true; + boost::multiprecision::cpp_int const shift = + value.exponent10 - divisor.exponent10; + if (shift < 0) { + // Both coefficients have no trailing decimal zero. Multiplying the + // divisor by at least one 10 therefore cannot divide the value. + return false; + } + if (divisor.coefficient == 1) return true; + + // Once 10^shift supplies every factor of 2 and 5 in the divisor, + // additional powers of ten are invertible modulo the remaining factor + // and cannot change divisibility. Cap the modular exponent accordingly, + // so a wire exponent with thousands of digits cannot amplify CPU work. + auto residual = divisor.coefficient; + std::size_t twos = 0; + std::size_t fives = 0; + while ((residual % 2) == 0) { + residual /= 2; + ++twos; + } + while ((residual % 5) == 0) { + residual /= 5; + ++fives; + } + std::size_t const cap = twos > fives ? twos : fives; + boost::multiprecision::cpp_int const effectiveShift = + shift > cap ? boost::multiprecision::cpp_int(cap) : shift; + auto const factor = boost::multiprecision::powm( + boost::multiprecision::cpp_int(10), + effectiveShift, divisor.coefficient); + return ((value.coefficient % divisor.coefficient) * factor) + % divisor.coefficient == 0; +} + + +/// Parses a JSON number without any locale dependence: strtod honors +/// LC_NUMERIC, so under a comma-decimal locale it would accept "1,5" +/// (which JSON forbids) and reject the valid "1.5". The grammar scan fixes +/// the accepted text; the accumulation below converts exactly that text +/// with a BOUNDED significand: at most kSignificantDigits digits enter the +/// accumulator (longer runs carry no precision past that budget and shift +/// the decimal exponent instead), so no finite JSON token can overflow the +/// intermediate value the way a full digit-by-digit mantissa would. The +/// exponent is then applied in bounded pow() steps: "0e400" scales nothing +/// (no 0 * inf -> NaN) and "1e400" saturates to infinity, which the +/// finiteness gate rejects. Underflow (1e-400) rounds to zero/denormal and +/// stays accepted, matching strtod semantics. Any residual difference from +/// a correctly-rounded conversion is at most an implementation-precision +/// ULP on long digit strings. +template +inline bool parseFloatScalar(std::string_view text, T& out) { + if (!isJsonNumberGrammar(text)) return false; + constexpr int kSignificantDigits = 18; + std::size_t i = 0; + bool negative = text[i] == '-'; + if (negative) ++i; + std::size_t const intBeg = i; + while (i < text.size() && text[i] >= '0' && text[i] <= '9') ++i; + std::size_t const intEnd = i; + std::size_t fracBeg = intEnd; + if (i < text.size() && text[i] == '.') { + ++i; + fracBeg = i; + while (i < text.size() && text[i] >= '0' && text[i] <= '9') ++i; + } + std::size_t const fracEnd = i; + long long declared = 0; + if (i < text.size() && (text[i] == 'e' || text[i] == 'E')) { + ++i; + bool exponentNegative = text[i] == '-'; + if (text[i] == '+' || text[i] == '-') ++i; + while (i < text.size() && text[i] >= '0' && text[i] <= '9') { + // Clamp: any magnitude beyond this saturates the scaling loop to + // 0 or infinity, and clamping keeps the accumulator from signed + // overflow. + if (declared < 100000) { + declared = declared * 10 + (text[i] - '0'); + } + ++i; + } + if (exponentNegative) declared = -declared; + } + // Significant digits are the run from the first non-zero digit of + // [intBeg, fracEnd) to the end of the fraction. Value = I * 10^-P * + // 10^declared, where I is that run read as an integer and P counts the + // fraction digits (they sit below the decimal point whether or not they + // are part of the significant run — "0.001" keeps P = 3). Truncating I + // to the first kSignificantDigits divides it by 10^(L - kept), which the + // exponent below pays back exactly. + std::size_t sigStart = intBeg; + bool allZero = true; + for (std::size_t k = intBeg; k < fracEnd; ++k) { + if (k >= intEnd && k < fracBeg) continue; // the '.' position + if (text[k] != '0') { sigStart = k; allZero = false; break; } + } + long double parsed = 0.0L; + if (!allZero) { + long long const headDigits = + sigStart < intEnd ? static_cast(intEnd - sigStart) : 0; + long long const fracDigits = + static_cast(fracEnd) - static_cast( + sigStart > fracBeg ? sigStart : fracBeg); + long long const totalDigits = headDigits + fracDigits; + long long const kept = totalDigits < kSignificantDigits + ? totalDigits : kSignificantDigits; + long long exponent = declared + - static_cast(fracEnd - fracBeg) + + (totalDigits - kept); + long long remaining = kept; + for (std::size_t k = sigStart; k < fracEnd && remaining > 0; ++k) { + if (k >= intEnd && k < fracBeg) continue; + parsed = parsed * 10.0L + static_cast(text[k] - '0'); + --remaining; + } + // Scaling in bounded steps: pow() itself never overflows even where + // long double is only 64-bit (MSVC, Apple arm64). Overflow of the + // product saturates to infinity and dies at the finiteness gate; + // underflow rounds toward zero and stays accepted. + while (exponent > 0) { + long double const factor = std::pow( + 10.0L, static_cast(exponent > 300 ? 300 : exponent)); + parsed *= factor; + exponent -= exponent > 300 ? 300 : exponent; + } + while (exponent < 0) { + long long const chunk = exponent < -300 ? 300 : -exponent; + parsed /= std::pow(10.0L, static_cast(chunk)); + exponent += chunk; + } + } + // (allZero skips scaling entirely: a zero significand stays zero + // whatever the exponent, so "0e400" is finite, not 0 * inf.) + if (negative) parsed = -parsed; + // JSON numbers are finite; overflow (1e400) saturated to infinity above + // and dies here. On platforms where long double is WIDER than double + // (x86 80-bit) it does not: 1e400 stays a finite long double, and the + // cast below would hand the service a non-finite double. The + // representability gate therefore compares against the DESTINATION + // range for float and double alike. Underflow (1e-400, or 1e-50 for + // float) rounds toward zero/denormal and stays accepted; a value above + // the destination maximum is a wire-level representability failure + // either way, so both platforms answer 400 identically. + if (!std::isfinite(parsed)) return false; + if constexpr (sizeof(T) < sizeof(long double)) { + // Narrowing must not reach the non-finite range from above. Values + // between the destination maximum and the next representable long + // double also exceed the maximum, so this comparison closes every + // rounding-to-infinity path. + if (parsed > static_cast(std::numeric_limits::max()) + || parsed < -static_cast(std::numeric_limits::max())) { + return false; + } + } + out = static_cast(parsed); + return true; +} + +inline bool parseScalar(std::string_view text, float& out) { + return parseFloatScalar(text, out); +} + +inline bool parseScalar(std::string_view text, double& out) { + return parseFloatScalar(text, out); +} + +} // namespace {{apiNamespace}} + +#endif // {{apiHeaderGuardPrefix}}_SERVER_PARAM_CODECS_H_ diff --git a/modules/openapi-generator/src/main/resources/cpp-boost-beast-server/param-constraints.mustache b/modules/openapi-generator/src/main/resources/cpp-boost-beast-server/param-constraints.mustache new file mode 100644 index 000000000000..50abe9d17c83 --- /dev/null +++ b/modules/openapi-generator/src/main/resources/cpp-boost-beast-server/param-constraints.mustache @@ -0,0 +1,171 @@ +{{! Shared scalar-parameter constraint ladder for the path/query/header/cookie + scalar branches. Requires C++ locals `present` (parameter seen on the wire; + path sets it true), `invalid`, and `problem`, plus the assembler facts + stringKind/boolKind/integerKind/numberKind and the constraint ladder: + hasEnum/enumValues, enumAlwaysInvalid, hasPattern/pattern, + hasMinLength/minLength, hasMaxLength/maxLength, hasMinimum/minimum, + hasMaximum/maximum, minimumExclusive/maximumExclusive, + minimumAlwaysInvalid/maximumAlwaysInvalid. The error keys use + baseNameLiteral (C++-escaped, triple-brace) so a wire name containing + quotes or backslashes cannot corrupt the generated string literal. + Integer kinds compare in their exact integer type against assembler-folded + thresholds (minimum is already the strict reject floor, maximum the strict + reject ceiling), so a value above 2^53 cannot slip through a lossy + floating-point conversion. String lengths are counted in UTF-8 code + points (matching the model validator's countCodePoints); the pattern runs + std::regex_search over the UTF-8 BYTES (see the README pattern-policy + note). }} +{{#stringKind}}{{#hasEnum}} + if (!invalid && present) { + static std::vector const kAllowed = { {{#enumValues}}{{{.}}}, {{/enumValues}} }; + if (std::find(kAllowed.begin(), kAllowed.end(), request.{{cppName}}) == kAllowed.end()) { + problem.withError("{{{baseNameLiteral}}}", "value is not one of the allowed enum members"); + invalid = true; + } + } +{{/hasEnum}}{{#enumAlwaysInvalid}} + if (!invalid && present) { + // Every declared enum member is unrepresentable for + // this parameter's C++ type (strings/fractional/out of + // range against an integer codec): no wire value can + // satisfy the schema. Fail closed like the model path. + problem.withError("{{{baseNameLiteral}}}", "no declared enum member is representable for this parameter type"); + invalid = true; + } +{{/enumAlwaysInvalid}}{{#hasPattern}} + if (!invalid && present) { + // JSON Schema `pattern` is UNANCHORED: a substring + // match satisfies it. A pattern outside the + // std::regex ECMAScript subset (unicode property + // escapes, named groups, lookbehind) throws at + // construction. Guard the function-local static so a + // failed initialization cannot retry-throw on every + // request (which would answer 500 forever); fail + // closed with 400, matching the model path's + // validatePattern policy. + static bool const kPatternOk = [] { + if (hasUnicodePropertyEscape("{{{pattern}}}")) { + return false; + } + try { + std::regex trial("{{{pattern}}}"); + (void)trial; + return true; + } catch (std::regex_error const&) { + return false; + } + }(); + if (!kPatternOk) { + problem.withError("{{{baseNameLiteral}}}", "the declared pattern is outside the supported regex grammar"); + invalid = true; + } else { + static std::regex const kPattern("{{{pattern}}}"); + if (!std::regex_search(request.{{cppName}}, kPattern)) { + problem.withError("{{{baseNameLiteral}}}", "value does not match the required pattern"); + invalid = true; + } + } + } +{{/hasPattern}}{{#hasMinLength}} + if (!invalid && present + && utf8CodepointCount(request.{{cppName}}) < {{minLength}}) { + problem.withError("{{{baseNameLiteral}}}", "value is shorter than minLength"); + invalid = true; + } +{{/hasMinLength}}{{#hasMaxLength}} + if (!invalid && present + && utf8CodepointCount(request.{{cppName}}) > {{maxLength}}) { + problem.withError("{{{baseNameLiteral}}}", "value is longer than maxLength"); + invalid = true; + } +{{/hasMaxLength}}{{/stringKind}} +{{#boolKind}}{{#hasEnum}} + if (!invalid && present) { + static std::vector const kAllowed = { {{#enumValues}}{{{.}}}, {{/enumValues}} }; + if (std::find(kAllowed.begin(), kAllowed.end(), request.{{cppName}}) == kAllowed.end()) { + problem.withError("{{{baseNameLiteral}}}", "value is not one of the allowed enum members"); + invalid = true; + } + } +{{/hasEnum}}{{#enumAlwaysInvalid}} + if (!invalid && present) { + // Every declared enum member is unrepresentable for + // a bool codec (strings/numbers): no wire value can + // satisfy the schema. Fail closed like the integer + // and string branches. + problem.withError("{{{baseNameLiteral}}}", "no declared enum member is representable for this parameter type"); + invalid = true; + } +{{/enumAlwaysInvalid}}{{/boolKind}} +{{#integerKind}}{{#enumAlwaysInvalid}} + if (!invalid && present) { + // Every declared enum member is unrepresentable for + // this parameter's C++ type: no wire value can satisfy + // the schema. Fail closed rather than skip the check. + problem.withError("{{{baseNameLiteral}}}", "no declared enum member is representable for this parameter type"); + invalid = true; + } +{{/enumAlwaysInvalid}}{{^enumAlwaysInvalid}}{{#minimumAlwaysInvalid}} + if (!invalid && present) { + problem.withError("{{{baseNameLiteral}}}", "value can never satisfy the declared minimum"); + invalid = true; + } +{{/minimumAlwaysInvalid}}{{#maximumAlwaysInvalid}} + if (!invalid && present) { + problem.withError("{{{baseNameLiteral}}}", "value can never satisfy the declared maximum"); + invalid = true; + } +{{/maximumAlwaysInvalid}}{{/enumAlwaysInvalid}}{{^minimumAlwaysInvalid}}{{^maximumAlwaysInvalid}}{{#hasEnum}} + if (!invalid && present) { + static std::vector const kAllowed = { {{#enumValues}}{{{.}}}, {{/enumValues}} }; + if (std::find(kAllowed.begin(), kAllowed.end(), static_cast(request.{{cppName}})) == kAllowed.end()) { + problem.withError("{{{baseNameLiteral}}}", "value is not one of the allowed enum members"); + invalid = true; + } + } +{{/hasEnum}}{{#hasMinimum}} + if (!invalid && present && request.{{cppName}} < {{minimum}}) { + problem.withError("{{{baseNameLiteral}}}", "value is below the minimum"); + invalid = true; + } +{{/hasMinimum}}{{#hasMaximum}} + if (!invalid && present && request.{{cppName}} > {{maximum}}) { + problem.withError("{{{baseNameLiteral}}}", "value is above the maximum"); + invalid = true; + } +{{/hasMaximum}}{{/maximumAlwaysInvalid}}{{/minimumAlwaysInvalid}}{{/integerKind}} +{{#numberKind}}{{#hasEnum}} + if (!invalid && present) { + static std::vector const kAllowed = { {{#enumValues}}{{{.}}}, {{/enumValues}} }; + if (std::find(kAllowed.begin(), kAllowed.end(), static_cast(request.{{cppName}})) == kAllowed.end()) { + problem.withError("{{{baseNameLiteral}}}", "value is not one of the allowed enum members"); + invalid = true; + } + } +{{/hasEnum}}{{#enumAlwaysInvalid}} + if (!invalid && present) { + // Every declared enum member is unrepresentable for + // a number codec (strings/bools): no wire value can + // satisfy the schema. Fail closed like the integer + // branch. + problem.withError("{{{baseNameLiteral}}}", "no declared enum member is representable for this parameter type"); + invalid = true; + } +{{/enumAlwaysInvalid}}{{#hasMinimum}} + if (!invalid && present && static_cast(request.{{cppName}}) {{#minimumExclusive}}<={{/minimumExclusive}}{{^minimumExclusive}}<{{/minimumExclusive}} {{minimum}}L) { + problem.withError("{{{baseNameLiteral}}}", "value is out of the allowed range"); + invalid = true; + } +{{/hasMinimum}}{{#hasMaximum}} + if (!invalid && present && static_cast(request.{{cppName}}) {{#maximumExclusive}}>={{/maximumExclusive}}{{^maximumExclusive}}>{{/maximumExclusive}} {{maximum}}L) { + problem.withError("{{{baseNameLiteral}}}", "value is out of the allowed range"); + invalid = true; + } +{{/hasMaximum}}{{/numberKind}} +{{#hasMultipleOf}} + if (!invalid && present + && !isExactMultipleOf(text, "{{{multipleOf}}}")) { + problem.withError("{{{baseNameLiteral}}}", "value is not a multiple of {{{multipleOf}}}"); + invalid = true; + } +{{/hasMultipleOf}} diff --git a/modules/openapi-generator/src/main/resources/cpp-boost-beast-server/param-container-constraints.mustache b/modules/openapi-generator/src/main/resources/cpp-boost-beast-server/param-container-constraints.mustache new file mode 100644 index 000000000000..2985a77638e6 --- /dev/null +++ b/modules/openapi-generator/src/main/resources/cpp-boost-beast-server/param-container-constraints.mustache @@ -0,0 +1,200 @@ +{{! Container-parameter validation: collection bounds (minItems/maxItems/ + uniqueItems) and the per-element constraint ladder derived from the items + schema. Requires C++ locals invalid, problem, `present` (declared by the + query/header branches; the path branch sets it true when the partial is + included) and the decoded container member request.cppName, plus the + assembler facts hasMinItems/minItems, hasMaxItems/maxItems, uniqueItems + and the item ladder (itemHasEnum, itemEnumValues, itemEnumAlwaysInvalid, + itemHasPattern, itemPattern, itemHasMinLength, itemMinLength, + itemHasMaxLength, itemMaxLength, itemHasMinimum, itemMinimum, + itemHasMaximum, itemMaximum, itemMinimumAlwaysInvalid, + itemMaximumAlwaysInvalid). Elements were decoded as the inner type, so + the item facts use that kind. The error keys use baseNameLiteral + (C++-escaped, triple-brace) so a wire name containing quotes or + backslashes cannot corrupt the generated string literal. Item string + lengths count UTF-8 code points; the item pattern is an unanchored + std::regex_search over the UTF-8 bytes (see the README pattern-policy + note). An absent parameter (present false) skips every check: the bounds + apply to the array instance, which is not on the wire. }} +{{#hasMinItems}} + if (!invalid && present && request.{{cppName}}.size() < {{minItems}}) { + problem.withError("{{{baseNameLiteral}}}", "value has fewer items than minItems"); + invalid = true; + } +{{/hasMinItems}}{{#hasMaxItems}} + if (!invalid && present && request.{{cppName}}.size() > {{maxItems}}) { + problem.withError("{{{baseNameLiteral}}}", "value has more items than maxItems"); + invalid = true; + } +{{/hasMaxItems}}{{#uniqueItems}} + if (!invalid && present) { + std::vector<{{{innerType}}}> sortedItems(request.{{cppName}}.begin(), request.{{cppName}}.end()); + std::sort(sortedItems.begin(), sortedItems.end()); + if (std::adjacent_find(sortedItems.begin(), sortedItems.end()) != sortedItems.end()) { + problem.withError("{{{baseNameLiteral}}}", "value items are not unique"); + invalid = true; + } + } +{{/uniqueItems}} +{{#itemEnumAlwaysInvalid}} + if (!invalid && present && !request.{{cppName}}.empty()) { + // Every declared item enum member is unrepresentable + // for the element codec: any present item violates + // the schema. Fail closed rather than skip the check. + problem.withError("{{{baseNameLiteral}}}", "no declared item enum member is representable for this element type"); + invalid = true; + } +{{/itemEnumAlwaysInvalid}}{{#itemMinimumAlwaysInvalid}} + if (!invalid && present && !request.{{cppName}}.empty()) { + problem.withError("{{{baseNameLiteral}}}", "item value can never satisfy the declared minimum"); + invalid = true; + } +{{/itemMinimumAlwaysInvalid}}{{#itemMaximumAlwaysInvalid}} + if (!invalid && present && !request.{{cppName}}.empty()) { + problem.withError("{{{baseNameLiteral}}}", "item value can never satisfy the declared maximum"); + invalid = true; + } +{{/itemMaximumAlwaysInvalid}} +{{#itemStringKind}}{{#itemHasEnum}} + if (!invalid && present) { + static std::vector const kItemAllowed = { {{#itemEnumValues}}{{{.}}}, {{/itemEnumValues}} }; + for ({{{innerType}}} const& item : request.{{cppName}}) { + if (std::find(kItemAllowed.begin(), kItemAllowed.end(), item) == kItemAllowed.end()) { + problem.withError("{{{baseNameLiteral}}}", "item value is not one of the allowed enum members"); + invalid = true; + break; + } + } + } +{{/itemHasEnum}}{{#itemHasPattern}} + if (!invalid && present && !request.{{cppName}}.empty()) { + // JSON Schema `pattern` is UNANCHORED (substring + // match), evaluated over the UTF-8 bytes. Same + // fail-closed policy as the scalar ladder: a pattern + // outside the std::regex ECMAScript subset answers + // 400 instead of retry-throwing 500. Empty (absent) + // containers skip the gate, matching the no-op loop + // semantics of the decodable case. + static bool const kItemPatternOk = [] { + if (hasUnicodePropertyEscape("{{{itemPattern}}}")) { + return false; + } + try { + std::regex trial("{{{itemPattern}}}"); + (void)trial; + return true; + } catch (std::regex_error const&) { + return false; + } + }(); + if (!kItemPatternOk) { + problem.withError("{{{baseNameLiteral}}}", "the declared item pattern is outside the supported regex grammar"); + invalid = true; + } else { + static std::regex const kItemPattern("{{{itemPattern}}}"); + for ({{{innerType}}} const& item : request.{{cppName}}) { + if (!std::regex_search(item, kItemPattern)) { + problem.withError("{{{baseNameLiteral}}}", "item value does not match the required pattern"); + invalid = true; + break; + } + } + } + } +{{/itemHasPattern}}{{#itemHasMinLength}} + if (!invalid && present) { + for ({{{innerType}}} const& item : request.{{cppName}}) { + if (utf8CodepointCount(item) < {{itemMinLength}}) { + problem.withError("{{{baseNameLiteral}}}", "item value is shorter than minLength"); + invalid = true; + break; + } + } + } +{{/itemHasMinLength}}{{#itemHasMaxLength}} + if (!invalid && present) { + for ({{{innerType}}} const& item : request.{{cppName}}) { + if (utf8CodepointCount(item) > {{itemMaxLength}}) { + problem.withError("{{{baseNameLiteral}}}", "item value is longer than maxLength"); + invalid = true; + break; + } + } + } +{{/itemHasMaxLength}}{{/itemStringKind}} +{{#itemBoolKind}}{{#itemHasEnum}} + if (!invalid && present) { + static std::vector const kItemAllowed = { {{#itemEnumValues}}{{{.}}}, {{/itemEnumValues}} }; + for ({{{innerType}}} const& item : request.{{cppName}}) { + if (std::find(kItemAllowed.begin(), kItemAllowed.end(), item) == kItemAllowed.end()) { + problem.withError("{{{baseNameLiteral}}}", "item value is not one of the allowed enum members"); + invalid = true; + break; + } + } + } +{{/itemHasEnum}}{{/itemBoolKind}} +{{#itemIntegerKind}}{{^itemMinimumAlwaysInvalid}}{{^itemMaximumAlwaysInvalid}}{{#itemHasEnum}} + if (!invalid && present) { + static std::vector const kItemAllowed = { {{#itemEnumValues}}{{{.}}}, {{/itemEnumValues}} }; + for ({{{innerType}}} const& item : request.{{cppName}}) { + if (std::find(kItemAllowed.begin(), kItemAllowed.end(), static_cast(item)) == kItemAllowed.end()) { + problem.withError("{{{baseNameLiteral}}}", "item value is not one of the allowed enum members"); + invalid = true; + break; + } + } + } +{{/itemHasEnum}}{{#itemHasMinimum}} + if (!invalid && present) { + for ({{{innerType}}} const& item : request.{{cppName}}) { + if (item < {{itemMinimum}}) { + problem.withError("{{{baseNameLiteral}}}", "item value is below the minimum"); + invalid = true; + break; + } + } + } +{{/itemHasMinimum}}{{#itemHasMaximum}} + if (!invalid && present) { + for ({{{innerType}}} const& item : request.{{cppName}}) { + if (item > {{itemMaximum}}) { + problem.withError("{{{baseNameLiteral}}}", "item value is above the maximum"); + invalid = true; + break; + } + } + } +{{/itemHasMaximum}}{{/itemMaximumAlwaysInvalid}}{{/itemMinimumAlwaysInvalid}}{{/itemIntegerKind}} +{{#itemNumberKind}}{{#itemHasEnum}} + if (!invalid && present) { + static std::vector const kItemAllowed = { {{#itemEnumValues}}{{{.}}}, {{/itemEnumValues}} }; + for ({{{innerType}}} const& item : request.{{cppName}}) { + if (std::find(kItemAllowed.begin(), kItemAllowed.end(), static_cast(item)) == kItemAllowed.end()) { + problem.withError("{{{baseNameLiteral}}}", "item value is not one of the allowed enum members"); + invalid = true; + break; + } + } + } +{{/itemHasEnum}}{{#itemHasMinimum}} + if (!invalid && present) { + for ({{{innerType}}} const& item : request.{{cppName}}) { + if (static_cast(item) {{#itemMinimumExclusive}}<={{/itemMinimumExclusive}}{{^itemMinimumExclusive}}<{{/itemMinimumExclusive}} {{itemMinimum}}L) { + problem.withError("{{{baseNameLiteral}}}", "item value is out of the allowed range"); + invalid = true; + break; + } + } + } +{{/itemHasMinimum}}{{#itemHasMaximum}} + if (!invalid && present) { + for ({{{innerType}}} const& item : request.{{cppName}}) { + if (static_cast(item) {{#itemMaximumExclusive}}>={{/itemMaximumExclusive}}{{^itemMaximumExclusive}}>{{/itemMaximumExclusive}} {{itemMaximum}}L) { + problem.withError("{{{baseNameLiteral}}}", "item value is out of the allowed range"); + invalid = true; + break; + } + } + } +{{/itemHasMaximum}}{{/itemNumberKind}} diff --git a/modules/openapi-generator/src/main/resources/cpp-boost-beast-server/problem-header.mustache b/modules/openapi-generator/src/main/resources/cpp-boost-beast-server/problem-header.mustache new file mode 100644 index 000000000000..521a54f479fe --- /dev/null +++ b/modules/openapi-generator/src/main/resources/cpp-boost-beast-server/problem-header.mustache @@ -0,0 +1,234 @@ +{{>licenseInfo}} +// ============================================================================ +// Problem.h - RFC 9457 problem details responses. +// ============================================================================ +#ifndef {{apiHeaderGuardPrefix}}_SERVER_PROBLEM_H_ +#define {{apiHeaderGuardPrefix}}_SERVER_PROBLEM_H_ + +#include + +#include +#include +#include +#include +#include + +namespace {{apiNamespace}} { + +/// One validation error occurrence: a location pointer plus a message. +struct ProblemError { + std::string path; + std::string message; +}; +/// RFC 9457 problem details object. +struct Problem { + unsigned status = 500; + std::string type = "about:blank"; + std::string title; + std::string detail; + std::string instance; + std::vector errors; + + Problem& withError(std::string path, std::string message) { + errors.push_back(ProblemError{std::move(path), std::move(message)}); + return *this; + } + + static Problem badRequest(std::string detail) { + Problem p; + p.status = 400; + p.title = "Bad Request"; + p.detail = std::move(detail); + return p; + } + + static Problem unauthorized() { + Problem p; + p.status = 401; + p.title = "Unauthorized"; + p.detail = "Missing or invalid credentials"; + return p; + } + + static Problem notFound(std::string const& target) { + Problem p; + p.status = 404; + p.title = "Not Found"; + p.detail = "The resource '" + target + "' was not found."; + p.instance = target; + return p; + } + + static Problem methodNotAllowed(std::string const& allow) { + Problem p; + p.status = 405; + p.title = "Method Not Allowed"; + p.detail = "Allowed methods: " + allow; + return p; + } + + static Problem unsupportedMediaType(std::string const& received) { + Problem p; + p.status = 415; + p.title = "Unsupported Media Type"; + p.detail = "Content-Type '" + received + "' is not supported"; + return p; + } + + static Problem payloadTooLarge() { + Problem p; + p.status = 413; + p.title = "Content Too Large"; + p.detail = "Request body exceeds the configured limit"; + return p; + } + + static Problem requestHeaderFieldsTooLarge() { + Problem p; + p.status = 431; + p.title = "Request Header Fields Too Large"; + p.detail = "Request headers exceed the configured limit"; + return p; + } + + static Problem expectationFailed() { + Problem p; + p.status = 417; + p.title = "Expectation Failed"; + p.detail = "The request carries an Expectation extension this server does not support"; + return p; + } + + static Problem internal() { + Problem p; + p.status = 500; + p.title = "Internal Server Error"; + return p; + } + + static Problem notImplemented(std::string const& operationId) { + Problem p; + p.status = 501; + p.title = "Not Implemented"; + p.detail = "Operation '" + operationId + "' has no implementation"; + return p; + } +}; + +/// Serializes a problem as an HTTP response with application/problem+json. +inline boost::beast::http::response +toProblemResponse(Problem const& problem) { + namespace http = boost::beast::http; + auto jsonEscape = [](std::string const& text) { + std::string out; + out.reserve(text.size()); + std::size_t i = 0; + while (i < text.size()) { + unsigned char byte = static_cast(text[i]); + if (byte < 0x80) { + switch (byte) { + case '"': out += "\\\""; break; + case '\\': out += "\\\\"; break; + case '\n': out += "\\n"; break; + case '\r': out += "\\r"; break; + case '\t': out += "\\t"; break; + case '\b': out += "\\b"; break; + case '\f': out += "\\f"; break; + default: + if (byte < 0x20 || byte == 0x7f) { + // Escape C0 controls and DEL as \u00XX. + char buffer[8]; + std::snprintf(buffer, sizeof(buffer), "\\u%04x", + static_cast(byte)); + out += buffer; + } else { + out.push_back(static_cast(byte)); + } + break; + } + i += 1; + continue; + } + // A UTF-8 lead byte and its continuation bytes pass through + // verbatim (accented text, CJK, emoji in echoed values stay + // human-readable). An invalid sequence — C1/overlong lead, + // truncated tail, bad continuation, surrogate, or out-of-range + // codepoint — is replaced with \ufffd one byte at a time so the + // problem document is always valid JSON (RFC 8259 requires it). + std::size_t extra = 0; + unsigned int codepoint = 0; + if (byte >= 0xC2 && byte <= 0xDF) { + extra = 1; + codepoint = byte & 0x1FU; + } else if (byte >= 0xE0 && byte <= 0xEF) { + extra = 2; + codepoint = byte & 0x0FU; + } else if (byte >= 0xF0 && byte <= 0xF4) { + extra = 3; + codepoint = byte & 0x07U; + } + bool valid = extra > 0 && i + extra + 1 <= text.size(); + for (std::size_t k = 1; valid && k <= extra; ++k) { + unsigned char next = static_cast(text[i + k]); + if (next < 0x80 || next > 0xBF) { + valid = false; + break; + } + codepoint = (codepoint << 6) | (next & 0x3FU); + } + // Shortest-form enforcement: the lead-byte table above already + // refuses C0/C1 overlongs, but 3- and 4-byte overlongs hide + // behind otherwise-valid continuation bytes — E0 A0 80 decodes + // to U+0080 and F0 8F BF BF to U+FFFF, both ≥ 0x80 yet both + // non-shortest (they would encode in fewer bytes). Require each + // form's minimum so accepted bytes are exactly valid UTF-8. + if (valid && (codepoint < 0x80 || codepoint > 0x10FFFF + || (extra == 2 && codepoint < 0x800) + || (extra == 3 && codepoint < 0x10000) + || (codepoint >= 0xD800 && codepoint <= 0xDFFF))) { + valid = false; + } + if (!valid) { + out += "\\ufffd"; + i += 1; + continue; + } + out.append(text, i, extra + 1); + i += extra + 1; + } + return out; + }; + http::response res{ + static_cast(problem.status), 11}; + res.set(http::field::server, "openapi-generator-cpp-boost-beast-server"); + res.set(http::field::content_type, "application/problem+json"); + std::string body = "{"; + body += "\"type\":\"" + jsonEscape(problem.type) + "\""; + body += ",\"title\":\"" + jsonEscape(problem.title) + "\""; + body += ",\"status\":" + std::to_string(problem.status); + if (!problem.detail.empty()) { + body += ",\"detail\":\"" + jsonEscape(problem.detail) + "\""; + } + if (!problem.instance.empty()) { + body += ",\"instance\":\"" + jsonEscape(problem.instance) + "\""; + } + if (!problem.errors.empty()) { + body += ",\"errors\":["; + for (std::size_t i = 0; i < problem.errors.size(); ++i) { + if (i != 0) { + body += ","; + } + body += "{\"path\":\"" + jsonEscape(problem.errors[i].path) + + "\",\"message\":\"" + jsonEscape(problem.errors[i].message) + "\"}"; + } + body += "]"; + } + body += "}"; + res.body() = std::move(body); + res.prepare_payload(); + return res; +} + +} // namespace {{apiNamespace}} + +#endif // {{apiHeaderGuardPrefix}}_SERVER_PROBLEM_H_ diff --git a/modules/openapi-generator/src/main/resources/cpp-boost-beast-server/responder-header.mustache b/modules/openapi-generator/src/main/resources/cpp-boost-beast-server/responder-header.mustache new file mode 100644 index 000000000000..e049251aa299 --- /dev/null +++ b/modules/openapi-generator/src/main/resources/cpp-boost-beast-server/responder-header.mustache @@ -0,0 +1,106 @@ +{{>licenseInfo}} +// ============================================================================ +// Responder.h - completion port for one request. Generated per-operation +// responders wrap a shared ResponderCore; completion posts the response +// onto the connection strand exactly once — later completions are rejected +// and logged, the response discarded. +// ============================================================================ +#ifndef {{apiHeaderGuardPrefix}}_SERVER_RESPONDER_H_ +#define {{apiHeaderGuardPrefix}}_SERVER_RESPONDER_H_ + +#include "BodyJson.h" +#include "Problem.h" + +#include + +#include +#include +#include +#include +#include +#include +namespace {{apiNamespace}} { + +/// Type-erased single-shot response completion. The write is posted to the +/// connection's executor; the strand serializes it against other I/O. +class ResponderCore { +public: + using Sink = std::function&&)>; + + explicit ResponderCore(Sink sink) : sink_(std::move(sink)) {} + + /// Completes the request with an already-built response. Second and + /// later completions are rejected; the response is discarded. + void complete(boost::beast::http::response&& response) { + if (completed_.exchange(true)) { + std::cerr << "cpp-boost-beast-server: duplicate responder completion for " + << operationId_ << " ignored\n"; + return; + } + if (sink_) { + sink_(std::move(response)); + } + } + + /// Completes the request with a problem response. + void sendProblem(Problem problem) { + complete(toProblemResponse(std::move(problem))); + } + + /// Completes the request with a JSON body and status. A model whose + /// serialization throws (e.g. a NaN/Infinity stored into a non-finite + /// field by a setter, which boost::json refuses to serialize) answers + /// 500 through the single-completion path instead of propagating out of + /// the connection handler. + template + void sendJson(unsigned status, T const& value, std::string const& contentType) { + namespace http = boost::beast::http; + std::string body; + try { + body = toJsonBody(value); + } catch (std::exception const& error) { + std::cerr << "cpp-boost-beast-server: response serialization" + << " failed for " << operationId_ << ": " + << error.what() << "\n"; + sendProblem(Problem::internal()); + return; + } + http::response res{ + static_cast(status), 11}; + res.set(http::field::server, "openapi-generator-cpp-boost-beast-server"); + res.set(http::field::content_type, contentType); + res.body() = std::move(body); + res.prepare_payload(); + complete(std::move(res)); + } + + /// Completes the request with an empty body and status. + void sendEmpty(unsigned status) { + namespace http = boost::beast::http; + http::response res{ + static_cast(status), 11}; + res.set(http::field::server, "openapi-generator-cpp-boost-beast-server"); + res.prepare_payload(); + complete(std::move(res)); + } + + void sendNotImplemented(std::string const& operationId) { + complete(toProblemResponse(Problem::notImplemented(operationId))); + } + + void setOperationId(std::string operationId) { + operationId_ = std::move(operationId); + } + + bool completed() const { return completed_.load(); } + +private: + Sink sink_; + std::atomic completed_{false}; + std::string operationId_; +}; + +} // namespace {{apiNamespace}} + +#endif // {{apiHeaderGuardPrefix}}_SERVER_RESPONDER_H_ diff --git a/modules/openapi-generator/src/main/resources/cpp-boost-beast-server/router-header.mustache b/modules/openapi-generator/src/main/resources/cpp-boost-beast-server/router-header.mustache new file mode 100644 index 000000000000..142ac49795c0 --- /dev/null +++ b/modules/openapi-generator/src/main/resources/cpp-boost-beast-server/router-header.mustache @@ -0,0 +1,313 @@ +{{>licenseInfo}} +// ============================================================================ +// Router.h - deterministic route table with encoded-segment matching. +// Each path segment is tokenized into the literal/expression parts OpenAPI +// allows, so `{petId}` captures a whole segment and `report-{year}` captures +// just the expression part. Captures keep the raw (still percent-encoded) +// text, so %2F stays inside one path parameter. +// ============================================================================ +#ifndef {{apiHeaderGuardPrefix}}_SERVER_ROUTER_H_ +#define {{apiHeaderGuardPrefix}}_SERVER_ROUTER_H_ + +#include "Responder.h" + +#include +#include +#include +#include +#include +#include +#include + +namespace {{apiNamespace}} { + +/// One security scheme requirement extracted from the OpenAPI document. +struct SchemeRequirement { + std::string name; // scheme name as declared + std::string type; // apiKey | http | unknown + std::string in; // header | query | cookie (apiKey) + std::string paramName; // declared parameter name (apiKey) + std::string httpScheme; // e.g. bearer (http) +}; + +/// OR-of-AND security alternatives for one route. +using SecurityGroups = std::vector>; + +/// Owning request data handed to generated handlers. Query parameters are +/// available both form-decoded (`query`) and as encoded values keyed by their +/// decoded names (`encodedQuery`), so style delimiters can be split before +/// percent-decoding without corrupting escaped data. +struct RequestContext { + std::string method; + std::string target; // normalized origin-form, encoded + std::multimap query; // form-decoded key/value pairs + std::multimap encodedQuery; // decoded keys, ENCODED values + std::map pathParams; // ENCODED raw captures + std::multimap headers; // lowercased names + std::multimap cookies; // decoded + std::string body; + std::string operationId; +}; + +using Handler = std::function, std::shared_ptr)>; + +/// Successful route match. +struct RouteMatch { + Handler handler; + SecurityGroups security; + std::string operationId; + std::map pathParams; +}; + +class Router { +public: + void add(std::string const& method, + std::string const& pathTemplate, + Handler handler, + SecurityGroups security = {}, + std::string operationId = ""); + + /// Matches the encoded origin-form path (query string is ignored). + /// Returns nullopt-shaped result (handler == nullptr) when no template + /// matched the path shape for ANY method. + RouteMatch match(std::string const& method, std::string const& encodedTarget) const; + + /// Comma-separated registered methods for the target path shape, or "". + std::string allowedMethods(std::string const& encodedTarget) const; + + bool empty() const { return routes_.empty(); } + +private: + /// One piece of a path segment: literal text, or a capture under a + /// parameter name (non-empty name = expression part). + struct Token { + std::string literal; + std::string param; + bool isParam() const { return !param.empty(); } + }; + + struct Route { + std::string method; + std::vector> segments; // per-segment tokens + std::size_t literalTokens = 0; // ranking weight + Handler handler; + SecurityGroups security; + std::string operationId; + }; + + static std::vector splitPath(std::string const& target); + static std::vector tokenizeSegment(std::string const& segment); + static bool matches(Route const& route, + std::vector const& segments, + std::map& pathParams); + + std::vector routes_; +}; + +inline void Router::add(std::string const& method, + std::string const& pathTemplate, + Handler handler, + SecurityGroups security, + std::string operationId) { + Route route; + route.method = method; + for (std::string const& segment : splitPath(pathTemplate)) { + std::vector tokens = tokenizeSegment(segment); + for (Token const& token : tokens) { + if (!token.isParam()) { + ++route.literalTokens; + } + } + route.segments.push_back(std::move(tokens)); + } + route.handler = std::move(handler); + route.security = std::move(security); + route.operationId = std::move(operationId); + routes_.push_back(std::move(route)); +} + +inline std::vector Router::splitPath(std::string const& target) { + std::string path = target; + std::size_t query = path.find('?'); + if (query != std::string::npos) { + path.resize(query); + } + std::vector segments; + std::size_t start = 0; + if (!path.empty() && path.front() == '/') { + start = 1; + } + while (start <= path.size()) { + std::size_t slash = path.find('/', start); + if (slash == std::string::npos) { + segments.push_back(path.substr(start)); + break; + } + segments.push_back(path.substr(start, slash - start)); + start = slash + 1; + } + return segments; +} + +/// Splits one template segment into literal and `{name}` expression tokens. +/// Well-formed templates (the generation gate rejects the rest) contain +/// balanced, non-nested, non-empty expressions; malformed remainders stay +/// literal text. +inline std::vector Router::tokenizeSegment( + std::string const& segment) { + std::vector tokens; + std::size_t start = 0; + while (start < segment.size()) { + std::size_t open = segment.find('{', start); + if (open == std::string::npos) { + tokens.push_back(Token{segment.substr(start), ""}); + break; + } + std::size_t close = segment.find('}', open); + if (close == std::string::npos + || segment.find('{', open + 1) < close + || close == open + 1) { + // Unbalanced/nested/empty expression: keep the remainder literal. + tokens.push_back(Token{segment.substr(start), ""}); + break; + } + if (open > start) { + tokens.push_back(Token{segment.substr(start, open - start), ""}); + } + tokens.push_back(Token{"", segment.substr(open + 1, close - open - 1)}); + start = close + 1; + } + return tokens; +} + +inline bool Router::matches(Route const& route, + std::vector const& segments, + std::map& pathParams) { + if (route.segments.size() != segments.size()) { + return false; + } + std::map captures; + for (std::size_t i = 0; i < segments.size(); ++i) { + std::string const& text = segments[i]; + std::vector const& tokens = route.segments[i]; + if (tokens.empty()) { + if (!text.empty()) { + return false; + } + continue; + } + // Walk tokens left to right: literal tokens must match at the cursor; + // expression tokens capture up to the next literal anchor (or to the + // segment end for a trailing expression). A whole-segment {param} + // may not capture an empty value. + std::size_t position = 0; + bool matched = true; + for (std::size_t t = 0; matched && t < tokens.size(); ++t) { + Token const& token = tokens[t]; + if (!token.isParam()) { + if (text.compare(position, token.literal.size(), token.literal) != 0) { + matched = false; + break; + } + position += token.literal.size(); + continue; + } + std::size_t begin = position; + std::size_t end = text.size(); + bool anchored = false; + if (t + 1 < tokens.size()) { + anchored = true; + std::size_t hit = text.find(tokens[t + 1].literal, begin); + if (hit == std::string::npos) { + matched = false; + break; + } + end = hit; + } + if (tokens.size() == 1 && begin == end) { + matched = false; // a whole-segment {param} may not be empty + break; + } + captures[token.param] = text.substr(begin, end - begin); + position = anchored ? end : text.size(); + } + if (!matched || position != text.size()) { + return false; + } + } + for (auto const& capture : captures) { + pathParams[capture.first] = capture.second; + } + return true; +} + +inline RouteMatch Router::match( + std::string const& method, std::string const& encodedTarget) const { + std::vector segments = splitPath(encodedTarget); + // Literal-over-parameter ranking: among routes whose method and shape + // match, the one with the most literal (non-expression) tokens wins; + // ties keep registration order. This makes dispatch independent of + // declaration order, so /pets/bulk beats an earlier /pets/{petId}. + Route const* best = nullptr; + std::map bestParams; + std::size_t bestLiterals = 0; + for (Route const& route : routes_) { + if (route.method != method) { + continue; + } + std::map pathParams; + if (!matches(route, segments, pathParams)) { + continue; + } + if (best == nullptr || route.literalTokens > bestLiterals) { + best = &route; + bestLiterals = route.literalTokens; + bestParams = std::move(pathParams); + } + } + if (best == nullptr) { + RouteMatch none; + none.handler = nullptr; + return none; + } + RouteMatch result; + result.handler = best->handler; + result.security = best->security; + result.operationId = best->operationId; + result.pathParams = std::move(bestParams); + return result; +} + +inline std::string Router::allowedMethods( + std::string const& encodedTarget) const { + std::vector segments = splitPath(encodedTarget); + std::vector allowed; + for (Route const& route : routes_) { + std::map ignored; + if (matches(route, segments, ignored)) { + bool seen = false; + for (std::string const& existing : allowed) { + if (existing == route.method) { + seen = true; + break; + } + } + if (!seen) { + allowed.push_back(route.method); + } + } + } + std::string joined; + for (std::size_t i = 0; i < allowed.size(); ++i) { + if (i != 0) { + joined += ", "; + } + joined += allowed[i]; + } + return joined; +} + +} // namespace {{apiNamespace}} + +#endif // {{apiHeaderGuardPrefix}}_SERVER_ROUTER_H_ diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/cppboostbeast/ModelApiSurfaceTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/cppboostbeast/ModelApiSurfaceTest.java index cc674c854339..d330346ce9e9 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/cppboostbeast/ModelApiSurfaceTest.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/cppboostbeast/ModelApiSurfaceTest.java @@ -1343,6 +1343,47 @@ public void formatAssertionPolicyRejectsUnimplementedStrictMode() { Assert.assertThrows(IllegalArgumentException.class, codegen::processOpts); } + @Test + public void scalarDefaultOnArrayCarrierDegradesToValueInitialization() + throws IOException { + // OpenAI's Eval.testing_criteria declares `default: eval` on an + // array of graders. JSON Schema keeps such a default as an + // annotation, but a generated `= "eval"` cannot even compile + // against std::vector; the decoder must drop it to value + // initialization instead of emitting broken code. + Path outputRoot = Files.createDirectories(Path.of("target")); + Path output = Files.createTempDirectory( + outputRoot, "cpp-boost-beast-array-default-"); + output.toFile().deleteOnExit(); + CodegenConfigurator configurator = new CodegenConfigurator() + .setGeneratorName("cpp-boost-beast-client") + .setInputSpec("src/test/resources/3_1/cpp-boost-beast-client/" + + "oas31-runtime-regression.yaml") + .setOutputDir(output.toString()); + new DefaultGenerator().opts(configurator.toClientOptInput()).generate(); + + String source = Files.readString(output.resolve("model/ScalarDefaults.cpp")); + Assert.assertTrue(source.contains("m_Mismatched_tags = {};"), + "a scalar default on an array carrier must degrade to {}"); + Assert.assertFalse(source.contains("m_Mismatched_tags = \"eval\""), + "a scalar default must never be assigned to a vector member"); + // Genuine scalar defaults on the same model must keep their + // explicit initializers. + Assert.assertTrue(source.contains("m_Retries = std::int32_t{-7};"), + "scalar defaults on scalar carriers must still be emitted"); + // A variant alias whose alternatives include an array keeps its + // scalar default: the initializer decodes through the alias's + // own fromJsonValue, so the container alternative is irrelevant. + String mixed = Files.readString( + output.resolve("model/MixedWithContainer.h")); + Assert.assertTrue(mixed.contains( + "m_Named = fromJsonValue_StringOrTags(boost::json::value(\"alloy\"))"), + "scalar default on a variant-alias carrier with a container " + + "alternative must be kept and decoded via the alias"); + Assert.assertFalse(mixed.contains("m_Named = \"alloy\""), + "a scalar default must never be assigned raw to a variant member"); + } + @Test public void tolerateNonNullableNullsDefaultsOnAndCanBeDisabled() throws IOException { CppBoostBeastClientCodegen defaults = new CppBoostBeastClientCodegen(); diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/cppboostbeast/Oas31ExactRuntimeTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/cppboostbeast/Oas31ExactRuntimeTest.java index 8308c9c64a88..e0463d4a665d 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/cppboostbeast/Oas31ExactRuntimeTest.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/cppboostbeast/Oas31ExactRuntimeTest.java @@ -44,7 +44,7 @@ public void exactRuntimePreservesArbitraryJsonNumbersEndToEnd() throws Exception Path includeDirectory = output; Path executable = output.resolve("oas31-exact-runtime-test"); Path validationTemplate = Path.of( - "src/main/resources/cpp-boost-beast-client/validation-types.mustache"); + "src/main/resources/cpp-boost-beast-common/validation-types.mustache"); String modelNamespace = "org::openapitools::client::model"; String validationNamespace = modelNamespace + "::detail::schema_validation"; String validationGuard = @@ -53,6 +53,7 @@ public void exactRuntimePreservesArbitraryJsonNumbersEndToEnd() throws Exception .replace("{{>licenseInfo}}", "") .replace("{{validateOnDecode}}", "true") .replace("{{schemaValidationHeaderGuardPrefix}}", validationGuard) + .replace("{{schemaValidationNamespace}}", validationNamespace) .replace("{{#modelNamespaceDeclarations}}\nnamespace {{this}} {\n" + "{{/modelNamespaceDeclarations}}", "namespace " + modelNamespace + " {") @@ -807,7 +808,7 @@ private static void writeValidationSupportHeaders( Path output, String namespaceName, String guardPrefix) throws IOException { - Path templateDirectory = Path.of("src/main/resources/cpp-boost-beast-client"); + Path templateDirectory = Path.of("src/main/resources/cpp-boost-beast-common"); String[][] headers = { {"oas31_exact_number.mustache", "Oas31ExactNumber.h"}, {"oas31_exact_json.mustache", "Oas31ExactJson.h"}, diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/cppboostbeastserver/CppBoostBeastServerCodegenTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/cppboostbeastserver/CppBoostBeastServerCodegenTest.java new file mode 100644 index 000000000000..267b7a1e400d --- /dev/null +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/cppboostbeastserver/CppBoostBeastServerCodegenTest.java @@ -0,0 +1,1100 @@ +/* + * 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.cppboostbeastserver; + +import org.openapitools.codegen.CodegenType; +import org.openapitools.codegen.DefaultGenerator; +import org.openapitools.codegen.config.CodegenConfigurator; +import org.testng.Assert; +import org.testng.annotations.Test; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +public class CppBoostBeastServerCodegenTest { + + private static final String SERVER_REGRESSION_SPEC = + "src/test/resources/3_1/cpp-boost-beast-server/server-regression.yaml"; + + private static Path generate(String spec, java.util.Map properties) + throws IOException { + Path outputRoot = Files.createDirectories(Path.of("target")); + Path output = Files.createTempDirectory(outputRoot, "cpp-boost-beast-server-test-"); + output.toFile().deleteOnExit(); + CodegenConfigurator configurator = new CodegenConfigurator() + .setGeneratorName("cpp-boost-beast-server") + .setInputSpec(spec) + .setOutputDir(output.toString()); + properties.forEach(configurator::addAdditionalProperty); + new DefaultGenerator().opts(configurator.toClientOptInput()).generate(); + return output; + } + + private static Path writeTempSpec(String... lines) throws IOException { + Path spec = Files.createTempFile("cpp-boost-beast-server-spec-", ".yaml"); + spec.toFile().deleteOnExit(); + Files.writeString(spec, String.join("\n", lines) + "\n"); + return spec.toAbsolutePath(); + } + + private static final String[] HEADER = { + "openapi: 3.1.0", "info: {title: t, version: '1'}", "paths:"}; + + private static String[] spec(String... body) { + String[] all = new String[body.length + HEADER.length]; + System.arraycopy(HEADER, 0, all, 0, HEADER.length); + System.arraycopy(body, 0, all, HEADER.length, body.length); + return all; + } + + @Test + public void defaultsAreConfigured() { + org.openapitools.codegen.languages.CppBoostBeastServerCodegen codegen = + new org.openapitools.codegen.languages.CppBoostBeastServerCodegen(); + Assert.assertEquals(codegen.getName(), "cpp-boost-beast-server"); + Assert.assertEquals(codegen.getTag(), CodegenType.SERVER); + Assert.assertEquals(codegen.modelPackage(), "org.openapitools.server.model"); + Assert.assertEquals(codegen.apiPackage(), "org.openapitools.server.api"); + Assert.assertTrue(codegen.getOutputDir().contains("cpp-boost-beast-server")); + List destinations = codegen.supportingFiles().stream() + .map(file -> file.getDestinationFilename()) + .sorted() + .collect(java.util.stream.Collectors.toList()); + Assert.assertTrue(destinations.contains("HttpServer.h"), + "runtime HttpServer.h must be a supporting file"); + Assert.assertTrue(destinations.contains("BodyJson.h"), + "runtime BodyJson.h must be a supporting file"); + Assert.assertTrue(destinations.contains("Oas31Validator.h"), + "shared validation header must be a supporting file"); + Assert.assertTrue(destinations.contains("schema_ir.generated.cpp"), + "schema IR source must be a supporting file"); + Assert.assertFalse(destinations.contains("main.cpp"), + "main.cpp must not be generated without addApiImplStubs"); + } + + @Test + public void generatesFullContractFromRegressionSpec() throws IOException { + Path output = generate(SERVER_REGRESSION_SPEC, java.util.Map.of()); + + String apiHeader = Files.readString(output.resolve("api/DefaultApi.h")); + Assert.assertTrue(apiHeader.contains("virtual void getPetById("), + "service interface must declare getPetById"); + Assert.assertTrue(apiHeader.contains("struct GetPetByIdRequest"), + "request struct must be named from the operationId"); + Assert.assertTrue(apiHeader.contains("void send200(Pet value) const"), + "responder must expose send200 for the Pet response"); + Assert.assertTrue(apiHeader.contains("void send204() const"), + "responder must expose the empty 204"); + Assert.assertTrue(apiHeader.contains( + "void sendDefault(ErrorResponse value, unsigned status) const"), + "responder must expose the default ErrorResponse sender"); + + String apiSource = Files.readString(output.resolve("api/DefaultApi.cpp")); + Assert.assertTrue(apiSource.contains("router->add("), + "route registration must be emitted"); + Assert.assertTrue(apiSource.contains("splitQueryParameter(values.first->second, '|')"), + "pipe-delimited query collection must split before decoding"); + Assert.assertTrue(apiSource.contains("std::regex"), + "pattern constraints must emit a regex check"); + Assert.assertTrue(apiSource.contains("kAllowed"), + "enum constraints must emit an allow-list check"); + Assert.assertTrue(apiSource.contains("isExactMultipleOf(text, \"0.1\")"), + "number multipleOf must be checked against the exact wire lexeme"); + Assert.assertTrue(apiSource.contains("isExactMultipleOf(decodedElement, \"2\")"), + "decoded query-array items must drive the multipleOf check"); + + Assert.assertTrue(Files.exists(output.resolve("server/HttpServer.h"))); + Assert.assertTrue(Files.exists(output.resolve("server/Router.h"))); + Assert.assertTrue(Files.exists(output.resolve("server/Responder.h"))); + Assert.assertTrue(Files.exists(output.resolve("server/Problem.h"))); + Assert.assertTrue(Files.exists(output.resolve("server/ParamCodecs.h"))); + String paramCodecs = Files.readString(output.resolve("server/ParamCodecs.h")); + Assert.assertTrue(paramCodecs.contains("percentDecode(encoded, true)"), + "query form decoding must translate plus to space"); + Assert.assertTrue(paramCodecs.contains("boost::multiprecision::powm"), + "exact multipleOf must support large decimal exponents"); + String httpServer = Files.readString(output.resolve("server/HttpServer.cpp")); + Assert.assertTrue(httpServer.contains("parse_absolute_uri"), + "absolute-form request targets must be accepted and normalized"); + Assert.assertTrue(httpServer.contains("requestIsHead_"), + "HEAD responses must suppress body bytes"); + String cmake = Files.readString(output.resolve("CMakeLists.txt")); + Assert.assertTrue(cmake.contains( + "option(CPP_BOOST_BEAST_SERVER_WERROR \"Treat compiler warnings as errors\" ON)"), + "generated CMake must enable warnings-as-errors by default"); + Assert.assertTrue(cmake.contains( + "if (CPP_BOOST_BEAST_SERVER_WERROR)\n add_compile_options(/WX)"), + "MSVC /WX must remain conditional on the WERROR option"); + Assert.assertTrue(Files.exists(output.resolve("server/BodyJson.h"))); + Assert.assertTrue(Files.exists(output.resolve("model/Pet.h")), + "models must be generated"); + } + + @Test + public void addApiImplStubsEmitsMainAndStubs() throws IOException { + Path output = generate(SERVER_REGRESSION_SPEC, + java.util.Map.of("addApiImplStubs", Boolean.TRUE)); + Assert.assertTrue(Files.exists(output.resolve("main.cpp")), + "addApiImplStubs must generate main.cpp"); + String apiHeader = Files.readString(output.resolve("api/DefaultApi.h")); + Assert.assertTrue(apiHeader.contains("class DefaultApiStub : public DefaultApi"), + "addApiImplStubs must generate a stub service"); + String main = Files.readString(output.resolve("main.cpp")); + Assert.assertTrue( + main.contains("DefaultApi::attach(server, std::make_shared())"), + "main.cpp must attach the stub service"); + Assert.assertTrue(main.contains("_dupenv_s(&rawPortText"), + "MSVC quick start must avoid deprecated getenv warnings under /WX"); + } + + @Test + public void compileWithValidationFalseStripsSchemaIr() throws IOException { + Path output = generate(SERVER_REGRESSION_SPEC, + java.util.Map.of("compileWithValidation", Boolean.FALSE)); + Assert.assertFalse(Files.exists(output.resolve("model/schema_ir.generated.cpp")), + "IR source must be stripped when validation is disabled"); + Assert.assertFalse(Files.exists(output.resolve("model/Oas31SchemaRegistry.h")), + "IR registry must be stripped when validation is disabled"); + String cmake = Files.readString(output.resolve("CMakeLists.txt")); + Assert.assertFalse(cmake.contains("schema_ir.generated"), + "CMake must not reference the stripped IR"); + Assert.assertTrue(Files.exists(output.resolve("model/Oas31Validator.h")), + "header-only validator must remain"); + } + + @Test + public void degradesMultipartOnlyBodyToNoTypedBody() throws IOException { + Path output = generate(writeTempSpec(spec( + " /upload:", + " post:", + " operationId: upload", + " requestBody:", + " content:", + " multipart/form-data:", + " schema:", + " type: object", + " responses:", + " '200': {description: ok}")).toString(), + java.util.Map.of()); + String apiHeader = Files.readString(output.resolve("api/DefaultApi.h")); + Assert.assertTrue(apiHeader.contains("struct UploadRequest"), + "operation must still generate its request contract"); + Assert.assertFalse(apiHeader.contains("body{};"), + "a multipart-only body must degrade to no typed body field"); + } + + @Test + public void degradesFormUrlencodedBodyToNoTypedBody() throws IOException { + Path output = generate(writeTempSpec(spec( + " /form:", + " post:", + " operationId: submit", + " requestBody:", + " content:", + " application/x-www-form-urlencoded:", + " schema:", + " type: object", + " responses:", + " '200': {description: ok}")).toString(), + java.util.Map.of()); + String apiHeader = Files.readString(output.resolve("api/DefaultApi.h")); + Assert.assertTrue(apiHeader.contains("struct SubmitRequest"), + "operation must still generate its request contract"); + Assert.assertFalse(apiHeader.contains("body{};"), + "a form-urlencoded-only body must degrade to no typed body"); + } + + @Test + public void keepsJsonMemberAndDropsXmlMemberFromMixedBody() throws IOException { + Path output = generate(writeTempSpec(spec( + " /mixed:", + " post:", + " operationId: mixed", + " requestBody:", + " required: true", + " content:", + " application/json:", + " schema: {type: object}", + " application/xml:", + " schema: {type: object}", + " responses:", + " '200': {description: ok}")).toString(), + java.util.Map.of()); + String apiSource = Files.readString(output.resolve("api/DefaultApi.cpp")); + Assert.assertTrue( + apiSource.contains("\"application/json\" };"), + "declared media types must be filtered to the JSON member"); + Assert.assertFalse(apiSource.contains("application/xml"), + "the XML member must not appear in the accepted list"); + } + + @Test + public void textPlainResponseDegradesToJsonSerialization() throws IOException { + Path output = generate(writeTempSpec(spec( + " /text:", + " get:", + " operationId: getText", + " responses:", + " '200':", + " description: ok", + " content:", + " text/plain:", + " schema: {type: string}")).toString(), + java.util.Map.of()); + String apiHeader = Files.readString(output.resolve("api/DefaultApi.h")); + Assert.assertTrue(apiHeader.contains("void send200(std::string value) const"), + "response model must serialize as JSON regardless of " + + "the declared text media type"); + } + + @Test + public void eventStreamResponseDegradesToJsonSerialization() throws IOException { + Path output = generate(writeTempSpec(spec( + " /stream:", + " get:", + " operationId: stream", + " responses:", + " '200':", + " description: events", + " content:", + " text/event-stream:", + " schema: {type: string}")).toString(), + java.util.Map.of()); + String apiHeader = Files.readString(output.resolve("api/DefaultApi.h")); + Assert.assertTrue(apiHeader.contains("void send200(std::string value) const"), + "SSE declaration must degrade to a plain JSON sender"); + } + @Test + public void degradesContentStyleParameterToDroppedField() throws IOException { + Path output = generate(writeTempSpec(spec( + " /p:", + " get:", + " operationId: op", + " parameters:", + " - name: token", + " in: query", + " content:", + " application/json:", + " schema: {type: string}", + " responses:", + " '200': {description: ok}")).toString(), + java.util.Map.of()); + String apiHeader = Files.readString(output.resolve("api/DefaultApi.h")); + Assert.assertTrue(apiHeader.contains("struct OpRequest"), + "operation must still generate its request contract"); + Assert.assertFalse(apiHeader.contains("token{}"), + "a content-style parameter must be dropped from the handler"); + } + + @Test + public void degradesCookieMatrixStyleParameter() throws IOException { + Path output = generate(writeTempSpec(spec( + " /c:", + " get:", + " operationId: op", + " parameters:", + " - name: session", + " in: cookie", + " style: matrix", + " schema: {type: string}", + " responses:", + " '200': {description: ok}")).toString(), + java.util.Map.of()); + String apiHeader = Files.readString(output.resolve("api/DefaultApi.h")); + Assert.assertTrue(apiHeader.contains("struct OpRequest"), + "operation must still generate its request contract"); + Assert.assertFalse(apiHeader.contains("session{}"), + "a cookie parameter with an unsupported style must be dropped"); + } + + @Test + public void degradesCookieArrayParameter() throws IOException { + Path output = generate(writeTempSpec(spec( + " /c:", + " get:", + " operationId: op", + " parameters:", + " - name: session", + " in: cookie", + " schema: {type: array, items: {type: string}}", + " responses:", + " '200': {description: ok}")).toString(), + java.util.Map.of()); + String apiHeader = Files.readString(output.resolve("api/DefaultApi.h")); + Assert.assertTrue(apiHeader.contains("struct OpRequest"), + "operation must still generate its request contract"); + Assert.assertFalse(apiHeader.contains("session{}"), + "an array cookie parameter must be dropped; the codec is scalar-only"); + } + + @Test + public void degradesObjectQueryParameterWithoutDeepObject() throws IOException { + Path output = generate(writeTempSpec(spec( + " /q:", + " get:", + " operationId: op", + " parameters:", + " - name: filter", + " in: query", + " schema: {type: object, additionalProperties: {type: string}}", + " responses:", + " '200': {description: ok}")).toString(), + java.util.Map.of()); + String apiHeader = Files.readString(output.resolve("api/DefaultApi.h")); + Assert.assertTrue(apiHeader.contains("struct OpRequest"), + "operation must still generate its request contract"); + Assert.assertFalse(apiHeader.contains("filter{}"), + "an object query parameter outside deepObject must be dropped"); + } + + @Test + public void acceptsDeepObjectStringMapQueryParameter() throws IOException { + Path output = generate(writeTempSpec(spec( + " /q:", + " get:", + " operationId: op", + " parameters:", + " - name: filter", + " in: query", + " style: deepObject", + " schema: {type: object, additionalProperties: {type: string}}", + " responses:", + " '200': {description: ok}")).toString(), java.util.Map.of()); + String apiHeader = Files.readString(output.resolve("api/DefaultApi.h")); + Assert.assertTrue( + apiHeader.contains("std::map filter{}"), + "deepObject string map must generate as a std::map field"); + } + + @Test + public void degradesNonScalarArrayParameterItems() throws IOException { + Path output = generate(writeTempSpec(spec( + " /q:", + " get:", + " operationId: op", + " parameters:", + " - name: things", + " in: query", + " schema: {type: array, items: {type: object}}", + " responses:", + " '200': {description: ok}")).toString(), + java.util.Map.of()); + String apiHeader = Files.readString(output.resolve("api/DefaultApi.h")); + Assert.assertTrue(apiHeader.contains("struct OpRequest"), + "operation must still generate its request contract"); + Assert.assertFalse(apiHeader.contains("things{}"), + "an array parameter with non-scalar items must be dropped"); + } + + @Test + public void degradesHeterogeneousParameterEnum() throws IOException { + Path output = generate(writeTempSpec(spec( + " /q:", + " get:", + " operationId: op", + " parameters:", + " - name: mixed", + " in: query", + " schema: {type: string, enum: [alpha, 1]}", + " responses:", + " '200': {description: ok}")).toString(), + java.util.Map.of()); + String apiHeader = Files.readString(output.resolve("api/DefaultApi.h")); + Assert.assertTrue(apiHeader.contains("struct OpRequest"), + "operation must still generate its request contract"); + Assert.assertFalse(apiHeader.contains("mixed{}"), + "a parameter with a mixed string/numeric enum must be dropped"); + } + + @Test + public void rejectsAmbiguousRouteShapes() throws IOException { + Path spec = writeTempSpec(spec( + " /a/{x}/c:", + " get:", + " operationId: first", + " responses:", + " '200': {description: ok}", + " /a/{y}/c:", + " get:", + " operationId: second", + " responses:", + " '200': {description: ok}")); + CodegenConfigurator configurator = new CodegenConfigurator() + .setGeneratorName("cpp-boost-beast-server") + .setInputSpec(spec.toString()) + .setOutputDir(Files.createTempDirectory("routes-").toString()) + .setValidateSpec(false); + IllegalArgumentException error = Assert.expectThrows( + IllegalArgumentException.class, + () -> new DefaultGenerator() + .opts(configurator.toClientOptInput()).generate()); + Assert.assertTrue(error.getMessage().contains("/a/{x}/c"), + "diagnostic must list the first template"); + Assert.assertTrue(error.getMessage().contains("/a/{y}/c"), + "diagnostic must list the second template"); + } + + @Test + public void rejectsQuerySuffixRouteAsSameShape() throws IOException { + // Router::splitPath strips the query string at REGISTRATION time, so + // '/responses?beta=true' registers the very same route as + // '/responses' — a literal duplicate, not a ranking puzzle. The + // shape gate must say exactly that (the same wording as two + // same-shape templates), instead of letting the pair fall through to + // the witness probe and mislabelling it as registration-order + // ambiguity. This is the OpenAI document's beta-path idiom. + Path spec = writeTempSpec(spec( + " /responses:", + " post:", + " operationId: createResponse", + " responses:", + " '200': {description: ok}", + " /responses?beta=true:", + " post:", + " operationId: betaCreateResponse", + " responses:", + " '200': {description: ok}")); + CodegenConfigurator configurator = new CodegenConfigurator() + .setGeneratorName("cpp-boost-beast-server") + .setInputSpec(spec.toString()) + .setOutputDir(Files.createTempDirectory("query-dup-").toString()) + .setValidateSpec(false); + IllegalArgumentException error = Assert.expectThrows( + IllegalArgumentException.class, + () -> new DefaultGenerator() + .opts(configurator.toClientOptInput()).generate()); + Assert.assertTrue(error.getMessage().contains("have the same shape"), + "a ?-suffixed duplicate must be reported as the same shape, got: " + + error.getMessage()); + } + + @Test + public void rejectsRangedResponseCodes() throws IOException { + Path spec = writeTempSpec(spec( + " /r:", + " get:", + " operationId: ranged", + " responses:", + " '2XX':", + " description: any success", + " content:", + " application/json:", + " schema: {type: string}")); + IllegalArgumentException error = Assert.expectThrows( + IllegalArgumentException.class, + () -> generate(spec.toString(), java.util.Map.of())); + Assert.assertTrue(error.getMessage().contains("2XX"), + "diagnostic must name the ranged code"); + } + + @Test + public void unsupportedSecuritySchemeDegradesToRuntimeDenial() throws IOException { + Path output = generate(writeTempSpec( + "openapi: 3.1.0", + "info: {title: t, version: '1'}", + "security:", + " - oauth: []", + "paths:", + " /o:", + " get:", + " operationId: needsOauth", + " responses:", + " '200': {description: ok}", + "components:", + " securitySchemes:", + " oauth:", + " type: oauth2", + " flows: {}").toString(), java.util.Map.of()); + String apiSource = Files.readString(output.resolve("api/DefaultApi.cpp")); + // The requirement survives into the route table with its declared + // type; the runtime has no credential extractor for oauth2, so + // structurallySatisfied() denies every request (401) rather than + // silently allowing it. + Assert.assertTrue(apiSource.contains("\"oauth\", \"oauth2\""), + "the oauth requirement must stay in the route table as type oauth2"); + } + + @Test + public void generatesFromCanonicalPetstore() throws IOException { + // The repository harness (AllGeneratorsTest) requires every + // registered generator to accept src/test/resources/3_0/petstore.yaml, + // which mixes XML/form/multipart payloads with an oauth2 scheme. + // This pins the degrade contract for the canonical spec. + Path output = generate("src/test/resources/3_0/petstore.yaml", + java.util.Map.of()); + Assert.assertTrue(Files.exists(output.resolve("api/PetApi.cpp")) + || Files.exists(output.resolve("api/PetsApi.cpp")), + "the canonical petstore must generate API sources"); + } + + @Test + public void normalizesMediaTypeParametersInFacts() throws IOException { + Path spec = writeTempSpec(spec( + " /m:", + " post:", + " operationId: mediaParams", + " requestBody:", + " content:", + " 'application/json; charset=utf-8':", + " schema: {type: string}", + " responses:", + " '200': {description: ok}")); + Path output = generate(spec.toString(), java.util.Map.of()); + String apiSource = Files.readString(output.resolve("api/DefaultApi.cpp")); + Assert.assertTrue(apiSource.contains("\"application/json\""), + "declared media-type parameters must be stripped in kMediaTypes"); + Assert.assertFalse(apiSource.contains("charset"), + "charset must not survive into the generated match list"); + } + + @Test + public void resolvesRefRequestBodiesAndParameters() throws IOException { + Path spec = writeTempSpec( + "openapi: 3.1.0", + "info: {title: t, version: '1'}", + "paths:", + " /ref:", + " post:", + " operationId: refBody", + " requestBody:", + " $ref: '#/components/requestBodies/PetBody'", + " parameters:", + " - $ref: '#/components/parameters/Tier'", + " responses:", + " '200': {description: ok}", + "components:", + " requestBodies:", + " PetBody:", + " content:", + " application/json:", + " schema: {type: string}", + " parameters:", + " Tier:", + " name: tier", + " in: query", + " schema:", + " type: integer", + " enum: [10, 20]"); + Path output = generate(spec.toString(), java.util.Map.of()); + String apiHeader = Files.readString(output.resolve("api/DefaultApi.h")); + Assert.assertTrue(apiHeader.contains("RefBodyRequest"), + "request struct must exist for the $ref-body operation"); + String apiSource = Files.readString(output.resolve("api/DefaultApi.cpp")); + Assert.assertTrue(apiSource.contains("fromJsonBody"), + "$ref request body must still be decoded"); + Assert.assertTrue(apiSource.contains("kAllowed"), + "$ref parameter constraints must survive"); + } + + @Test + public void generatesFromOas30Spec() throws IOException { + // The shared pipeline must keep 3.0 documents working (JSON-only). + Path spec = writeTempSpec( + "openapi: 3.0.3", + "info: {title: t, version: '1'}", + "paths:", + " /pets/{petId}:", + " get:", + " operationId: getPet", + " parameters:", + " - name: petId", + " in: path", + " required: true", + " schema: {type: integer, format: int64}", + " responses:", + " '200':", + " description: ok", + " content:", + " application/json:", + " schema: {type: string}"); + Path output = generate(spec.toString(), java.util.Map.of()); + Assert.assertTrue(Files.exists(output.resolve("api/DefaultApi.h")), + "3.0 spec must generate the API"); + Assert.assertTrue(Files.exists(output.resolve("model/ValidationTypes.h")), + "3.0 spec must generate the shared validation runtime"); + } + + @Test + public void untypedFreeFormSchemasResolveWithoutBaseSentinel() throws IOException { + // DefaultCodegen seeds AnyType -> oas_any_type_not_mapped, a header + // placeholder this generator family never provides. The OpenAI + // corpus (FunctionToolParam_output_schema) reaches it through an + // anyOf of a freeform object and null; the server must resolve the + // branch to boost::json::value exactly like the client, which wipes + // the inherited map. + Path output = generate(writeTempSpec(spec( + " /hold:", + " post:", + " operationId: hold", + " requestBody:", + " required: true", + " content:", + " application/json:", + " schema: { $ref: '#/components/schemas/Holder' }", + " responses:", + " '200': {description: ok}", + "components:", + " schemas:", + " Holder:", + " type: object", + " properties:", + " output_schema:", + " anyOf:", + " - additionalProperties: {}", + " type: object", + " - type: \"null\"")).toString(), + java.util.Map.of()); + String wrapper = Files.readString( + output.resolve("model/Holder_output_schema.h")); + Assert.assertFalse(wrapper.contains("oas_any_type_not_mapped"), + "the base AnyType placeholder must never reach generated sources"); + Assert.assertTrue(wrapper.contains("boost::json::value"), + "the freeform branch must resolve to boost::json::value"); + } + + @Test + public void qualifiesBodyModelCollidingWithRequestStruct() throws IOException { + // operationId 'echo' yields struct EchoRequest; a body model of the + // same name would be re-declared inside it (injected-class-name), so + // the field type must be namespace-qualified. + Path output = generate(writeTempSpec(spec( + " /echo:", + " post:", + " operationId: echo", + " requestBody:", + " required: true", + " content:", + " application/json:", + " schema: { $ref: '#/components/schemas/EchoRequest' }", + " responses:", + " '200': {description: ok}", + "components:", + " schemas:", + " EchoRequest:", + " type: object", + " properties:", + " text: {type: string}")).toString(), java.util.Map.of()); + String apiHeader = Files.readString(output.resolve("api/DefaultApi.h")); + Assert.assertTrue(apiHeader.contains( + "org.openapitools.server.model::EchoRequest body{};") + || apiHeader.contains( + "org::openapitools::server::model::EchoRequest body{};"), + "a colliding body model must be qualified, not shadowed"); + } + + @Test + public void recoversTypedBodyAndIncludeForMixedJsonMember() throws IOException { + // multipart + JSON: DefaultCodegen flattens the form fields and never + // imports the JSON member's model. The assembler must both type the + // body from the recovered $ref and append its #include. + Path output = generate(writeTempSpec(spec( + " /edit:", + " post:", + " operationId: edit", + " requestBody:", + " required: true", + " content:", + " multipart/form-data:", + " schema:", + " type: object", + " properties:", + " image: {type: string, format: binary}", + " application/json:", + " schema: { $ref: '#/components/schemas/EditBody' }", + " responses:", + " '200': {description: ok}", + "components:", + " schemas:", + " EditBody:", + " type: object", + " properties:", + " prompt: {type: string}")).toString(), java.util.Map.of()); + String apiHeader = Files.readString(output.resolve("api/DefaultApi.h")); + Assert.assertTrue(apiHeader.contains("#include \"EditBody.h\""), + "the recovered body model's header must be included"); + Assert.assertTrue(apiHeader.contains("EditBody body{};"), + "the JSON member's model must type the body field"); + Assert.assertFalse(apiHeader.contains("image{}"), + "form-field parameters must not appear as handler fields"); + } + + @Test + public void degradesBodyModelAliasedToVariant() throws IOException { + // A named oneOf component is a model, so it passes the model-name + // check; but its C++ type is std::variant, which fromJsonLeaf cannot + // decode. The handler must receive no typed body. + Path output = generate(writeTempSpec(spec( + " /union:", + " post:", + " operationId: sendUnion", + " requestBody:", + " required: true", + " content:", + " application/json:", + " schema: { $ref: '#/components/schemas/UnionBody' }", + " responses:", + " '200': {description: ok}", + "components:", + " schemas:", + " UnionBody:", + " oneOf:", + " - { $ref: '#/components/schemas/Alpha' }", + " - { $ref: '#/components/schemas/Beta' }", + " Alpha:", + " type: object", + " properties: {a: {type: string}}", + " Beta:", + " type: object", + " properties: {b: {type: string}}")).toString(), + java.util.Map.of()); + String apiHeader = Files.readString(output.resolve("api/DefaultApi.h")); + Assert.assertTrue(apiHeader.contains("struct SendUnionRequest"), + "operation must still generate its request contract"); + Assert.assertFalse(apiHeader.contains("body{}"), + "a union body must degrade to no typed body field"); + } + + @Test + public void degradesEnumClassTypedQueryParameter() throws IOException { + // A $ref'd string-enum component gives the parameter the enum class + // as dataType; parseScalar has no overload for it, so the parameter + // degrades rather than failing to compile. + Path output = generate(writeTempSpec(spec( + " /colored:", + " get:", + " operationId: colored", + " parameters:", + " - name: color", + " in: query", + " schema: { $ref: '#/components/schemas/Color' }", + " responses:", + " '200': {description: ok}", + "components:", + " schemas:", + " Color:", + " type: string", + " enum: [red, green, blue]")).toString(), java.util.Map.of()); + String apiHeader = Files.readString(output.resolve("api/DefaultApi.h")); + Assert.assertTrue(apiHeader.contains("struct ColoredRequest"), + "operation must still generate its request contract"); + Assert.assertFalse(apiHeader.contains("color{}"), + "an enum-class-typed parameter must be dropped"); + } + + @Test + public void typesJsonBodyWhenXmlDeclaredFirst() throws IOException { + // DefaultCodegen types the body parameter from the FIRST content + // entry. When that entry is a media type the runtime cannot parse + // (XML here) but a JSON member names a model, the handler must be + // typed from the JSON representation, not the dropped one. + Path output = generate(writeTempSpec(spec( + " /switch:", + " post:", + " operationId: switch", + " requestBody:", + " required: true", + " content:", + " application/xml:", + " schema: { $ref: '#/components/schemas/XmlBody' }", + " application/json:", + " schema: { $ref: '#/components/schemas/JsonBody' }", + " responses:", + " '200': {description: ok}", + "components:", + " schemas:", + " XmlBody:", + " type: object", + " properties: {xml: {type: string}}", + " JsonBody:", + " type: object", + " properties: {json: {type: string}}")).toString(), + java.util.Map.of()); + String apiHeader = Files.readString(output.resolve("api/DefaultApi.h")); + Assert.assertTrue(apiHeader.contains("JsonBody body{};"), + "the JSON member's model must type the body when XML came first"); + Assert.assertFalse(apiHeader.contains("XmlBody body{};"), + "the dropped XML representation must not type the body field"); + } + + @Test + public void qualifiesReadmeSendTypeForModelResponses() throws IOException { + // The README quick-start class lives outside the generated api + // namespace, where the header's `using namespace ;` is not in + // effect, so model response types must carry the `model::` alias. + Path output = generate(SERVER_REGRESSION_SPEC, java.util.Map.of()); + String readme = Files.readString(output.resolve("README.md")); + // Operations render alphabetically; the first one (codec) responds + // with the Report model, which must carry the `model::` alias. + Assert.assertTrue(readme.contains("model::Report value{};"), + "the quick-start must qualify the model response type"); + } + + @Test + public void servesTaggedVariantResponseTyped() throws IOException { + // A oneOf whose branches share a C++ type is a std::variant of tagged + // CompositionBranchValue members. Responses serialize by visiting the + // active branch (bodyLeaf unwrap), so the sender stays typed. + Path output = generate(SERVER_REGRESSION_SPEC, java.util.Map.of()); + String apiHeader = Files.readString(output.resolve("api/DefaultApi.h")); + Assert.assertTrue(apiHeader.contains("void send200(Pick value) const"), + "the variant response must generate a typed sender"); + } + + @Test + public void rejectsCrossLayoutRouteOverlapWithWitness() throws IOException { + // /a/{x}b and /a/a{y} have different shape keys but both match /a/ab + // with equal ranking; the witness probe must prove the collision. + Path spec = writeTempSpec(spec( + " /a/{x}b:", + " get:", + " operationId: first", + " responses:", + " '200': {description: ok}", + " /a/a{y}:", + " get:", + " operationId: second", + " responses:", + " '200': {description: ok}")); + CodegenConfigurator configurator = new CodegenConfigurator() + .setGeneratorName("cpp-boost-beast-server") + .setInputSpec(spec.toString()) + .setOutputDir(Files.createTempDirectory("overlap-").toString()) + .setValidateSpec(false); + IllegalArgumentException error = Assert.expectThrows( + IllegalArgumentException.class, + () -> new DefaultGenerator() + .opts(configurator.toClientOptInput()).generate()); + Assert.assertTrue(error.getMessage().contains("/a/ab"), + "diagnostic must name the ambiguous witness path, got: " + + error.getMessage()); + } + + @Test + public void rejectsOverlapOfDistinctWholeSegmentCaptures() throws IOException { + // '/{p}/a/b' and '/{q}/a{r}/b' have different shape keys and equal + // literal-token ranking (2 each), and BOTH match '/c/a/b'. The + // first segment is a whole-segment capture on each side, and the + // router forbids empty whole-segment captures, so every witness + // needs a char NEITHER pattern consumes. A search that skips + // steps where both sides merely absorb a char "proves" the first + // segment disjoint (empty option list) and lets this ambiguous + // route table through; the root both-absorb step must be taken. + Path spec = writeTempSpec(spec( + " /{p}/a/b:", + " get:", + " operationId: first", + " responses:", + " '200': {description: ok}", + " /{q}/a{r}/b:", + " get:", + " operationId: second", + " responses:", + " '200': {description: ok}")); + CodegenConfigurator configurator = new CodegenConfigurator() + .setGeneratorName("cpp-boost-beast-server") + .setInputSpec(spec.toString()) + .setOutputDir(Files.createTempDirectory("overlap-stretch-").toString()) + .setValidateSpec(false); + IllegalArgumentException error = Assert.expectThrows( + IllegalArgumentException.class, + () -> new DefaultGenerator() + .opts(configurator.toClientOptInput()).generate()); + // 'c' is the spare filler char: absent from both templates. + Assert.assertTrue(error.getMessage().contains("/c/a/b"), + "diagnostic must name the ambiguous witness path, got: " + + error.getMessage()); + } + + @Test + public void provesOverlapForNearBudgetLiteralInitialSegments() throws IOException { + // The second segment flattens to exactly 125 chars on BOTH sides and + // starts with a literal on both, so the root both-absorb level is + // unreachable for it. The old cell guard charged that level anyway: + // 126x126x252 = 4,000,752 cells crossed the 4M budget and the + // witness probe under-reported a collision that a pre-root-absorb + // budget (126x126x251 = 3,984,876) searches completely. '/l{c}/l' + // vs '/l{c}ll' style layouts: different shape keys, equal literal + // token counts, and both match the all-'l' path segment. + String tail = "l".repeat(123); + Path spec = writeTempSpec(spec( + " /api/l{c1}" + tail + ":", + " get:", + " operationId: first", + " responses:", + " '200': {description: ok}", + " /api/ll{c2}" + "l".repeat(122) + ":", + " get:", + " operationId: second", + " responses:", + " '200': {description: ok}")); + CodegenConfigurator configurator = new CodegenConfigurator() + .setGeneratorName("cpp-boost-beast-server") + .setInputSpec(spec.toString()) + .setOutputDir(Files.createTempDirectory("overlap-budget-").toString()) + .setValidateSpec(false); + IllegalArgumentException error = Assert.expectThrows( + IllegalArgumentException.class, + () -> new DefaultGenerator() + .opts(configurator.toClientOptInput()).generate()); + // Each template forces at least 124 literal 'l' chars into the + // segment (captures may be empty, so 124 is the shortest joint + // match). Pin the exact 'both match' diagnostic: a looser prefix + // would pass on a wrong-length witness. + Assert.assertTrue(error.getMessage().contains( + "both match '/api/" + "l".repeat(124) + "' with equal ranking"), + "diagnostic must name the exact ambiguous witness path, got: " + + error.getMessage()); + } + + @Test + public void rejectsAdjacentPathExpressions() throws IOException { + // The router cannot split a capture boundary with no literal between + // expressions, so /a/{first}{second} must be rejected up front. + Path spec = writeTempSpec(spec( + " /a/{first}{second}:", + " get:", + " operationId: adjacent", + " responses:", + " '200': {description: ok}")); + CodegenConfigurator configurator = new CodegenConfigurator() + .setGeneratorName("cpp-boost-beast-server") + .setInputSpec(spec.toString()) + .setOutputDir(Files.createTempDirectory("adjacent-").toString()) + .setValidateSpec(false); + IllegalArgumentException error = Assert.expectThrows( + IllegalArgumentException.class, + () -> new DefaultGenerator() + .opts(configurator.toClientOptInput()).generate()); + Assert.assertTrue(error.getMessage().contains("adjacent expressions"), + "diagnostic must name the adjacency defect, got: " + + error.getMessage()); + } + + @Test + public void sanitizesCommentTerminatorsInInfoFields() throws IOException { + // A title containing */ would close the generated block comment and + // turn the remaining text into code in every generated file. + Path spec = writeTempSpec( + "openapi: 3.1.0", + "info: {title: \"evil */ int x;\", version: '1'}", + "paths:", + " /ping:", + " get:", + " operationId: ping", + " responses:", + " '200': {description: ok}"); + Path output = generate(spec.toString(), java.util.Map.of()); + String apiHeader = Files.readString(output.resolve("api/DefaultApi.h")); + Assert.assertTrue(apiHeader.contains("evil * / int x;"), + "the terminator must be neutralized in the license header"); + Assert.assertFalse(apiHeader.contains("*/ int x"), + "no generated comment may embed the raw terminator"); + } + + @Test + public void keepsNullableModelBodyTypedAsOptional() throws IOException { + // A nullable component body must lower to std::optional<...> and stay + // typed: the JSON runtime decodes std::optional natively, so only + // variant unions are untypable. + Path spec = writeTempSpec(spec( + " /maybe:", + " post:", + " operationId: maybe", + " requestBody:", + " required: false", + " content:", + " application/json:", + " schema: { $ref: '#/components/schemas/MaybeName' }", + " responses:", + " '200': {description: ok}", + "components:", + " schemas:", + " MaybeName:", + " type: ['null', 'string']")); + Path output = generate(spec.toString(), java.util.Map.of()); + String apiHeader = Files.readString(output.resolve("api/DefaultApi.h")); + Assert.assertTrue(apiHeader.contains("body{};"), + "the request struct must exist"); + Assert.assertTrue( + apiHeader.contains("std::optional body{};") + || apiHeader.contains("std::optional body{"), + "a nullable body must stay typed as std::optional, header was:\n" + + apiHeader.substring(0, Math.min(apiHeader.length(), 4000))); + } + + @Test + public void rendersHtmlSignificantNamesUnescapedInLiterals() throws IOException { + // Path, operationId, and parameter names with & < > " must reach the + // C++ literals verbatim (C++-escaped once), not HTML-escaped. + Path spec = writeTempSpec(spec( + " /a&b:", + " get:", + " operationId: getIt", + " parameters:", + " - name: \"fr&ac\"", + " in: query", + " schema: {type: string}", + " responses:", + " '200': {description: ok}")); + Path output = generate(spec.toString(), java.util.Map.of()); + String apiSource = Files.readString(output.resolve("api/DefaultApi.cpp")); + Assert.assertTrue(apiSource.contains("\"/a&b\""), + "the registered route must carry the raw path, not &"); + Assert.assertFalse(apiSource.contains("/a&b"), + "html escaping must not corrupt the route literal"); + Assert.assertTrue(apiSource.contains("\"fr&ac\""), + "the query lookup key must carry the raw parameter name"); + Assert.assertFalse(apiSource.contains("fr&ac"), + "html escaping must not corrupt the parameter name"); + Assert.assertTrue(apiSource.contains("impl->getIt("), + "the dispatch call must carry the raw operationId nickname"); + } + + @Test + public void rejectsBranchyRouteOverlapWithWitness() throws IOException { + // Alternating wildcard/literal layouts with equal literal-token + // ranking: '/v/{p1}a{p2}a{p3}a' and '/v/a{p4}a{p5}a{p6}' both match + // '/v/aaa'. The intersection search must terminate in polynomial + // time on this branchy pair and still PROVE the collision (a + // step-budgeted DFS could under-report it; the trie BFS cannot). + Path spec = writeTempSpec(spec( + " /v/{p1}a{p2}a{p3}a:", + " get:", + " operationId: odd", + " responses:", + " '200': {description: ok}", + " /v/a{p4}a{p5}a{p6}:", + " get:", + " operationId: even", + " responses:", + " '200': {description: ok}")); + CodegenConfigurator configurator = new CodegenConfigurator() + .setGeneratorName("cpp-boost-beast-server") + .setInputSpec(spec.toString()) + .setOutputDir(Files.createTempDirectory("branchy-").toString()) + .setValidateSpec(false); + IllegalArgumentException error = Assert.expectThrows( + IllegalArgumentException.class, + () -> new DefaultGenerator() + .opts(configurator.toClientOptInput()).generate()); + Assert.assertTrue(error.getMessage().contains("'/v/"), + "diagnostic must name a witness path, got: " + error.getMessage()); + } +} diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/cppboostbeastserver/CppBoostBeastServerRuntimeTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/cppboostbeastserver/CppBoostBeastServerRuntimeTest.java new file mode 100644 index 000000000000..85fde7428171 --- /dev/null +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/cppboostbeastserver/CppBoostBeastServerRuntimeTest.java @@ -0,0 +1,223 @@ +/* + * 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.cppboostbeastserver; + +import org.openapitools.codegen.DefaultGenerator; +import org.openapitools.codegen.config.CodegenConfigurator; +import org.testng.Assert; +import org.testng.SkipException; +import org.testng.annotations.Test; + +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; +import java.util.stream.Stream; + +/** + * Generates a server from the OAS 3.1 regression spec, compiles it together + * with the loopback driver, runs it against real sockets, and asserts the + * sentinel output. This is the end-to-end behavior proof for the generator: + * routing and request-target normalization, Host/HEAD HTTP semantics, + * parameter codecs (encoded delimiters, form plus decoding, deepObject), + * exact multipleOf checks, body decoding, security challenges, version + * mirroring, deferred-response timers, and error mapping all execute in the + * produced C++ binary. + */ +public class CppBoostBeastServerRuntimeTest { + + private static final String SPEC = + "src/test/resources/3_1/cpp-boost-beast-server/server-regression.yaml"; + private static final String DRIVER = + "src/test/resources/3_1/cpp-boost-beast-server/" + + "server-runtime-regression.cpp"; + + @Test + public void generatedServerServesLoopbackRegressions() throws Exception { + Path output = generateServer(Map.of()); + Assert.assertTrue( + compileAndRunDriver(output).contains( + "cpp-boost-beast-server runtime regressions passed"), + "server runtime test did not report completion"); + } + + @Test + public void validationDisabledOutputCompilesAndServesLoopbackRegressions() + throws Exception { + Path output = generateServer( + Map.of("compileWithValidation", Boolean.FALSE)); + Assert.assertTrue( + compileAndRunDriver(output).contains( + "cpp-boost-beast-server runtime regressions passed"), + "validation-disabled server runtime test did not complete"); + } + + private static Path generateServer(Map properties) + throws IOException { + Path outputRoot = Files.createDirectories(Path.of("target")); + Path output = Files.createTempDirectory( + outputRoot, "cpp-boost-beast-server-runtime-"); + output.toFile().deleteOnExit(); + CodegenConfigurator configurator = new CodegenConfigurator() + .setGeneratorName("cpp-boost-beast-server") + .setInputSpec(SPEC) + .setOutputDir(output.toString()); + properties.forEach(configurator::addAdditionalProperty); + new DefaultGenerator().opts(configurator.toClientOptInput()).generate(); + return output; + } + + private static String compileAndRunDriver(Path output) throws Exception { + Path executable = output.resolve("server-runtime-regression"); + String compiler = System.getenv().getOrDefault("CXX", "c++"); + + List command = new ArrayList<>(); + command.add(compiler); + command.add("-std=c++17"); + command.add("-Wall"); + command.add("-Werror"); + command.add("-DBOOST_ERROR_CODE_HEADER_ONLY"); + // Schema-IR-dependent driver legs (explicit-null rejection at the + // request boundary) only hold when the validation runtime was + // generated; the compileWithValidation=false leg documents and + // keeps the tolerate-null compatibility default instead. + if (Files.exists(output.resolve("model/schema_ir.generated.cpp"))) { + command.add("-DCPPBB_EXPECT_SCHEMA_VALIDATION"); + } + command.add("-I" + output); + command.add("-I" + output.resolve("api")); + command.add("-I" + output.resolve("model")); + command.add("-I" + output.resolve("server")); + for (String candidate : new String[]{"/opt/homebrew", "/usr/local"}) { + Path include = Path.of(candidate, "include"); + Path lib = Path.of(candidate, "lib"); + if (Files.isDirectory(include)) { + command.add("-I" + include); + } + if (Files.isDirectory(lib)) { + command.add("-L" + lib); + } + } + command.add(Path.of(DRIVER).toString()); + try (Stream sources = Files.list(output.resolve("model"))) { + sources.filter(path -> path.getFileName().toString().endsWith(".cpp")) + .map(Path::toString) + .sorted() + .forEach(command::add); + } + try (Stream sources = Files.list(output.resolve("api"))) { + sources.filter(path -> path.getFileName().toString().endsWith(".cpp")) + .map(Path::toString) + .sorted() + .forEach(command::add); + } + command.add(output.resolve("server/HttpServer.cpp").toString()); + command.add("-lboost_json"); + command.add("-lboost_url"); + command.add("-pthread"); + command.add("-o"); + command.add(executable.toString()); + + // Redirect to files instead of pipes: a chatty compiler would + // otherwise deadlock the pipe buffer while waitFor() blocks. + Path compileLog = output.resolve("compile.log"); + Process compile; + try { + compile = new ProcessBuilder(command) + .redirectErrorStream(true) + .redirectOutput(compileLog.toFile()) + .directory(new File(".")) + .start(); + } catch (IOException unavailable) { + throw unavailableDependency("C++ compiler is unavailable (" + compiler + ")", + unavailable.getMessage()); + } + if (!compile.waitFor(10, TimeUnit.MINUTES)) { + terminate(compile); + Assert.fail("server runtime compile timed out:\n" + + readQuietly(compileLog)); + } + String compileOutput = readQuietly(compileLog); + if (compile.exitValue() != 0 && missingBoost(compileOutput)) { + throw unavailableDependency("Boost development files are unavailable", + compileOutput.trim()); + } + Assert.assertEquals(compile.exitValue(), 0, + "server runtime compile failed:\n" + compileOutput); + + Path runLog = output.resolve("run.log"); + Process run; + try { + run = new ProcessBuilder(executable.toString()) + .redirectErrorStream(true) + .redirectOutput(runLog.toFile()) + .start(); + } catch (IOException unavailable) { + throw unavailableDependency("compiled binary could not start", + unavailable.getMessage()); + } + if (!run.waitFor(120, TimeUnit.SECONDS)) { + terminate(run); + Assert.fail("server runtime execution timed out:\n" + + readQuietly(runLog)); + } + String runOutput = readQuietly(runLog); + Assert.assertEquals(run.exitValue(), 0, + "server runtime test failed:\n" + runOutput); + return runOutput; + } + + private static void terminate(Process process) { + process.descendants().forEach(ProcessHandle::destroyForcibly); + process.destroyForcibly(); + } + + private static String readQuietly(Path log) { + try { + return Files.readString(log, StandardCharsets.UTF_8); + } catch (IOException missing) { + return ""; + } + } + + private static boolean missingBoost(String compilerOutput) { + String normalized = compilerOutput.toLowerCase(java.util.Locale.ROOT); + boolean missingHeaders = normalized.contains("boost/") + && (normalized.contains("not found") + || normalized.contains("no such file")); + boolean missingLibraries = normalized.contains("cannot find -lboost_") + || normalized.contains("library 'boost_") + || normalized.contains("library not found for -lboost_"); + return missingHeaders || missingLibraries; + } + + /** Builds the skip (or, when the build declared Boost mandatory via + * -Dcpp.boost.beast.require=true — the sample CI leg where a silently + * skipped runtime suite would hide regressions — a failure) for a + * missing native dependency. */ + private static RuntimeException unavailableDependency(String what, String detail) { + if (Boolean.getBoolean("cpp.boost.beast.require")) { + Assert.fail(what + " but was required for this run: " + detail); + } + return new SkipException(what + "; skipping server runtime test: " + detail); + } +} diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/templating/GeneratorTemplateContentLocatorAdditionalDirsTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/templating/GeneratorTemplateContentLocatorAdditionalDirsTest.java new file mode 100644 index 000000000000..49539f863d64 --- /dev/null +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/templating/GeneratorTemplateContentLocatorAdditionalDirsTest.java @@ -0,0 +1,97 @@ +/* + * 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.templating; + +import org.openapitools.codegen.CodegenConfig; +import org.openapitools.codegen.languages.CppBoostBeastClientCodegen; +import org.openapitools.codegen.languages.CppBoostBeastServerCodegen; +import org.testng.Assert; +import org.testng.annotations.Test; +import java.io.File; +import java.util.List; + +public class GeneratorTemplateContentLocatorAdditionalDirsTest { + + private static String normalized(String path) { + return path.replace(File.separatorChar, '/'); + } + + @Test + public void resolvesTemplatesFromAdditionalEmbeddedDirs() { + CppBoostBeastClientCodegen codegen = new CppBoostBeastClientCodegen(); + GeneratorTemplateContentLocator locator = + new GeneratorTemplateContentLocator(codegen); + + String resolved = locator.getFullTemplatePath("oas31_validator.mustache"); + + Assert.assertNotNull(resolved, "shared template must resolve via additional dirs"); + Assert.assertEquals(normalized(resolved), "cpp-boost-beast-common/oas31_validator.mustache"); + } + + @Test + public void primaryEmbeddedDirWinsOverAdditionalDirs() { + CppBoostBeastClientCodegen codegen = new CppBoostBeastClientCodegen(); + // licenseInfo.mustache exists in BOTH cpp-boost-beast-client (primary) + // and cpp-boost-beast-common (additional): only a template present in + // both directories can detect an inverted probe order. + GeneratorTemplateContentLocator locator = + new GeneratorTemplateContentLocator(codegen); + + String resolved = locator.getFullTemplatePath("licenseInfo.mustache"); + + Assert.assertNotNull(resolved); + Assert.assertEquals(normalized(resolved), "cpp-boost-beast-client/licenseInfo.mustache"); + } + + @Test + public void unknownTemplateReturnsNull() { + CppBoostBeastClientCodegen codegen = new CppBoostBeastClientCodegen(); + GeneratorTemplateContentLocator locator = + new GeneratorTemplateContentLocator(codegen); + + Assert.assertNull(locator.getFullTemplatePath("no-such-template.mustache")); + } + + @Test + public void additionalDirsListIsConfiguredOnGenerator() { + CppBoostBeastClientCodegen codegen = new CppBoostBeastClientCodegen(); + + Assert.assertEquals(codegen.additionalEmbeddedTemplateDirs(), + List.of("cpp-boost-beast-common")); + } + + @Test + public void schemaIrChunkTemplatesResolveForBothGenerators() { + // The chunked IR path (large specs, e.g. the OpenAI corpus) is only + // reachable when every chunk template resolves from the shared + // common dir: the server's own embedded dir does not carry them. + for (CodegenConfig config : List.of( + new CppBoostBeastClientCodegen(), new CppBoostBeastServerCodegen())) { + GeneratorTemplateContentLocator locator = + new GeneratorTemplateContentLocator(config); + for (int chunk = 0; chunk <= 15; chunk++) { + String resolved = locator.getFullTemplatePath( + "oas31_schema_ir_chunk" + chunk + ".mustache"); + Assert.assertNotNull(resolved, + config.getName() + " must resolve chunk " + chunk); + Assert.assertEquals(normalized(resolved), + "cpp-boost-beast-common/oas31_schema_ir_chunk" + chunk + ".mustache", + config.getName() + " chunk " + chunk + " must come from the common dir"); + } + } + } +} diff --git a/modules/openapi-generator/src/test/resources/3_1/cpp-boost-beast-client/oas31-exact-runtime-test.cpp b/modules/openapi-generator/src/test/resources/3_1/cpp-boost-beast-client/oas31-exact-runtime-test.cpp index f4ec63503721..77d595d03132 100644 --- a/modules/openapi-generator/src/test/resources/3_1/cpp-boost-beast-client/oas31-exact-runtime-test.cpp +++ b/modules/openapi-generator/src/test/resources/3_1/cpp-boost-beast-client/oas31-exact-runtime-test.cpp @@ -186,21 +186,64 @@ void testNumericConversionBoundaries() { require(!tryGetMathematicalInteger(tooLargeSigned, signedValue), "2^63 must not convert to int64"); + // The largest integral double below 2^63 is a trustworthy IMAGE of no + // specific integer: every token in a 1024-wide rounding band produces + // it, and past 2^53 the cast-based exactness check agrees with the + // band's centre. Without a wire lexeme there is nothing to prove which + // token arrived, so the decode refuses it (fail closed) ... boost::json::value const largestDoubleBelowSignedUpper( std::nextafter(signedUpper, 0.0)); - require(tryGetMathematicalInteger( + require(!tryGetMathematicalInteger( largestDoubleBelowSignedUpper, signedValue), - "the largest integral double below 2^63 must convert to int64"); - + "integral doubles past the exact window must refuse without a lexeme"); + // ... while the same magnitude inside an ExactInstanceScope converts + // exactly from the decimal text it was parsed from. + { + schema_validation::ExactJsonValue document = + schema_validation::parseExactJson("[9223372036854774784.0]"); + schema_validation::ExactInstanceScope scope(document); + require(tryGetMathematicalInteger(document.value.as_array()[0], signedValue) + && signedValue == 9223372036854774784LL, + "the lexeme must convert exactly inside a scope"); + } boost::json::value const signedLower(-signedUpper); - require(tryGetMathematicalInteger(signedLower, signedValue) - && signedValue == (std::numeric_limits::min)(), - "-2^63 must convert to int64 exactly"); + require(!tryGetMathematicalInteger(signedLower, signedValue), + "-2^63 image must refuse without a lexeme: in- AND out-of-range " + "tokens both round into it and cannot be told apart"); + { + schema_validation::ExactJsonValue document = + schema_validation::parseExactJson("[-9223372036854775808.0]"); + schema_validation::ExactInstanceScope scope(document); + require(tryGetMathematicalInteger(document.value.as_array()[0], signedValue) + && signedValue == (std::numeric_limits::min)(), + "-2^63 must convert to int64 exactly from its lexeme"); + } std::uint64_t unsignedValue = 0; boost::json::value const tooLargeUnsigned(std::ldexp(1.0, 64)); require(!tryGetMathematicalInteger(tooLargeUnsigned, unsignedValue), "2^64 must not convert to uint64"); + // A signed destination whose FULL range fits inside the double exact + // window must accept its own minimum: every integer up to 2^53 has an + // unambiguous double image, so -2^31 names exactly one int32 and the + // trust window closes there. + std::int32_t narrow = 0; + boost::json::value const int32Lower( + static_cast((std::numeric_limits::min)())); + require(tryGetMathematicalInteger(int32Lower, narrow) + && narrow == (std::numeric_limits::min)(), + "-2^31 must convert to int32: the image names exactly one integer"); + boost::json::value const int32Upper( + static_cast((std::numeric_limits::max)())); + require(tryGetMathematicalInteger(int32Upper, narrow) + && narrow == (std::numeric_limits::max)(), + "2^31-1 must convert to int32: the image names exactly one integer"); + // Destinations reaching the ambiguous precision boundary keep the open + // edge: -2^53 is the image of both -2^53 and -2^53 - 1 (both in int64's + // range), so without a lexeme it must still fail closed. + boost::json::value const ambiguousSignedLower(-std::ldexp(1.0, 53)); + require(!tryGetMathematicalInteger(ambiguousSignedLower, signedValue), + "-2^53 must refuse for int64: tokens on both sides round into it"); requireThrows( [&]() { (void)convertJsonNumber(tooLargeSigned); }, "out-of-range floating-to-integer conversion must throw"); @@ -433,6 +476,61 @@ void testAnnotationPayloadsLocationsAndRollback() { "a failing parent schema must roll back earlier child annotations"); } +void testUnicodePropertyEscapesFailClosed() { + auto validateAgainstPattern = [](std::string const& pattern, + std::string const& payload) + -> schema_validation::ValidationResult { + schema_validation::SchemaResourceRegistry registry; + registry.nodes.resize(1); + registry.nodes[0].hasPattern = true; + registry.nodes[0].pattern = pattern; + schema_validation::SchemaEvaluator const evaluator(registry); + schema_validation::ExactJsonValue document = + schema_validation::parseExactJson(payload); + schema_validation::ExactInstanceScope scope(document); + schema_validation::RawInstance instance(&document.value); + schema_validation::ValidationPath path; + schema_validation::ValidationContext context; + return evaluator.validate(0, instance, path, context); + }; + + // \p{L} has no representation in std::regex's ECMAScript subset, and a + // hand-maintained range list would mis-accept/mis-reject. The validator + // must refuse the schema explicitly instead of approximating it. + { + schema_validation::ValidationResult const result = + validateAgainstPattern("^\\p{L}+$", "\"abc\""); + require(!result.success, + "a property-escape pattern must never validate a value"); + require(result.failureMessage.find("unsupported pattern") + != std::string::npos, + ("property escapes must be reported as unsupported, got: " + + result.failureMessage).c_str()); + } + { + schema_validation::ValidationResult const result = + validateAgainstPattern("\\P{Letter}x", "\"zy\""); + require(!result.success && result.failureMessage.find( + "unsupported pattern") != std::string::npos, + "negated property escapes must fail closed too"); + } + // An escaped backslash before p is literal text, not a property escape: + // the pattern bytes are '\\' + 'p' (matching the two-character text + // "\p"), and must compile and search normally. + { + schema_validation::ValidationResult const hit = + validateAgainstPattern("\\\\p", "\"x\\\\p\""); + require(hit.success, + "a doubled-backslash pattern must match its literal text"); + schema_validation::ValidationResult const miss = + validateAgainstPattern("\\\\p", "\"ab\""); + require(!miss.success + && miss.failureMessage.find("does not match") + != std::string::npos, + "a doubled-backslash pattern must still reject other text"); + } +} + } // namespace int main() { @@ -448,6 +546,7 @@ int main() { testConditionalGuardOutputsDoNotLeak(); testDynamicAnchorChainsAreNotDepthLimited(); testAnnotationPayloadsLocationsAndRollback(); + testUnicodePropertyEscapesFailClosed(); std::cout << "oas31 exact runtime tests passed\n"; return EXIT_SUCCESS; } catch (std::exception const& exception) { diff --git a/modules/openapi-generator/src/test/resources/3_1/cpp-boost-beast-client/oas31-runtime-regression.yaml b/modules/openapi-generator/src/test/resources/3_1/cpp-boost-beast-client/oas31-runtime-regression.yaml index cce42d437da6..0a58daf1f625 100644 --- a/modules/openapi-generator/src/test/resources/3_1/cpp-boost-beast-client/oas31-runtime-regression.yaml +++ b/modules/openapi-generator/src/test/resources/3_1/cpp-boost-beast-client/oas31-runtime-regression.yaml @@ -361,6 +361,15 @@ components: nullable_fallback: type: [string, "null"] default: fallback + # OpenAI's Eval.testing_criteria: a SCALAR default on an array + # carrier. JSON Schema treats default as an annotation here, but a + # generated scalar initializer cannot even compile against + # std::vector; the decode path must drop it to value-initialization. + mismatched_tags: + type: array + items: + type: string + default: eval ConditionalStreamRequest: type: object properties: @@ -389,3 +398,15 @@ components: voice: $ref: '#/components/schemas/DefaultVoice' default: alloy + MixedWithContainer: + type: object + properties: + named: + $ref: '#/components/schemas/StringOrTags' + default: alloy + StringOrTags: + oneOf: + - type: array + items: + type: string + - type: string diff --git a/modules/openapi-generator/src/test/resources/3_1/cpp-boost-beast-client/preserve-additional-properties-runtime-regression.cpp b/modules/openapi-generator/src/test/resources/3_1/cpp-boost-beast-client/preserve-additional-properties-runtime-regression.cpp index 5f65236e5db4..907648f58cff 100644 --- a/modules/openapi-generator/src/test/resources/3_1/cpp-boost-beast-client/preserve-additional-properties-runtime-regression.cpp +++ b/modules/openapi-generator/src/test/resources/3_1/cpp-boost-beast-client/preserve-additional-properties-runtime-regression.cpp @@ -47,7 +47,11 @@ int main() { conflictingExtras.emplace("future", boost::json::value("retained")); extraFields.setExtraJsonProperties2(std::move(conflictingExtras)); extraFields.setName("typed"); - const boost::json::object& conflictOutput = extraFields.toJsonValue().as_object(); + // Own the serialized value: as_object() returns a reference INTO the + // temporary returned by toJsonValue(), which would die at the end of + // this statement (a dangling reference, not a diagnostic failure). + const boost::json::value conflictValue = extraFields.toJsonValue(); + const boost::json::object& conflictOutput = conflictValue.as_object(); expect(conflictOutput.at("name") == "typed", "an extra field overrode a typed model property"); expect(conflictOutput.at("future") == "retained", diff --git a/modules/openapi-generator/src/test/resources/3_1/cpp-boost-beast-server/petstore.yaml b/modules/openapi-generator/src/test/resources/3_1/cpp-boost-beast-server/petstore.yaml new file mode 100644 index 000000000000..2bea385372b9 --- /dev/null +++ b/modules/openapi-generator/src/test/resources/3_1/cpp-boost-beast-server/petstore.yaml @@ -0,0 +1,167 @@ +openapi: 3.1.0 +info: + title: Petstore Server + description: Sample petstore server for the cpp-boost-beast-server generator + version: 1.0.0 +servers: + - url: http://petstore.swagger.io/api/v3 +tags: + - name: pets + - name: store + - name: users +paths: + /pets: + get: + tags: [pets] + operationId: listPets + summary: List pets + parameters: + - name: limit + in: query + description: How many items to return at one time (max 100) + schema: + type: integer + format: int32 + minimum: 1 + maximum: 100 + responses: + '200': + description: A paged array of pets + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Pet' + post: + tags: [pets] + operationId: createPet + summary: Create a pet + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Pet' + responses: + '201': + description: The created pet + content: + application/json: + schema: + $ref: '#/components/schemas/Pet' + /pets/{petId}: + get: + tags: [pets] + operationId: showPetById + summary: Info for a specific pet + parameters: + - name: petId + in: path + required: true + description: The id of the pet to retrieve + schema: + type: integer + format: int64 + responses: + '200': + description: Expected response to a valid request + content: + application/json: + schema: + $ref: '#/components/schemas/Pet' + '404': + description: No pet found + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + delete: + tags: [pets] + operationId: deletePetById + summary: Delete a pet + parameters: + - name: petId + in: path + required: true + schema: + type: integer + format: int64 + responses: + '204': + description: Deleted + /store/inventory: + get: + tags: [store] + operationId: getInventory + summary: Returns pet inventories by status + responses: + '200': + description: Status counts + content: + application/json: + schema: + type: object + additionalProperties: + type: integer + format: int32 + /users/{username}: + get: + tags: [users] + operationId: getUserByName + summary: Get user by username + parameters: + - name: username + in: path + required: true + schema: + type: string + responses: + '200': + description: The user + content: + application/json: + schema: + $ref: '#/components/schemas/User' + '404': + description: No such user + content: + application/json: + schema: + $ref: '#/components/schemas/Error' +components: + schemas: + Pet: + type: object + required: [id, name] + properties: + id: + type: integer + format: int64 + name: + type: string + tag: + type: string + status: + type: string + enum: [available, pending, sold] + User: + type: object + required: [username] + properties: + id: + type: integer + format: int64 + username: + type: string + email: + type: string + Error: + type: object + required: [code, message] + properties: + code: + type: integer + format: int32 + message: + type: string diff --git a/modules/openapi-generator/src/test/resources/3_1/cpp-boost-beast-server/server-regression.yaml b/modules/openapi-generator/src/test/resources/3_1/cpp-boost-beast-server/server-regression.yaml new file mode 100644 index 000000000000..d9900f1869d1 --- /dev/null +++ b/modules/openapi-generator/src/test/resources/3_1/cpp-boost-beast-server/server-regression.yaml @@ -0,0 +1,518 @@ +openapi: 3.1.0 +info: + title: cpp-boost-beast-server regression + version: 1.0.0 +servers: + - url: http://localhost:8080 +security: + - api_key: [] +paths: + /pets/{petId}: + parameters: + - name: version + in: query + description: >- + Declared on the PATH ITEM: the rawschema lookup must fall back + from the operation list to shared parameters (constraint + inheritance regression) + schema: + type: integer + format: int32 + minimum: 5 + get: + operationId: getPetById + parameters: + - name: petId + in: path + required: true + description: Pet identifier + schema: + type: integer + format: int64 + minimum: 1 + - name: tag + in: query + description: Form-decoded scalar echo probe + schema: + type: string + responses: + '200': + description: The pet + content: + application/json: + schema: + $ref: '#/components/schemas/Pet' + '404': + description: No such pet + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + security: [] + head: + operationId: headPet + parameters: + - name: petId + in: path + required: true + schema: + type: integer + format: int64 + minimum: 1 + responses: + '200': + description: Pet metadata + content: + application/json: + schema: + $ref: '#/components/schemas/Pet' + security: [] + put: + operationId: updatePet + parameters: + - name: petId + in: path + required: true + schema: + type: integer + format: int64 + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Pet' + responses: + '200': + description: Updated pet + content: + application/json: + schema: + $ref: '#/components/schemas/Pet' + delete: + operationId: deletePet + parameters: + - name: petId + in: path + required: true + schema: + type: integer + format: int64 + - name: X-API-KEY + in: header + required: true + description: API key header array (simple style) + schema: + type: array + items: + type: string + style: simple + responses: + '204': + description: Deleted + /pets: + get: + operationId: listPets + parameters: + - name: status + in: query + description: Filter by status enum + schema: + type: string + enum: [available, sold] + - name: tags + in: query + description: Pipe-delimited tag filter + schema: + type: array + items: + type: string + style: pipeDelimited + explode: false + - name: limit + in: query + description: Page size + schema: + type: integer + format: int32 + minimum: 1 + maximum: 100 + - name: tier + in: query + description: Numeric tier selector + schema: + type: integer + format: int32 + enum: [10, 20] + responses: + '200': + description: Pet collection + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Pet' + security: [] + post: + operationId: createPet + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Pet' + responses: + '201': + description: Created pet + content: + application/json: + schema: + $ref: '#/components/schemas/Pet' + '400': + description: Invalid pet + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + default: + description: Unexpected error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + security: + - bearer: [] + /reports/{reportId}: + get: + operationId: getReport + parameters: + - name: reportId + in: path + required: true + schema: + type: string + pattern: '^[A-Z]{2}-[0-9]+$' + style: label + - name: lang + in: cookie + schema: + type: string + responses: + '200': + description: The report + content: + application/vnd.report+json: + schema: + $ref: '#/components/schemas/Report' + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + security: + - bearer: [] + /batch/{ids}: + get: + operationId: getBatch + parameters: + - name: ids + in: path + required: true + description: Matrix-exploded identifier list + schema: + type: array + items: + type: string + style: matrix + explode: true + responses: + '200': + description: Batch echo + content: + application/json: + schema: + $ref: '#/components/schemas/Report' + security: [] + /pets/bulk: + get: + operationId: getBulkPets + description: Literal route declared after /pets/{petId} (ranking probe) + responses: + '200': + description: Bulk pet + content: + application/json: + schema: + $ref: '#/components/schemas/Pet' + security: [] + /search: + get: + operationId: search + parameters: + - name: filter + in: query + required: true + description: Deep-object string map filter + schema: + type: object + additionalProperties: + type: string + style: deepObject + explode: true + - name: sort + in: query + description: Space-delimited sort fields + schema: + type: array + items: + type: string + style: spaceDelimited + explode: false + responses: + '200': + description: Search echo + content: + application/json: + schema: + $ref: '#/components/schemas/Report' + security: [] + /defaults: + get: + operationId: getDefaults + description: Declared defaults, exclusive bounds, collection constraints + parameters: + - name: name + in: query + schema: + type: string + default: five + - name: X-Note + in: header + description: >- + Code-point length probe: maxLength counts Unicode scalars (two + CJK characters pass as 2), and malformed (e.g. overlong-encoded) + UTF-8 degrades to a strict byte count so continuation bytes + cannot be smuggled through length checks. A header (not query) + so raw non-ASCII octets are legal wire bytes (RFC 7230 + obs-text). + schema: + type: string + maxLength: 2 + - name: limit + in: query + schema: + type: integer + format: int32 + default: 7 + - name: score + in: query + schema: + type: number + format: double + exclusiveMinimum: 1.5 + exclusiveMaximum: 3 + - name: step + in: query + description: Exact-decimal multipleOf probe + schema: + type: number + format: double + multipleOf: 0.1 + - name: stride + in: query + description: Integer multipleOf probe + schema: + type: integer + format: int32 + multipleOf: 3 + - name: ticks + in: query + description: Array-item multipleOf probe + schema: + type: array + items: + type: integer + format: int32 + multipleOf: 2 + style: form + explode: false + - name: ids + in: query + required: true + schema: + type: array + items: + type: string + minLength: 2 + minItems: 2 + uniqueItems: true + style: form + explode: false + responses: + '200': + description: Echo of declared defaults + content: + application/json: + schema: + $ref: '#/components/schemas/Report' + security: [] + /periods/{year}-{month}/summary: + get: + operationId: getPeriod + description: Embedded path expressions in one segment + parameters: + - name: year + in: path + required: true + schema: + type: integer + format: int32 + - name: month + in: path + required: true + schema: + type: integer + format: int32 + multipleOf: 2 + responses: + '200': + description: Period echo + content: + application/json: + schema: + $ref: '#/components/schemas/Report' + security: [] + /echo: + post: + operationId: postEcho + description: Optional JSON body (decodes only when bytes arrive) + requestBody: + required: false + content: + application/json: + schema: + $ref: '#/components/schemas/Pet' + responses: + '200': + description: Echoed body + content: + application/json: + schema: + $ref: '#/components/schemas/Pet' + security: [] + /codec: + get: + operationId: codec + description: Float wire-grammar gates and fail-closed regex patterns + parameters: + - name: w + in: query + description: Unbounded float parameter for range/grammar probes + schema: + type: number + format: float + - name: z + in: query + description: >- + Unbounded double parameter: on platforms where long double is + wider than double (x86 80-bit), 1e400 parses finite and must + still be refused by the destination-range gate + schema: + type: number + format: double + - name: code + in: query + description: Pattern outside the std::regex ECMAScript subset + schema: + type: string + pattern: '^(?i)abc$' + - name: codes + in: query + description: Item pattern with a dangling quantifier + schema: + type: array + items: + type: string + pattern: '*oops' + style: form + explode: false + - name: uni + in: query + description: Unicode property escape outside the supported grammar + schema: + type: string + pattern: '^\p{L}+$' + responses: + '200': + description: Echo of the decoded float + content: + application/json: + schema: + $ref: '#/components/schemas/Report' + security: [] + /pick: + get: + operationId: getPick + description: oneOf response model (std::variant of tagged branches) + responses: + '200': + description: One selected branch + content: + application/json: + schema: + $ref: '#/components/schemas/Pick' + security: [] +components: + securitySchemes: + api_key: + type: apiKey + name: X-API-KEY + in: header + bearer: + type: http + scheme: bearer + schemas: + Pet: + type: object + required: [id, name] + properties: + id: + type: integer + format: int64 + name: + type: string + status: + type: string + enum: [available, sold] + tag: + type: [string, 'null'] + photoUrls: + type: array + items: + type: string + note: + type: string + description: Unicode property escape pattern for fail-closed probes + pattern: '^\p{L}+$' + ErrorResponse: + type: object + properties: + code: + type: integer + format: int32 + message: + type: string + Pick: + oneOf: + - {type: string, pattern: '^A'} + - {type: string, pattern: '^B'} + Report: + type: object + required: [title] + properties: + title: + type: string + createdAt: + type: [string, 'null'] diff --git a/modules/openapi-generator/src/test/resources/3_1/cpp-boost-beast-server/server-runtime-regression.cpp b/modules/openapi-generator/src/test/resources/3_1/cpp-boost-beast-server/server-runtime-regression.cpp new file mode 100644 index 000000000000..3c230b4c2067 --- /dev/null +++ b/modules/openapi-generator/src/test/resources/3_1/cpp-boost-beast-server/server-runtime-regression.cpp @@ -0,0 +1,1109 @@ +// ============================================================================ +// server-runtime-regression.cpp - end-to-end loopback driver for the +// cpp-boost-beast-server runtime test. Implements every service method, +// serves on 127.0.0.1:0, then asserts wire behavior with raw sockets. +// ============================================================================ +#include "HttpServer.h" +#include "ParamCodecs.h" +#include "Problem.h" +#include "Responder.h" +#include "Router.h" +#include "DefaultApi.h" + +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace api = org::openapitools::server::api; +namespace model = org::openapitools::server::model; + +static int failures = 0; + +static void expect(bool condition, std::string const& what) { + if (!condition) { + ++failures; + std::cerr << "FAIL: " << what << "\n"; + } +} + +// --------------------------------------------------------------------------- +// Service implementation with deterministic echo behavior. +// --------------------------------------------------------------------------- +class RegressionApi : public api::DefaultApi { +public: + void getPetById(GetPetByIdRequest request, + std::shared_ptr context, + GetPetByIdResponder responder) override { + model::Pet pet; + pet.setId(request.petId); + pet.setName(request.tag.empty() + ? "pet-" + std::to_string(request.petId) : request.tag); + pet.setStatus(std::string("available")); + // The context is heap-owned; reading it here proves the handler kept + // it alive for the service. + expect(!context->operationId.empty(), "context should carry operationId"); + // Complete twice: the single-completion guard must ignore the + // second call, so the client still sees exactly one clean 200. + responder.send200(pet); + responder.send200(std::move(pet)); + } + + void headPet(HeadPetRequest request, + std::shared_ptr, + HeadPetResponder responder) override { + model::Pet pet; + pet.setId(request.petId); + pet.setName("head-" + std::to_string(request.petId)); + pet.setStatus(std::string("available")); + responder.send200(std::move(pet)); + } + + void updatePet(UpdatePetRequest request, + std::shared_ptr, + UpdatePetResponder responder) override { + responder.send200(request.body); + } + + void deletePet(DeletePetRequest, + std::shared_ptr, + DeletePetResponder responder) override { + responder.send204(); + } + + void createPet(CreatePetRequest request, + std::shared_ptr, + CreatePetResponder responder) override { + responder.send201(request.body); + } + + void listPets(ListPetsRequest request, + std::shared_ptr, + ListPetsResponder responder) override { + std::vector> pets; + int count = request.limit > 0 ? static_cast(request.limit) : 2; + for (int i = 1; i <= count; ++i) { + auto pet = std::make_shared(); + pet->setId(i); + pet->setName("pet-" + std::to_string(i)); + if (!request.status.empty()) { + pet->setStatus(request.status); + } + pets.push_back(std::move(pet)); + } + responder.send200(std::move(pets)); + } + + void getReport(GetReportRequest request, + std::shared_ptr, + GetReportResponder responder) override { + model::Report report; + // Echo the decoded cookie so the test can prove cookie parsing + // (including optional whitespace after ';') end to end. + report.setTitle(request.lang.empty() ? "report" : request.lang); + responder.send200(std::move(report)); + } + + void getBatch(GetBatchRequest request, + std::shared_ptr, + GetBatchResponder responder) override { + std::string joined; + for (std::size_t i = 0; i < request.ids.size(); ++i) { + if (i != 0) { + joined += ","; + } + joined += request.ids[i]; + } + model::Report report; + report.setTitle(joined); + responder.send200(std::move(report)); + } + + void getBulkPets(GetBulkPetsRequest, + std::shared_ptr, + GetBulkPetsResponder responder) override { + model::Pet pet; + pet.setId(999); + pet.setName("bulk"); + responder.send200(std::move(pet)); + } + + void search(SearchRequest request, + std::shared_ptr, + SearchResponder responder) override { + // Deferred completion: the responder outlives the read deadline + // (readTimeoutSeconds is 1s), proving send_response re-arms the + // stream timer before writing. Responder is a movable, thread-safe + // value type whose sink posts onto the connection strand. + std::size_t filterSize = request.filter.size(); + std::size_t sortSize = request.sort.size(); + std::thread([responder = std::move(responder), + filterSize, sortSize]() mutable { + std::this_thread::sleep_for(std::chrono::seconds(2)); + model::Report report; + report.setTitle(std::to_string(filterSize) + "-" + + std::to_string(sortSize)); + responder.send200(std::move(report)); + }).detach(); + } + + void getDefaults(GetDefaultsRequest request, + std::shared_ptr, + GetDefaultsResponder responder) override { + // Echoes the values the wire produced: declared defaults for absent + // optional parameters, decoded values for present ones. + model::Report report; + report.setTitle(request.name + "|" + std::to_string(request.limit) + + "|" + std::to_string(request.ids.size()) + + "|" + request.xNote); + responder.send200(std::move(report)); + } + + void getPeriod(GetPeriodRequest request, + std::shared_ptr, + GetPeriodResponder responder) override { + model::Report report; + report.setTitle(std::to_string(request.year) + "-" + + std::to_string(request.month)); + responder.send200(std::move(report)); + } + + void postEcho(PostEchoRequest request, + std::shared_ptr, + PostEchoResponder responder) override { + // An absent optional body keeps the default-constructed Pet (id 0, + // no name); a present one echoes the decoded model. + responder.send200(request.body); + } + + void codec(CodecRequest request, + std::shared_ptr, + CodecResponder responder) override { + // Echo the decoded float so the driver can prove the wire grammar + // gates: hex forms and float overflow are rejected pre-handler, + // underflow arrives as zero (ordinary IEEE rounding). The double z + // only appends when nonzero so existing legs stay byte-exact. + model::Report report; + report.setTitle(std::to_string(request.w) + + (request.z == 0.0 ? "" : "|" + std::to_string(request.z))); + responder.send200(std::move(report)); + } + + void getPick(GetPickRequest, + std::shared_ptr, + GetPickResponder responder) override { + // oneOf of two string branches shares one C++ type, so the generated + // variant holds tagged CompositionBranchValue members. Serializing it + // exercises the response-side unwrap (bodyLeaf overload). + responder.send200(model::Pick{ + model::CompositionBranchValue<0, std::string>(std::string("Alpha"))}); + } +}; + +class RegressionAuthorizer : public api::Authorizer { +public: + bool authorize(std::string const& operationId, + api::AuthCredentials const& credentials) override { + // Deny the anonymous op outright: it must still succeed because + // `security: []` bypasses the gate entirely. + if (operationId == "getPetById") { + return false; + } + return credentials.httpAuthorization == "Bearer ok" + || credentials.apiKeyValues.count("header:X-API-KEY") != 0; + } +}; + +struct RawResponse { + unsigned status = 0; + std::string versionLine; + std::string allow; + std::string wwwAuthenticate; + std::string contentType; + std::string body; +}; + +static RawResponse roundtrip( + boost::asio::io_context& ioc, + unsigned port, + std::string const& request, + bool closeConnection = true, + boost::asio::ip::tcp::socket* persistent = nullptr) { + RawResponse result; + boost::asio::ip::tcp::socket owned(ioc); + boost::asio::ip::tcp::socket& target = + persistent != nullptr ? *persistent : owned; + if (persistent == nullptr) { + target.connect(boost::asio::ip::tcp::endpoint{ + boost::asio::ip::make_address("127.0.0.1"), + static_cast(port)}); + } + boost::asio::write(target, boost::asio::buffer(request)); + + std::string accumulated; + char buffer[4096]; + boost::system::error_code error; + for (;;) { + std::size_t received = target.read_some( + boost::asio::buffer(buffer), error); + if (error) { + break; + } + accumulated.append(buffer, received); + std::size_t headerEnd = accumulated.find("\r\n\r\n"); + if (headerEnd == std::string::npos) { + continue; + } + std::size_t contentLength = 0; + std::istringstream headerStream(accumulated.substr(0, headerEnd)); + std::string line; + bool firstLine = true; + while (std::getline(headerStream, line)) { + // CRLF lines keep a trailing '\r'; strip it so exact header + // comparisons behave. + if (!line.empty() && line.back() == '\r') { + line.pop_back(); + } + if (firstLine) { + result.versionLine = line; + firstLine = false; + continue; + } + std::string lower; + lower.reserve(line.size()); + for (char c : line) { + lower.push_back(static_cast(std::tolower( + static_cast(c)))); + } + std::string const contentLengthPrefix = "content-length: "; + std::string const allowPrefix = "allow: "; + std::string const wwwAuthenticatePrefix = "www-authenticate: "; + std::string const contentTypePrefix = "content-type: "; + if (lower.size() > contentLengthPrefix.size() + && lower.compare(0, contentLengthPrefix.size(), + contentLengthPrefix) == 0) { + contentLength = static_cast( + std::stoul(lower.substr(contentLengthPrefix.size()))); + } + if (lower.size() > allowPrefix.size() + && lower.compare(0, allowPrefix.size(), allowPrefix) == 0) { + result.allow = line.substr(allowPrefix.size()); + } + if (lower.size() > wwwAuthenticatePrefix.size() + && lower.compare(0, wwwAuthenticatePrefix.size(), + wwwAuthenticatePrefix) == 0) { + result.wwwAuthenticate = + line.substr(wwwAuthenticatePrefix.size()); + } + if (lower.size() > contentTypePrefix.size() + && lower.compare(0, contentTypePrefix.size(), + contentTypePrefix) == 0) { + result.contentType = line.substr(contentTypePrefix.size()); + } + } + if (accumulated.size() - headerEnd - 4 >= contentLength) { + std::string headerBlock = accumulated.substr(0, headerEnd); + std::size_t statusStart = headerBlock.find(' '); + result.status = static_cast(std::stoul( + headerBlock.substr(statusStart + 1, 3))); + result.body = accumulated.substr(headerEnd + 4, contentLength); + break; + } + } + if (closeConnection) { + boost::system::error_code ignored; + target.shutdown(boost::asio::ip::tcp::socket::shutdown_both, ignored); + } + return result; +} + +/// Builds a request string, computing Content-Length from the actual body. +static std::string request( + std::string const& methodAndPath, + std::string const& headers, + std::string const& body) { + std::string fixedHeaders = headers; + if (!body.empty() && fixedHeaders.find("Content-Length") == std::string::npos) { + fixedHeaders += "Content-Length: " + std::to_string(body.size()) + "\r\n"; + } + return methodAndPath + " HTTP/1.1\r\nHost: t\r\n" + fixedHeaders + + "\r\n" + body; +} + +/// Same as request() but for a caller-chosen HTTP version (version tests). +static std::string requestVersioned( + std::string const& version, + std::string const& methodAndPath, + std::string const& headers, + std::string const& body) { + std::string fixedHeaders = headers; + if (!body.empty() && fixedHeaders.find("Content-Length") == std::string::npos) { + fixedHeaders += "Content-Length: " + std::to_string(body.size()) + "\r\n"; + } + return methodAndPath + " " + version + "\r\nHost: t\r\n" + fixedHeaders + + "\r\n" + body; +} + +int main() { + boost::asio::io_context ioc; + auto router = std::make_shared(); + api::ServerOptions options; + // 1-second deadline makes the deferred search completion (2s worker + // thread) meaningful: the write must survive an already-expired timer. + options.readTimeoutSeconds = 1; + options.bodyLimitBytes = 1024; + options.authorizer = std::make_shared(); + auto server = api::HttpServer::create(ioc, router, options); + api::DefaultApi::attach(*server, std::make_shared()); + server->listen(boost::asio::ip::tcp::endpoint{ + boost::asio::ip::make_address("127.0.0.1"), + static_cast(0)}); + unsigned port = server->localEndpoint().port(); + + std::thread serverThread([&ioc] { ioc.run(); }); + + // 200 + JSON body on a valid GET. + RawResponse ok = roundtrip(ioc, port, + "GET /pets/42 HTTP/1.1\r\nHost: t\r\nConnection: close\r\n\r\n"); + expect(ok.status == 200, "valid pet GET should be 200"); + expect(ok.body.find("\"id\":42") != std::string::npos, + "pet body should carry id 42"); + + // 400 problem on int64 path failure and minimum violation. + RawResponse badId = roundtrip(ioc, port, + "GET /pets/abc HTTP/1.1\r\nHost: t\r\nConnection: close\r\n\r\n"); + expect(badId.status == 400, "non-numeric petId should be 400"); + expect(badId.contentType.find("application/problem+json") != std::string::npos, + "problem content type on bad petId"); + expect(badId.body.find("\"errors\"") != std::string::npos, + "problem errors array on bad petId"); + + RawResponse belowMin = roundtrip(ioc, port, + "GET /pets/0 HTTP/1.1\r\nHost: t\r\nConnection: close\r\n\r\n"); + expect(belowMin.status == 400, "petId below minimum should be 400"); + + // 400 on enum failure and limit bound failure. + RawResponse badEnum = roundtrip(ioc, port, + "GET /pets?status=unknown HTTP/1.1\r\nHost: t\r\nConnection: close\r\n\r\n"); + expect(badEnum.status == 400, "invalid status enum should be 400"); + + RawResponse badLimit = roundtrip(ioc, port, + "GET /pets?limit=0 HTTP/1.1\r\nHost: t\r\nConnection: close\r\n\r\n"); + expect(badLimit.status == 400, "limit below minimum should be 400"); + + // 400 on a numeric enum member outside the allowed set; 200 inside it. + RawResponse badTier = roundtrip(ioc, port, + "GET /pets?tier=15 HTTP/1.1\r\nHost: t\r\nConnection: close\r\n\r\n"); + expect(badTier.status == 400, "tier outside the integer enum should be 400"); + RawResponse goodTier = roundtrip(ioc, port, + "GET /pets?tier=20 HTTP/1.1\r\nHost: t\r\nConnection: close\r\n\r\n"); + expect(goodTier.status == 200, "tier inside the integer enum should be 200"); + + // 404 on unknown path. + RawResponse missing = roundtrip(ioc, port, + "GET /nope HTTP/1.1\r\nHost: t\r\nConnection: close\r\n\r\n"); + expect(missing.status == 404, "unknown path should be 404"); + + // 405 with Allow on wrong method. + RawResponse wrongMethod = roundtrip(ioc, port, + "PATCH /pets/42 HTTP/1.1\r\nHost: t\r\nConnection: close\r\n\r\n"); + expect(wrongMethod.status == 405, "wrong method should be 405"); + expect(wrongMethod.allow.find("GET") != std::string::npos + && wrongMethod.allow.find("PUT") != std::string::npos + && wrongMethod.allow.find("DELETE") != std::string::npos, + "Allow should list GET, PUT, DELETE"); + + // 400 on malformed JSON body. + RawResponse badJson = roundtrip(ioc, port, request("POST /pets", + "Authorization: Bearer ok\r\nContent-Type: application/json\r\n" + "Connection: close\r\n", "{oops}}")); + expect(badJson.status == 400, "malformed JSON body should be 400"); + + // 400 with errors[] on schema-invalid body (missing required name). + RawResponse missingName = roundtrip(ioc, port, request("POST /pets", + "Authorization: Bearer ok\r\nContent-Type: application/json\r\n" + "Connection: close\r\n", "{\"id\":7}")); + expect(missingName.status == 400, "missing required name should be 400"); + expect(missingName.body.find("\"errors\"") != std::string::npos, + "missing-name problem should carry errors"); + + // 201 on valid body. + RawResponse created = roundtrip(ioc, port, request("POST /pets", + "Authorization: Bearer ok\r\nContent-Type: application/json\r\n" + "Connection: close\r\n", "{\"id\":9,\"name\":\"rex\"}")); + expect(created.status == 201, "valid create should be 201"); + expect(created.body.find("rex") != std::string::npos, + "created body should echo name"); + // 400 on a body whose enum member is not declared (schema validation + // runs before the handler sees the value). + RawResponse badEnumBody = roundtrip(ioc, port, request("POST /pets", + "Authorization: Bearer ok\r\nContent-Type: application/json\r\n" + "Connection: close\r\n", + "{\"id\":9,\"name\":\"rex\",\"status\":\"invalid\"}")); + expect(badEnumBody.status == 400, + "undeclared enum member in body should be 400"); + // 400 on an explicit null for a required non-nullable field (no silent + // default substitution). Only the validation-enabled build enforces + // this at the request boundary; the compileWithValidation=false leg + // keeps the documented tolerate-null compatibility default. +#ifdef CPPBB_EXPECT_SCHEMA_VALIDATION + RawResponse nullRequired = roundtrip(ioc, port, request("POST /pets", + "Authorization: Bearer ok\r\nContent-Type: application/json\r\n" + "Connection: close\r\n", "{\"id\":null,\"name\":\"rex\"}")); + expect(nullRequired.status == 400, + "null for a required non-nullable body field should be 400"); +#endif + + // 415 on text/plain. + RawResponse wrongType = roundtrip(ioc, port, request("POST /pets", + "Authorization: Bearer ok\r\nContent-Type: text/plain\r\n" + "Connection: close\r\n", "hi")); + expect(wrongType.status == 415, "text/plain body should be 415"); + // 404 must not echo the query: a credential sent as ?api_key= would + // otherwise leak through detail/instance. + RawResponse missingKey = roundtrip(ioc, port, + "GET /nope?api_key=SUPERSECRET HTTP/1.1\r\nHost: t\r\n" + "Connection: close\r\n\r\n"); + expect(missingKey.status == 404, "unknown path with query should be 404"); + expect(missingKey.body.find("SUPERSECRET") == std::string::npos, + "404 problem must not echo the query string"); + + // 401 without credentials on POST. + RawResponse noAuth = roundtrip(ioc, port, request("POST /pets", + "Content-Type: application/json\r\nConnection: close\r\n", + "{\"id\":9,\"name\":\"rex\"}")); + expect(noAuth.status == 401, "POST without bearer should be 401"); + // RFC 9110 11.6.1: http-scheme 401 carries a challenge. The scheme token + // mirrors the declared casing (spec uses lowercase "bearer"; RFC 7235 + // treats auth scheme tokens case-insensitively). + expect(noAuth.wwwAuthenticate == "bearer realm=\"api\"", + "bearer 401 should carry WWW-Authenticate challenge"); + + // 401 on explicitly denied credentials. + RawResponse denied = roundtrip(ioc, port, request("POST /pets", + "Authorization: Bearer deny\r\nContent-Type: application/json\r\n" + "Connection: close\r\n", "{\"id\":9,\"name\":\"rex\"}")); + expect(denied.status == 401, "denied bearer should be 401"); + expect(denied.wwwAuthenticate == "bearer realm=\"api\"", + "denied bearer 401 should carry WWW-Authenticate challenge"); + + // 204 with valid API key (inherited global security) on DELETE. + RawResponse deleted = roundtrip(ioc, port, + "DELETE /pets/42 HTTP/1.1\r\nHost: t\r\nX-API-KEY: k1,k2\r\n" + "Connection: close\r\n\r\n"); + expect(deleted.status == 204, "DELETE with api_key should be 204"); + + // 401 without the API key on DELETE. + RawResponse noKey = roundtrip(ioc, port, + "DELETE /pets/42 HTTP/1.1\r\nHost: t\r\nConnection: close\r\n\r\n"); + expect(noKey.status == 401, "DELETE without api_key should be 401"); + // API keys have no standardized challenge form: none must be emitted. + expect(noKey.wwwAuthenticate.empty(), + "apiKey 401 should not carry WWW-Authenticate"); + + // 200 round-trip through the typed request-body decoder + echo. + RawResponse updated = roundtrip(ioc, port, request("PUT /pets/42", + "X-API-KEY: k1\r\nContent-Type: application/json\r\n" + "Connection: close\r\n", "{\"id\":42,\"name\":\"put-dog\"}")); + expect(updated.status == 200, "PUT with api_key should be 200"); + expect(updated.body.find("put-dog") != std::string::npos, + "PUT response should echo the decoded body"); + // Constraint inheritance from the PATH ITEM: `version` is declared on + // /pets/{petId}.parameters with minimum:5, so the raw-schema lookup + // must fall back beyond the operation list; violating values answer 400. + RawResponse versionLow = roundtrip(ioc, port, + "GET /pets/3?version=2 HTTP/1.1\r\nHost: t\r\nConnection: close\r\n\r\n"); + expect(versionLow.status == 400, + "path-item parameter constraint must be enforced (below minimum)"); + expect(versionLow.body.find("below the minimum") != std::string::npos, + "path-item constraint failure should report the minimum"); + RawResponse versionOk = roundtrip(ioc, port, + "GET /pets/3?version=9 HTTP/1.1\r\nHost: t\r\nConnection: close\r\n\r\n"); + expect(versionOk.status == 200, + "path-item parameter satisfying its constraint should be 200"); + + // HTTP/1.1 Host is a framing requirement: exactly one syntactically + // valid authority is required. HTTP/1.0 retains the legacy optional form. + RawResponse missingHost = roundtrip(ioc, port, + "GET /pets/3 HTTP/1.1\r\nConnection: close\r\n\r\n"); + expect(missingHost.status == 400, + "HTTP/1.1 request without Host should be 400"); + RawResponse duplicateHost = roundtrip(ioc, port, + "GET /pets/3 HTTP/1.1\r\nHost: one\r\nHost: two\r\n" + "Connection: close\r\n\r\n"); + expect(duplicateHost.status == 400, + "HTTP/1.1 request with duplicate Host should be 400"); + RawResponse invalidHost = roundtrip(ioc, port, + "GET /pets/3 HTTP/1.1\r\nHost: user@example.test\r\n" + "Connection: close\r\n\r\n"); + expect(invalidHost.status == 400, + "Host with userinfo should be rejected as an invalid authority"); + RawResponse noHost10 = roundtrip(ioc, port, + "GET /pets/3 HTTP/1.0\r\nConnection: close\r\n\r\n"); + expect(noHost10.status == 200, + "HTTP/1.0 request may omit Host"); + + RawResponse absoluteTarget = roundtrip(ioc, port, + "GET http://upstream.example/pets/3?version=9 HTTP/1.1\r\n" + "Host: proxy.example\r\nConnection: close\r\n\r\n"); + expect(absoluteTarget.status == 200, + "absolute-form HTTP target should normalize and route"); + + // HEAD carries the GET-equivalent Content-Length but no content octets. + // Reusing the connection for a GET proves no hidden HEAD body remains to + // desynchronize the next response parser. + { + boost::asio::ip::tcp::socket persistent(ioc); + persistent.connect(boost::asio::ip::tcp::endpoint{ + boost::asio::ip::make_address("127.0.0.1"), + static_cast(port)}); + boost::beast::flat_buffer carry; + std::string const headRequest = + "HEAD /pets/3 HTTP/1.1\r\nHost: t\r\n\r\n"; + boost::asio::write(persistent, boost::asio::buffer(headRequest)); + boost::beast::http::response_parser< + boost::beast::http::string_body> headParser; + headParser.skip(true); + boost::system::error_code headEc; + boost::beast::http::read(persistent, carry, headParser, headEc); + expect(!headEc && headParser.get().result_int() == 200, + "HEAD should receive a valid 200 response head"); + expect(headParser.content_length().value_or(0) > 0, + "HEAD should retain GET-equivalent Content-Length metadata"); + expect(headParser.get().body().empty(), + "HEAD response must not contain body bytes"); + + std::string const getAfterHead = + "GET /pets/8 HTTP/1.1\r\nHost: t\r\nConnection: close\r\n\r\n"; + boost::asio::write(persistent, boost::asio::buffer(getAfterHead)); + boost::beast::http::response afterHead; + boost::system::error_code afterHeadEc; + boost::beast::http::read(persistent, carry, afterHead, afterHeadEc); + expect(!afterHeadEc && afterHead.result_int() == 200, + "GET after HEAD should remain response-aligned"); + expect(afterHead.body().find("\"id\":8") != std::string::npos, + "GET after HEAD should return the second request body"); + } + + // 413 on a body over the configured limit. + std::string big(2048, 'x'); + RawResponse tooBig = roundtrip(ioc, port, request("POST /pets", + "Authorization: Bearer ok\r\nContent-Type: application/json\r\n" + "Connection: close\r\n", big)); + expect(tooBig.status == 413, "oversized body should be 413"); + + // 413 on HTTP/1.0 must still mirror the request version (parser head + // is available when body_limit fires). + RawResponse tooBig10 = roundtrip(ioc, port, requestVersioned( + "HTTP/1.0", "POST /pets", + "Authorization: Bearer ok\r\nContent-Type: application/json\r\n", + big)); + expect(tooBig10.status == 413, "oversized HTTP/1.0 body should be 413"); + expect(tooBig10.versionLine.rfind("HTTP/1.0", 0) == 0, + "413 response should mirror HTTP/1.0 (no stale version on error path)"); + + // HTTP/1.0 response version mirroring on the success path too. + RawResponse plain10 = roundtrip(ioc, port, requestVersioned( + "HTTP/1.0", "GET /pets/42", "", "")); + expect(plain10.status == 200, "plain HTTP/1.0 GET should be 200"); + expect(plain10.versionLine.rfind("HTTP/1.0", 0) == 0, + "HTTP/1.0 request should get an HTTP/1.0 response (RFC 9110 6.7)"); + + // Keep-alive: two requests on one connection. + { + boost::asio::ip::tcp::socket persistent(ioc); + persistent.connect(boost::asio::ip::tcp::endpoint{ + boost::asio::ip::make_address("127.0.0.1"), + static_cast(port)}); + RawResponse first = roundtrip(ioc, port, + "GET /pets/7 HTTP/1.1\r\nHost: t\r\n\r\n", + false, &persistent); + RawResponse second = roundtrip(ioc, port, + "GET /pets/8 HTTP/1.1\r\nHost: t\r\nConnection: close\r\n\r\n", + true, &persistent); + expect(first.status == 200 && second.status == 200, + "keep-alive should serve two requests on one connection"); + expect(second.body.find("\"id\":8") != std::string::npos, + "second keep-alive response should be for pet 8"); + } + + // Expect: 100-continue must be answered BEFORE the body is released. + // A client that holds its body back would deadlock against the read + // timeout on a server that reads head+body in one step, so the probe + // writes only the head and asserts the interim response arrives first. + { + boost::asio::ip::tcp::socket expectSocket(ioc); + expectSocket.connect(boost::asio::ip::tcp::endpoint{ + boost::asio::ip::make_address("127.0.0.1"), + static_cast(port)}); + const std::string body = "{\"id\":5,\"name\":\"cont\"}"; + const std::string head = + "POST /echo HTTP/1.1\r\nHost: t\r\nExpect: 100-continue\r\n" + "Content-Type: application/json\r\nConnection: close\r\n" + "Content-Length: " + std::to_string(body.size()) + "\r\n\r\n"; + boost::asio::write(expectSocket, boost::asio::buffer(head)); + expectSocket.non_blocking(true); + std::string transcript; + char probe[64]; + for (int attempt = 0; attempt < 300; ++attempt) { + boost::system::error_code probeEc; + std::size_t got = 0; + try { + got = expectSocket.read_some( + boost::asio::buffer(probe), probeEc); + } catch (boost::system::system_error const&) { + got = 0; + probeEc = boost::asio::error::would_block; + } + if (got > 0) { + transcript.append(probe, got); + break; // first bytes after the head = the interim response + } + if (probeEc != boost::asio::error::would_block) { + break; + } + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + } + expectSocket.non_blocking(false); + expect(transcript.rfind("HTTP/1.1 100", 0) == 0, + "Expect: 100-continue should receive an interim response first"); + boost::asio::write(expectSocket, boost::asio::buffer(body)); + char buffer[4096]; + boost::system::error_code readEc; + for (;;) { + std::size_t got = expectSocket.read_some( + boost::asio::buffer(buffer), readEc); + if (readEc) { + break; + } + transcript.append(buffer, got); + } + expect(transcript.find("HTTP/1.1 200") != std::string::npos, + "100-continue request should complete with the final response"); + expect(transcript.find("cont") != std::string::npos, + "final response should echo the released body"); + } + + // RFC 9110 5.3 also allows the expectation list across repeated field + // lines. An unsupported token on the SECOND line must still answer 417: + // tokenizing only the first line would silently ignore it. + RawResponse expectSecondLineBad = roundtrip(ioc, port, request("POST /echo", + "Expect: 100-continue\r\nExpect: confirm-10x\r\n" + "Content-Type: application/json\r\nConnection: close\r\n", + "{\"id\":1,\"name\":\"x\"}")); + expect(expectSecondLineBad.status == 417, + "unsupported token on a repeated Expect line should answer 417"); + // Repeated all-100-continue lines complete: interim, body, final 200. + { + boost::asio::ip::tcp::socket repeatSocket(ioc); + repeatSocket.connect(boost::asio::ip::tcp::endpoint{ + boost::asio::ip::make_address("127.0.0.1"), + static_cast(port)}); + const std::string body = "{\"id\":6,\"name\":\"twice\"}"; + const std::string head = + "POST /echo HTTP/1.1\r\nHost: t\r\nExpect: 100-continue\r\n" + "Expect: 100-continue\r\nContent-Type: application/json\r\n" + "Connection: close\r\nContent-Length: " + + std::to_string(body.size()) + "\r\n\r\n"; + boost::asio::write(repeatSocket, boost::asio::buffer(head)); + boost::asio::write(repeatSocket, boost::asio::buffer(body)); + std::string transcript; + char buffer[4096]; + boost::system::error_code readEc; + for (;;) { + std::size_t got = repeatSocket.read_some( + boost::asio::buffer(buffer), readEc); + if (readEc) { + break; + } + transcript.append(buffer, got); + // The Connection: close server hangs up after the final + // response, so EOF ends the read. Stop only once BOTH the 200 + // status line and the echoed body marker arrived; the body can + // split across segments (or land in the same segment as the + // headers), and breaking on the status line alone races the + // body and makes the "twice" assertion below flaky. + if (transcript.find("HTTP/1.1 200") != std::string::npos + && transcript.find("twice") != std::string::npos) { + break; + } + } + expect(transcript.find("HTTP/1.1 100") != std::string::npos + && transcript.find("HTTP/1.1 200") != std::string::npos, + "repeated 100-continue lines should interim then complete"); + expect(transcript.find("twice") != std::string::npos, + "repeated-Expect request should echo its body"); + } + + // Label-style pattern path parameter: valid then invalid. + RawResponse labelOk = roundtrip(ioc, port, + "GET /reports/.AB-12 HTTP/1.1\r\nHost: t\r\n" + "Authorization: Bearer ok\r\nCookie: session=x; lang=en\r\n" + "Connection: close\r\n\r\n"); + expect(labelOk.status == 200, "label path with valid pattern should be 200"); + // Cookie value decoded, OWS after ';' skipped, echoed via title. + expect(labelOk.body.find("\"title\":\"en\"") != std::string::npos, + "cookie lang should decode to en and echo in title"); + + RawResponse labelBad = roundtrip(ioc, port, + "GET /reports/.ab12 HTTP/1.1\r\nHost: t\r\n" + "Authorization: Bearer ok\r\nConnection: close\r\n\r\n"); + expect(labelBad.status == 400, "label path violating pattern should be 400"); + + // Pipe-delimited array query parameter. + RawResponse piped = roundtrip(ioc, port, + "GET /pets?tags=red%7Cblue HTTP/1.1\r\nHost: t\r\n" + "Connection: close\r\n\r\n"); + expect(piped.status == 200, "pipe-delimited tags should parse"); + + // Matrix-exploded array path parameter: ";ids=3;ids=4;ids=5" -> 3,4,5. + RawResponse batch = roundtrip(ioc, port, + "GET /batch/;ids=3;ids=4;ids=5 HTTP/1.1\r\nHost: t\r\n" + "Connection: close\r\n\r\n"); + expect(batch.status == 200, "matrix-exploded ids should parse"); + expect(batch.body.find("\"title\":\"3,4,5\"") != std::string::npos, + "matrix explode should strip repeated name= per element"); + + // Literal route registered AFTER /pets/{petId} must still win. + RawResponse bulk = roundtrip(ioc, port, + "GET /pets/bulk HTTP/1.1\r\nHost: t\r\nConnection: close\r\n\r\n"); + expect(bulk.status == 200, "/pets/bulk should hit the literal route"); + expect(bulk.body.find("\"id\":999") != std::string::npos, + "literal-over-param ranking should select getBulkPets"); + + // Deep-object required map + space-delimited array; completion is + // deferred 2s, surviving the expired 1s read timer via the re-arm. + RawResponse searchOk = roundtrip(ioc, port, + "GET /search?filter%5Bcolor%5D=blue HTTP/1.1\r\nHost: t\r\n" + "Connection: close\r\n\r\n"); + expect(searchOk.status == 200, + "deepObject filter should parse and deferred response should arrive"); + expect(searchOk.body.find("\"title\":\"1-0\"") != std::string::npos, + "deferred completion should echo 1 filter, 0 sort fields"); + + RawResponse searchSorted = roundtrip(ioc, port, + "GET /search?filter%5Bcolor%5D=blue&sort=a%20b HTTP/1.1\r\nHost: t\r\n" + "Connection: close\r\n\r\n"); + expect(searchSorted.status == 200, "space-delimited sort should parse"); + expect(searchSorted.body.find("\"title\":\"1-2\"") != std::string::npos, + "spaceDelimited sort should split into two elements"); + + RawResponse searchMissing = roundtrip(ioc, port, + "GET /search HTTP/1.1\r\nHost: t\r\nConnection: close\r\n\r\n"); + expect(searchMissing.status == 400, + "required deepObject filter missing should be 400"); + + // ---- declared defaults + presence ---- + // Absent optional parameters keep their OpenAPI defaults, not zero values. + RawResponse defaultsAbsent = roundtrip(ioc, port, + "GET /defaults?ids=aa,bb HTTP/1.1\r\nHost: t\r\nConnection: close\r\n\r\n"); + expect(defaultsAbsent.status == 200, "defaults with valid ids should be 200"); + expect(defaultsAbsent.body.find("\"title\":\"five|7|2|\"") != std::string::npos, + "absent name/limit should carry declared defaults (five, 7)"); + + RawResponse defaultsPresent = roundtrip(ioc, port, + "GET /defaults?name=hi&limit=3&ids=aa,bb HTTP/1.1\r\nHost: t\r\n" + "Connection: close\r\n\r\n"); + expect(defaultsPresent.status == 200, "explicit values should be 200"); + expect(defaultsPresent.body.find("\"title\":\"hi|3|2|\"") != std::string::npos, + "present name/limit should override declared defaults"); + + // Form query decoding converts '+' only for query values and splits a + // CSV delimiter before percent-decoding, so an escaped comma stays data. + RawResponse plusAsSpace = roundtrip(ioc, port, + "GET /defaults?name=a+b&ids=aa,bb HTTP/1.1\r\nHost: t\r\n" + "Connection: close\r\n\r\n"); + expect(plusAsSpace.body.find("\"title\":\"a b|7|2|\"") + != std::string::npos, + "query plus should decode as a space"); + RawResponse escapedPlus = roundtrip(ioc, port, + "GET /defaults?name=a%2Bb&ids=aa,bb HTTP/1.1\r\nHost: t\r\n" + "Connection: close\r\n\r\n"); + expect(escapedPlus.body.find("\"title\":\"a+b|7|2|\"") + != std::string::npos, + "percent-encoded plus should remain literal data"); + RawResponse escapedComma = roundtrip(ioc, port, + "GET /defaults?ids=a%2Cb,cc HTTP/1.1\r\nHost: t\r\n" + "Connection: close\r\n\r\n"); + expect(escapedComma.body.find("\"title\":\"five|7|2|\"") + != std::string::npos, + "escaped CSV comma should remain inside one array element"); + + // Exclusive bounds: score must be >1.5 and <3 (JSON Schema semantics). + RawResponse scoreLow = roundtrip(ioc, port, + "GET /defaults?ids=aa,bb&score=1.5 HTTP/1.1\r\nHost: t\r\n" + "Connection: close\r\n\r\n"); + expect(scoreLow.status == 400, "score == exclusiveMinimum should be 400"); + RawResponse scoreOk = roundtrip(ioc, port, + "GET /defaults?ids=aa,bb&score=1.75 HTTP/1.1\r\nHost: t\r\n" + "Connection: close\r\n\r\n"); + expect(scoreOk.status == 200, "score above exclusiveMinimum should be 200"); + RawResponse scoreHigh = roundtrip(ioc, port, + "GET /defaults?ids=aa,bb&score=3 HTTP/1.1\r\nHost: t\r\n" + "Connection: close\r\n\r\n"); + expect(scoreHigh.status == 400, "score == exclusiveMaximum should be 400"); + + // JSON Schema multipleOf uses exact decimal arithmetic over the wire + // lexeme, not a binary floating-point tolerance. + RawResponse exactStep = roundtrip(ioc, port, + "GET /defaults?ids=aa,bb&step=0.3 HTTP/1.1\r\nHost: t\r\n" + "Connection: close\r\n\r\n"); + expect(exactStep.status == 200, "0.3 should be a multiple of 0.1"); + RawResponse inexactStep = roundtrip(ioc, port, + "GET /defaults?ids=aa,bb&step=0.30000000000000004 HTTP/1.1\r\n" + "Host: t\r\nConnection: close\r\n\r\n"); + expect(inexactStep.status == 400, + "0.30000000000000004 should not be a multiple of 0.1"); + RawResponse strideOk = roundtrip(ioc, port, + "GET /defaults?ids=aa,bb&stride=6 HTTP/1.1\r\nHost: t\r\n" + "Connection: close\r\n\r\n"); + expect(strideOk.status == 200, "integer multipleOf should accept 6 / 3"); + RawResponse strideBad = roundtrip(ioc, port, + "GET /defaults?ids=aa,bb&stride=7 HTTP/1.1\r\nHost: t\r\n" + "Connection: close\r\n\r\n"); + expect(strideBad.status == 400, "integer multipleOf should reject 7 / 3"); + RawResponse ticksOk = roundtrip(ioc, port, + "GET /defaults?ids=aa,bb&ticks=2,4 HTTP/1.1\r\nHost: t\r\n" + "Connection: close\r\n\r\n"); + expect(ticksOk.status == 200, + "array item multipleOf should accept all valid elements"); + RawResponse encodedTicks = roundtrip(ioc, port, + "GET /defaults?ids=aa,bb&ticks=%32,%34 HTTP/1.1\r\nHost: t\r\n" + "Connection: close\r\n\r\n"); + expect(encodedTicks.status == 200, + "multipleOf should validate decoded query-array item lexemes"); + RawResponse ticksBad = roundtrip(ioc, port, + "GET /defaults?ids=aa,bb&ticks=2,3 HTTP/1.1\r\nHost: t\r\n" + "Connection: close\r\n\r\n"); + expect(ticksBad.status == 400, + "array item multipleOf should reject one invalid element"); + + // Collection + item constraints: minItems=2, uniqueItems, item minLength=2. + RawResponse fewItems = roundtrip(ioc, port, + "GET /defaults?ids=aa HTTP/1.1\r\nHost: t\r\nConnection: close\r\n\r\n"); + expect(fewItems.status == 400, "ids below minItems should be 400"); + RawResponse dupItems = roundtrip(ioc, port, + "GET /defaults?ids=aa,aa HTTP/1.1\r\nHost: t\r\nConnection: close\r\n\r\n"); + expect(dupItems.status == 400, "duplicate ids should violate uniqueItems"); + RawResponse shortItem = roundtrip(ioc, port, + "GET /defaults?ids=aa,b HTTP/1.1\r\nHost: t\r\nConnection: close\r\n\r\n"); + expect(shortItem.status == 400, "item below item minLength should be 400"); + + // ---- embedded path expressions ---- + RawResponse period = roundtrip(ioc, port, + "GET /periods/2026-8/summary HTTP/1.1\r\nHost: t\r\n" + "Connection: close\r\n\r\n"); + expect(period.status == 200, "embedded path expressions should route"); + expect(period.body.find("\"title\":\"2026-8\"") != std::string::npos, + "embedded expressions should capture year and month separately"); + RawResponse oddMonth = roundtrip(ioc, port, + "GET /periods/2026-7/summary HTTP/1.1\r\nHost: t\r\n" + "Connection: close\r\n\r\n"); + expect(oddMonth.status == 400, + "path scalar multipleOf should reject an odd month"); + // Header code-point length probe: two CJK characters are TWO code + // points and pass maxLength 2 despite six bytes; an overlong three-byte + // encoding of 'A' is malformed UTF-8 whose strict count is three bytes, + // so it must fail the same check (continuation bytes cannot be + // smuggled through a naive lead-byte count). + const std::string cjk{ + static_cast(0xE6), static_cast(0x97), + static_cast(0xA5), static_cast(0xE6), + static_cast(0x9C), static_cast(0xAC)}; // 日本 + const std::string overlong{ + static_cast(0xE0), static_cast(0x80), + static_cast(0x80)}; // overlong 'A' + RawResponse noteUnicode = roundtrip(ioc, port, + "GET /defaults?ids=aa,bb HTTP/1.1\r\nHost: t\r\n" + "X-Note: " + cjk + "\r\nConnection: close\r\n\r\n"); + expect(noteUnicode.status == 200, + "two CJK characters should pass maxLength as two code points"); + expect(noteUnicode.body.find(cjk) != std::string::npos, + "the header value should echo through the title"); + RawResponse noteOverlong = roundtrip(ioc, port, + "GET /defaults?ids=aa,bb HTTP/1.1\r\nHost: t\r\n" + "X-Note: " + overlong + "\r\nConnection: close\r\n\r\n"); + expect(noteOverlong.status == 400, + "overlong UTF-8 must degrade to a byte count (3 > 2) and answer 400"); + + RawResponse periodBad = roundtrip(ioc, port, + "GET /periods/20x6-8/summary HTTP/1.1\r\nHost: t\r\n" + "Connection: close\r\n\r\n"); + expect(periodBad.status == 400, "non-numeric embedded capture should be 400"); + + // ---- strict style codecs ---- + // Label without the leading dot is malformed (the dot is part of the + // label serialization), even though the bare value matches the pattern. + RawResponse labelNoDot = roundtrip(ioc, port, + "GET /reports/AB-12 HTTP/1.1\r\nHost: t\r\n" + "Authorization: Bearer ok\r\nConnection: close\r\n\r\n"); + expect(labelNoDot.status == 400, "label segment without dot should be 400"); + // Matrix segment with the wrong parameter name is malformed. + RawResponse batchWrongName = roundtrip(ioc, port, + "GET /batch/;x=3;x=4 HTTP/1.1\r\nHost: t\r\nConnection: close\r\n\r\n"); + expect(batchWrongName.status == 400, "matrix segment with wrong name should be 400"); + // Matrix element missing the repeated name= prefix is malformed. + RawResponse batchLoose = roundtrip(ioc, port, + "GET /batch/;ids=3;4 HTTP/1.1\r\nHost: t\r\nConnection: close\r\n\r\n"); + expect(batchLoose.status == 400, "matrix element without name= should be 400"); + + // ---- declared JSON response media type ---- + expect(labelOk.contentType.find("application/vnd.report+json") != std::string::npos, + "getReport should serve its declared +json media type"); + + // ---- optional request body ---- + RawResponse echoEmpty = roundtrip(ioc, port, + "POST /echo HTTP/1.1\r\nHost: t\r\nContent-Length: 0\r\n" + "Connection: close\r\n\r\n"); + expect(echoEmpty.status == 200, "optional body may be absent"); + expect(echoEmpty.body.find("\"id\":0") != std::string::npos, + "absent optional body should decode to the default model"); + RawResponse echoBad = roundtrip(ioc, port, request("POST /echo", + "Content-Type: application/json\r\nConnection: close\r\n", "{oops}}")); + expect(echoBad.status == 400, "present-but-malformed optional body should be 400"); + RawResponse echoGood = roundtrip(ioc, port, request("POST /echo", + "Content-Type: application/json\r\nConnection: close\r\n", + "{\"id\":5,\"name\":\"echoed\"}")); + expect(echoGood.status == 200 && echoGood.body.find("echoed") != std::string::npos, + "present optional body should decode and echo"); + // int64 leaf above 2^53 arriving in double form: the handler decode + // runs inside an ExactInstanceScope, so the id converts from the wire + // lexeme EXACTLY (9007199254740993.0 names an integer; the double image + // would silently round it to ...92). Only in the validation-enabled + // config: without the schema gate there is no lexeme table, and the + // documented decode-shape-only path rejects instead of corrupting. +#ifdef CPPBB_EXPECT_SCHEMA_VALIDATION + RawResponse echoExactBig = roundtrip(ioc, port, request("POST /echo", + "Content-Type: application/json\r\nConnection: close\r\n", + "{\"id\":9007199254740993.0,\"name\":\"exact\"}")); + expect(echoExactBig.status == 200, + "integral double naming an int64 above 2^53 should decode exactly"); + expect(echoExactBig.body.find("\"id\":9007199254740993") != std::string::npos, + "the echo must carry the lexeme's integer, not the rounded double"); +#endif + // ---- float wire grammar (parseScalar gates) ---- + // Plain decimal parses and reaches the handler. + RawResponse floatOk = roundtrip(ioc, port, + "GET /codec?w=1.5 HTTP/1.1\r\nHost: t\r\nConnection: close\r\n\r\n"); + expect(floatOk.status == 200, "decimal float query should be 200"); + expect(floatOk.body.find("\"title\":\"1.500000\"") != std::string::npos, + "decimal float should decode to 1.5"); + // C99 hex floats are not JSON numbers even though strtod consumes them. + RawResponse floatHex = roundtrip(ioc, port, + "GET /codec?w=0x10 HTTP/1.1\r\nHost: t\r\nConnection: close\r\n\r\n"); + expect(floatHex.status == 400, "hex float text should be 400"); + // Values outside float range must not reach the service as infinity. + RawResponse floatBig = roundtrip(ioc, port, + "GET /codec?w=1e40 HTTP/1.1\r\nHost: t\r\nConnection: close\r\n\r\n"); + expect(floatBig.status == 400, "float overflow should be 400"); + // Underflow (ERANGE to zero) is ordinary IEEE rounding: accepted. + RawResponse floatTiny = roundtrip(ioc, port, + "GET /codec?w=1e-400 HTTP/1.1\r\nHost: t\r\nConnection: close\r\n\r\n"); + expect(floatTiny.status == 200, "float underflow should be accepted"); + expect(floatTiny.body.find("\"title\":\"0.000000\"") != std::string::npos, + "underflowed float should arrive as zero"); + // strtod's inf/nan spellings are not JSON numbers. + RawResponse floatInf = roundtrip(ioc, port, + "GET /codec?w=inf HTTP/1.1\r\nHost: t\r\nConnection: close\r\n\r\n"); + expect(floatInf.status == 400, "inf text should be 400"); + + // 1e400 on a DOUBLE parameter: where long double is wider than double + // (x86 80-bit) the parse stays FINITE, so only the destination-range + // gate can refuse it — the leg must answer 400 on every platform. + RawResponse doubleBig = roundtrip(ioc, port, + "GET /codec?z=1e400 HTTP/1.1\r\nHost: t\r\nConnection: close\r\n\r\n"); + expect(doubleBig.status == 400, "double overflow should be 400"); + // A double inside range but far above float range still passes: the + // gate is destination-aware, not a blanket overflow ban. + RawResponse doubleOk = roundtrip(ioc, port, + "GET /codec?z=1e308 HTTP/1.1\r\nHost: t\r\nConnection: close\r\n\r\n"); + expect(doubleOk.status == 200, "in-range large double should be 200"); + expect(doubleOk.body.find("\"title\":\"0.000000|1") != std::string::npos, + "the double should decode and echo alongside the default float"); + // ---- fail-closed regex patterns ---- + // '(?i)' is outside std::regex's ECMAScript subset: construction throws, + // so the gate answers 400 for every present value instead of retry- + // throwing 500 on each request. + RawResponse patternHit = roundtrip(ioc, port, + "GET /codec?code=abc HTTP/1.1\r\nHost: t\r\nConnection: close\r\n\r\n"); + expect(patternHit.status == 400, "uncompilable scalar pattern should fail closed 400"); + expect(patternHit.body.find("supported regex grammar") != std::string::npos, + "fail-closed problem should explain the pattern grammar"); + // An absent optional parameter skips the (broken) pattern entirely. + RawResponse patternAbsent = roundtrip(ioc, port, + "GET /codec HTTP/1.1\r\nHost: t\r\nConnection: close\r\n\r\n"); + expect(patternAbsent.status == 200, "absent pattern parameter should be 200"); + // Item patterns fail closed the same way. + RawResponse itemPatternHit = roundtrip(ioc, port, + "GET /codec?codes=aa,bb HTTP/1.1\r\nHost: t\r\nConnection: close\r\n\r\n"); + expect(itemPatternHit.status == 400, "uncompilable item pattern should fail closed 400"); + // A Unicode property escape (\p{L}) is refused explicitly, not matched + // approximately: some std::regex libraries compile \p as an identity + // escape (literal "p"), which would silently mis-accept. The scanner + // rejects it before construction, so any present value answers 400. + RawResponse propertyEscape = roundtrip(ioc, port, + "GET /codec?uni=abc HTTP/1.1\r\nHost: t\r\nConnection: close\r\n\r\n"); + expect(propertyEscape.status == 400, + "unicode property-escape pattern should fail closed 400"); + expect(propertyEscape.body.find("supported regex grammar") != std::string::npos, + "property-escape problem should explain the grammar"); + // The same policy applies to a pattern nested in a BODY property: Pet.note + // declares '^\p{L}+$', which the model validator refuses as an unsupported + // pattern expression, so any payload carrying the property answers 400 + // (the object-property fail-closed path). Only when the schema registry is + // generated — the decode-shape-only config has no schema gate and accepts + // the payload. +#ifdef CPPBB_EXPECT_SCHEMA_VALIDATION + RawResponse propertyNote = roundtrip(ioc, port, request("POST /pets", + "Authorization: Bearer ok\r\nContent-Type: application/json\r\n" + "Connection: close\r\n", + "{\"id\":9,\"name\":\"rex\",\"note\":\"abc\"}")); + expect(propertyNote.status == 400, + "body property with property-escape pattern should fail closed 400"); + expect(propertyNote.body.find("unsupported pattern expression") != std::string::npos, + "body fail-closed problem should name the pattern grammar"); +#endif + + // An unsupported Expectation token (anything but 100-continue) is refused + // with 417 (RFC 9110 10.1.1) instead of being silently ignored: a client + // that requested an assurance the server cannot give must never have its + // body read as if the expectation held. + RawResponse expectUnknown = roundtrip(ioc, port, request("POST /echo", + "Content-Type: application/json\r\nExpect: confirm-10x\r\n" + "Connection: close\r\n", "{\"id\":1,\"name\":\"x\"}")); + expect(expectUnknown.status == 417, + "unsupported Expectation token should answer 417"); + // A list containing 100-continue plus an unsupported token is also 417: + // every expectation must be satisfiable before the interim response. + RawResponse expectMixed = roundtrip(ioc, port, request("POST /echo", + "Content-Type: application/json\r\nExpect: 100-continue, confirm-10x\r\n" + "Connection: close\r\n", "{\"id\":1,\"name\":\"x\"}")); + expect(expectMixed.status == 417, + "mixed Expectation list with an unsupported token should answer 417"); + + // ---- tagged variant (oneOf) response ---- + // Pick is a oneOf of two string branches sharing one C++ type, so the + // generated model is std::variant, + // CompositionBranchValue<1,std::string>>. Serving it exercises the + // response-side bodyLeaf unwrap. + RawResponse picked = roundtrip(ioc, port, + "GET /pick HTTP/1.1\r\nHost: t\r\nConnection: close\r\n\r\n"); + expect(picked.status == 200, "tagged variant response should be 200"); + expect(picked.body.find("\"Alpha\"") != std::string::npos, + "variant response should serialize the selected branch as its value"); + + ioc.stop(); + serverThread.join(); + + if (failures != 0) { + std::cerr << failures << " server runtime assertion(s) failed\n"; + return 1; + } + std::cout << "cpp-boost-beast-server runtime regressions passed\n"; + return 0; +} diff --git a/samples/client/petstore/cpp-boost-beast/generated/model/ApiResponse.cpp b/samples/client/petstore/cpp-boost-beast/generated/model/ApiResponse.cpp index ee699f1853c0..4283fb0aa9a8 100644 --- a/samples/client/petstore/cpp-boost-beast/generated/model/ApiResponse.cpp +++ b/samples/client/petstore/cpp-boost-beast/generated/model/ApiResponse.cpp @@ -403,6 +403,32 @@ struct JsonValueConverter } }; +// Integer destinations honour the exact wire lexeme when the decode runs +// inside an ExactInstanceScope (see tryGetMathematicalInteger). Plain +// value_to would accept the ROUNDED double image of any +// integral token above 2^53 — 9007199254740993.0 arriving as ...92 — +// because its static_cast equality check agrees with the image. +template <> +struct JsonValueConverter +{ + static boost::json::value toJsonValue(const std::int64_t& sourceValue) + { + return boost::json::value_from(sourceValue); + } + + static std::int64_t fromJsonValue(const boost::json::value& jsonValue) + { + std::int64_t result = 0; + if (!tryGetMathematicalInteger(jsonValue, result)) { + throw std::invalid_argument( + "Decode failed: value not representable as int64 " + "(non-integral, out of range, or past the exact window " + "with no wire lexeme to recover from)"); + } + return result; + } +}; + template <> struct JsonValueConverter { @@ -698,8 +724,11 @@ boost::json::object ApiResponse::toJsonObject_internal() const void ApiResponse::fromJsonObject_internal(boost::json::object const& object) { m_CodeIsSet = false; + m_Code = {}; m_TypeIsSet = false; + m_Type = {}; m_MessageIsSet = false; + m_Message = {}; { const auto CodeIt = object.find("code"); if (CodeIt != object.end()) { diff --git a/samples/client/petstore/cpp-boost-beast/generated/model/Category.cpp b/samples/client/petstore/cpp-boost-beast/generated/model/Category.cpp index 22d3402f1755..f1a31109bf3d 100644 --- a/samples/client/petstore/cpp-boost-beast/generated/model/Category.cpp +++ b/samples/client/petstore/cpp-boost-beast/generated/model/Category.cpp @@ -403,6 +403,32 @@ struct JsonValueConverter } }; +// Integer destinations honour the exact wire lexeme when the decode runs +// inside an ExactInstanceScope (see tryGetMathematicalInteger). Plain +// value_to would accept the ROUNDED double image of any +// integral token above 2^53 — 9007199254740993.0 arriving as ...92 — +// because its static_cast equality check agrees with the image. +template <> +struct JsonValueConverter +{ + static boost::json::value toJsonValue(const std::int64_t& sourceValue) + { + return boost::json::value_from(sourceValue); + } + + static std::int64_t fromJsonValue(const boost::json::value& jsonValue) + { + std::int64_t result = 0; + if (!tryGetMathematicalInteger(jsonValue, result)) { + throw std::invalid_argument( + "Decode failed: value not representable as int64 " + "(non-integral, out of range, or past the exact window " + "with no wire lexeme to recover from)"); + } + return result; + } +}; + template <> struct JsonValueConverter { @@ -695,7 +721,9 @@ boost::json::object Category::toJsonObject_internal() const void Category::fromJsonObject_internal(boost::json::object const& object) { m_IdIsSet = false; + m_Id = {}; m_NameIsSet = false; + m_Name = {}; { const auto IdIt = object.find("id"); if (IdIt != object.end()) { diff --git a/samples/client/petstore/cpp-boost-beast/generated/model/Oas31ExactJson.h b/samples/client/petstore/cpp-boost-beast/generated/model/Oas31ExactJson.h index daa2e749985d..ef7b5ac116b1 100644 --- a/samples/client/petstore/cpp-boost-beast/generated/model/Oas31ExactJson.h +++ b/samples/client/petstore/cpp-boost-beast/generated/model/Oas31ExactJson.h @@ -22,9 +22,12 @@ #include #include +#include +#include #include #include #include +#include #include #include #include @@ -389,6 +392,46 @@ inline InstanceLexemeTable const* activeInstanceLexemes( path = found->second; return context->lexemes; } + +/// Inside a live ExactInstanceScope the original wire lexeme is recoverable +/// for every numeric node of the parsed document. Returns nullptr when no +/// scope is active (plain parse, untouched DOM) or the node is not a +/// recorded number. +inline std::string const* exactNumericLexeme(boost::json::value const& json) { + std::string path; + auto const* table = activeInstanceLexemes(&json, path); + return table == nullptr ? nullptr : table->lexemeAt(path); +} + +/// Converts `lexeme` into `out` exactly when it names an integer within the +/// bounds of the destination type T. Works on the decimal text, never on a +/// binary-float image, so tokens the double cannot represent (integers +/// above 2^53 written with a fraction point or exponent) convert exactly. +/// A lexeme longer than the ExactNumber implementation limit throws +/// std::length_error; callers already treat that as a payload error. +template +bool exactLexemeToInteger(std::string const& lexeme, T& out) { + static_assert(std::is_integral_v && !std::is_same_v, + "exactLexemeToInteger requires an integral destination"); + ExactNumber const number = ExactNumber::parseLexeme(lexeme); + if (!number.isInteger()) { + return false; + } + using Big = ExactNumber::Integer; + ExactNumber const low(Big((std::numeric_limits::min)()), Big(0)); + ExactNumber const high(Big((std::numeric_limits::max)()), Big(0)); + if (number.compare(low) < 0 || number.compare(high) > 0) { + return false; + } + // The range check bounds the exponent (T's maximum has finitely many + // decimal digits), so this loop is finite and small by construction. + Big scaled = number.mantissa(); + for (Big e = number.exponent10(); e > 0; --e) { + scaled *= 10; + } + out = scaled.convert_to(); + return true; +} } // namespace org::openapitools::client::model::detail::schema_validation #endif // ORG_OPENAPITOOLS_CLIENT_MODEL_OAS31_EXACT_JSON_H_ diff --git a/samples/client/petstore/cpp-boost-beast/generated/model/Oas31Validator.h b/samples/client/petstore/cpp-boost-beast/generated/model/Oas31Validator.h index 48b9cfcc4a53..c852b276c457 100644 --- a/samples/client/petstore/cpp-boost-beast/generated/model/Oas31Validator.h +++ b/samples/client/petstore/cpp-boost-beast/generated/model/Oas31Validator.h @@ -847,63 +847,102 @@ class SchemaEvaluator { // ==================================================================== // ECMAScript-subset pattern engine // - // The implementation decodes code points before translating supported - // Unicode letter escapes to wide-regex ranges. Matching is unanchored unless - // the pattern supplies anchors; unsupported constructs fail closed. + // The implementation decodes code points before compiling a wide regex. + // Matching is unanchored unless the pattern supplies anchors; unsupported + // constructs fail closed. // ==================================================================== - /// Letter ranges approximated from the Unicode Letter categories for the - /// corpus surface (Latin, Greek, Cyrillic, Armenian, Hebrew, Arabic, - /// Indic, CJK, Hangul). Documented approximation: not exhaustive. - static char const* letterRanges() { - return "a-zA-Z" - "\\u00C0-\\u00FF\\u0100-\\u024F" - "\\u0370-\\u03FF\\u0400-\\u04FF" - "\\u0500-\\u052F\\u0531-\\u058F" - "\\u0591-\\u05FF\\u0600-\\u06FF" - "\\u0900-\\u097F\\u0A00-\\u0A7F" - "\\u0B00-\\u0B7F\\u0C00-\\u0C7F" - "\\u0D00-\\u0D7F\\u1E00-\\u1EFF" - "\\u3041-\\u3096\\u30A1-\\u30FA" - "\\u3400-\\u4DBF\\u4E00-\\u9FFF" - "\\uAC00-\\uD7A3"; - } - - static std::string replaceAll(std::string s, std::string const& from, - std::string const& to) { - std::size_t pos = 0; - while ((pos = s.find(from, pos)) != std::string::npos) { - s.replace(pos, from.size(), to); - pos += to.size(); + /// True when `pattern` contains a real Unicode property escape + /// (\\p{...} / \\P{...}). std::regex's ECMAScript subset has no complete + /// representation for these, and a hand-maintained code-point range list + /// can only ever approximate a property like Letter — silently rejecting + /// valid letters outside the listed scripts. Such patterns are therefore + /// reported as unsupported (fail closed) rather than approximated: the + /// validator answers with an explicit "unsupported pattern expression" + /// error instead of a wrong accept/reject. Only a backslash starting an + /// ODD-length run escapes the next character; in "\\\\p{L}" the doubled + /// backslashes are literals and the following p is ordinary text. + static bool hasUnicodePropertyEscape(std::string const& pattern) { + for (std::size_t i = 0; i < pattern.size(); ++i) { + if (pattern[i] != '\\') { + continue; + } + std::size_t slashes = 0; + while (i + slashes < pattern.size() && pattern[i + slashes] == '\\') { + ++slashes; + } + std::size_t const after = i + slashes; + if (after >= pattern.size()) { + break; // run ends the pattern; nothing to test + } + if ((slashes % 2) == 0) { + // Even run: every backslash is itself escaped, the following + // character is a literal; loop ++i lands right after the run. + i = after - 1; + continue; + } + if ((pattern[after] == 'p' || pattern[after] == 'P') + && after + 1 < pattern.size() && pattern[after + 1] == '{') { + return true; + } + // Odd run that is not a property escape: the escaped character + // is a literal; consume it so its bytes are not re-scanned. + i = after; } - return s; + return false; } - /// Translate \p{...} / \P{...} letter escapes into explicit ranges. static std::wstring normalizeEcmaPattern(std::string p) { - p = replaceAll(std::move(p), "\\p{Letter}", - "[" + std::string(letterRanges()) + "]"); - p = replaceAll(std::move(p), "\\p{L}", - "[" + std::string(letterRanges()) + "]"); - p = replaceAll(std::move(p), "\\P{Letter}", - "[^" + std::string(letterRanges()) + "]"); - p = replaceAll(std::move(p), "\\P{L}", - "[^" + std::string(letterRanges()) + "]"); return utf8ToWide(std::move(p)); } - /// UTF-8 code-point count: skips continuation bytes; invalid sequences - /// degrade to a byte count (never a crash). + /// UTF-8 code-point count: a complete valid sequence counts once; any + /// byte not part of one (invalid lead, truncated sequence, overlong, + /// surrogate, orphaned continuation) counts as one, so malformed input + /// degrades to a byte count and can never UNDERCOUNT — same grammar as + /// the parameter codecs' utf8CodepointCount. static std::size_t countCodePoints(std::string const& s) { - std::size_t n = 0; - for (unsigned char c : s) { - if ((c & 0xC0) != 0x80) ++n; + std::size_t count = 0; + std::size_t i = 0; + while (i < s.size()) { + unsigned char const lead = static_cast(s[i]); + std::size_t extra; + if (lead < 0x80) { + extra = 0; + } else if (lead >= 0xC2 && lead <= 0xDF) { + extra = 1; + } else if (lead >= 0xE0 && lead <= 0xEF) { + extra = 2; + } else if (lead >= 0xF0 && lead <= 0xF4) { + extra = 3; + } else { + extra = static_cast(-1); // C0/C1/F5+/stray cont + } + bool whole = extra != static_cast(-1) + && i + extra < s.size(); + for (std::size_t k = 1; whole && k <= extra; ++k) { + unsigned char const next = static_cast(s[i + k]); + if (next < 0x80 || next > 0xBF + || (k == 1 && ( + (lead == 0xE0 && next < 0xA0) + || (lead == 0xED && next > 0x9F) + || (lead == 0xF0 && next < 0x90) + || (lead == 0xF4 && next > 0x8F)))) { + whole = false; + } + } + ++count; + i += whole ? extra + 1 : 1; } - return n; + return count; } - /// Decode UTF-8 into code-point values stored in wchar_t (32-bit on - /// macOS/Linux). Invalid bytes pass through verbatim. + /// Decode UTF-8 into code-point values stored in wchar_t. On 32-bit + /// wchar_t (macOS/Linux) each element is one scalar; on 16-bit wchar_t + /// (Windows) scalars above U+FFFF are encoded as UTF-16 surrogate pairs, + /// which is what std::wregex there matches against — storing the bare + /// code point would silently truncate it to its low 16 bits. Invalid + /// bytes pass through verbatim. static std::wstring utf8ToWide(std::string const& s) { std::wstring out; out.reserve(s.size()); @@ -927,7 +966,13 @@ class SchemaEvaluator { cp = (cp << 6) | (cc & 0x3F); } if (!ok) { out.push_back(static_cast(c)); ++i; continue; } - out.push_back(static_cast(cp)); + if (sizeof(wchar_t) == 2 && cp > 0xFFFF) { + std::uint32_t v = cp - 0x10000; + out.push_back(static_cast(0xD800 + (v >> 10))); + out.push_back(static_cast(0xDC00 + (v & 0x3FF))); + } else { + out.push_back(static_cast(cp)); + } i += extra + 1; } return out; @@ -938,9 +983,14 @@ class SchemaEvaluator { bool matched; }; - /// Unanchored ECMAScript-subset search on code points. + /// Unanchored ECMAScript-subset search on code points. Property escapes + /// are refused before compilation (see hasUnicodePropertyEscape); no + /// approximation is attempted. static RegexMatch ecmaRegexSearch(std::string const& pattern, std::string const& key) { + if (hasUnicodePropertyEscape(pattern)) { + return {false, false}; + } try { std::wregex re(normalizeEcmaPattern(pattern), std::regex_constants::ECMAScript); diff --git a/samples/client/petstore/cpp-boost-beast/generated/model/Order.cpp b/samples/client/petstore/cpp-boost-beast/generated/model/Order.cpp index b68aad578c8e..6561ab1624b6 100644 --- a/samples/client/petstore/cpp-boost-beast/generated/model/Order.cpp +++ b/samples/client/petstore/cpp-boost-beast/generated/model/Order.cpp @@ -498,6 +498,32 @@ struct JsonValueConverter } }; +// Integer destinations honour the exact wire lexeme when the decode runs +// inside an ExactInstanceScope (see tryGetMathematicalInteger). Plain +// value_to would accept the ROUNDED double image of any +// integral token above 2^53 — 9007199254740993.0 arriving as ...92 — +// because its static_cast equality check agrees with the image. +template <> +struct JsonValueConverter +{ + static boost::json::value toJsonValue(const std::int64_t& sourceValue) + { + return boost::json::value_from(sourceValue); + } + + static std::int64_t fromJsonValue(const boost::json::value& jsonValue) + { + std::int64_t result = 0; + if (!tryGetMathematicalInteger(jsonValue, result)) { + throw std::invalid_argument( + "Decode failed: value not representable as int64 " + "(non-integral, out of range, or past the exact window " + "with no wire lexeme to recover from)"); + } + return result; + } +}; + template <> struct JsonValueConverter { @@ -802,11 +828,17 @@ boost::json::object Order::toJsonObject_internal() const void Order::fromJsonObject_internal(boost::json::object const& object) { m_IdIsSet = false; + m_Id = {}; m_PetIdIsSet = false; + m_PetId = {}; m_QuantityIsSet = false; + m_Quantity = {}; m_ShipDateIsSet = false; + m_ShipDate = {}; m_StatusIsSet = false; + m_Status = {}; m_CompleteIsSet = false; + m_Complete = {}; { const auto IdIt = object.find("id"); if (IdIt != object.end()) { diff --git a/samples/client/petstore/cpp-boost-beast/generated/model/Pet.cpp b/samples/client/petstore/cpp-boost-beast/generated/model/Pet.cpp index 8d9da2681c76..8a18d0dec32e 100644 --- a/samples/client/petstore/cpp-boost-beast/generated/model/Pet.cpp +++ b/samples/client/petstore/cpp-boost-beast/generated/model/Pet.cpp @@ -498,6 +498,32 @@ struct JsonValueConverter } }; +// Integer destinations honour the exact wire lexeme when the decode runs +// inside an ExactInstanceScope (see tryGetMathematicalInteger). Plain +// value_to would accept the ROUNDED double image of any +// integral token above 2^53 — 9007199254740993.0 arriving as ...92 — +// because its static_cast equality check agrees with the image. +template <> +struct JsonValueConverter +{ + static boost::json::value toJsonValue(const std::int64_t& sourceValue) + { + return boost::json::value_from(sourceValue); + } + + static std::int64_t fromJsonValue(const boost::json::value& jsonValue) + { + std::int64_t result = 0; + if (!tryGetMathematicalInteger(jsonValue, result)) { + throw std::invalid_argument( + "Decode failed: value not representable as int64 " + "(non-integral, out of range, or past the exact window " + "with no wire lexeme to recover from)"); + } + return result; + } +}; + template <> struct JsonValueConverter { @@ -798,9 +824,15 @@ boost::json::object Pet::toJsonObject_internal() const void Pet::fromJsonObject_internal(boost::json::object const& object) { m_IdIsSet = false; + m_Id = {}; m_CategoryIsSet = false; + m_Category = {}; + m_Name = {}; + m_PhotoUrls = {}; m_TagsIsSet = false; + m_Tags = {}; m_StatusIsSet = false; + m_Status = {}; { const auto IdIt = object.find("id"); if (IdIt != object.end()) { diff --git a/samples/client/petstore/cpp-boost-beast/generated/model/Tag.cpp b/samples/client/petstore/cpp-boost-beast/generated/model/Tag.cpp index c9387fa14192..3dc9706896d9 100644 --- a/samples/client/petstore/cpp-boost-beast/generated/model/Tag.cpp +++ b/samples/client/petstore/cpp-boost-beast/generated/model/Tag.cpp @@ -403,6 +403,32 @@ struct JsonValueConverter } }; +// Integer destinations honour the exact wire lexeme when the decode runs +// inside an ExactInstanceScope (see tryGetMathematicalInteger). Plain +// value_to would accept the ROUNDED double image of any +// integral token above 2^53 — 9007199254740993.0 arriving as ...92 — +// because its static_cast equality check agrees with the image. +template <> +struct JsonValueConverter +{ + static boost::json::value toJsonValue(const std::int64_t& sourceValue) + { + return boost::json::value_from(sourceValue); + } + + static std::int64_t fromJsonValue(const boost::json::value& jsonValue) + { + std::int64_t result = 0; + if (!tryGetMathematicalInteger(jsonValue, result)) { + throw std::invalid_argument( + "Decode failed: value not representable as int64 " + "(non-integral, out of range, or past the exact window " + "with no wire lexeme to recover from)"); + } + return result; + } +}; + template <> struct JsonValueConverter { @@ -695,7 +721,9 @@ boost::json::object Tag::toJsonObject_internal() const void Tag::fromJsonObject_internal(boost::json::object const& object) { m_IdIsSet = false; + m_Id = {}; m_NameIsSet = false; + m_Name = {}; { const auto IdIt = object.find("id"); if (IdIt != object.end()) { diff --git a/samples/client/petstore/cpp-boost-beast/generated/model/User.cpp b/samples/client/petstore/cpp-boost-beast/generated/model/User.cpp index d30857c25157..e00d3bf8cbf1 100644 --- a/samples/client/petstore/cpp-boost-beast/generated/model/User.cpp +++ b/samples/client/petstore/cpp-boost-beast/generated/model/User.cpp @@ -403,6 +403,32 @@ struct JsonValueConverter } }; +// Integer destinations honour the exact wire lexeme when the decode runs +// inside an ExactInstanceScope (see tryGetMathematicalInteger). Plain +// value_to would accept the ROUNDED double image of any +// integral token above 2^53 — 9007199254740993.0 arriving as ...92 — +// because its static_cast equality check agrees with the image. +template <> +struct JsonValueConverter +{ + static boost::json::value toJsonValue(const std::int64_t& sourceValue) + { + return boost::json::value_from(sourceValue); + } + + static std::int64_t fromJsonValue(const boost::json::value& jsonValue) + { + std::int64_t result = 0; + if (!tryGetMathematicalInteger(jsonValue, result)) { + throw std::invalid_argument( + "Decode failed: value not representable as int64 " + "(non-integral, out of range, or past the exact window " + "with no wire lexeme to recover from)"); + } + return result; + } +}; + template <> struct JsonValueConverter { @@ -713,13 +739,21 @@ boost::json::object User::toJsonObject_internal() const void User::fromJsonObject_internal(boost::json::object const& object) { m_IdIsSet = false; + m_Id = {}; m_UsernameIsSet = false; + m_Username = {}; m_FirstNameIsSet = false; + m_FirstName = {}; m_LastNameIsSet = false; + m_LastName = {}; m_EmailIsSet = false; + m_Email = {}; m_PasswordIsSet = false; + m_Password = {}; m_PhoneIsSet = false; + m_Phone = {}; m_UserStatusIsSet = false; + m_UserStatus = {}; { const auto IdIt = object.find("id"); if (IdIt != object.end()) { diff --git a/samples/client/petstore/cpp-boost-beast/generated/model/ValidationTypes.h b/samples/client/petstore/cpp-boost-beast/generated/model/ValidationTypes.h index 6a70965c9b01..89ff93c5ed42 100644 --- a/samples/client/petstore/cpp-boost-beast/generated/model/ValidationTypes.h +++ b/samples/client/petstore/cpp-boost-beast/generated/model/ValidationTypes.h @@ -24,6 +24,7 @@ #include +#include #include #include #include @@ -33,6 +34,10 @@ #include #include #include +// The exact-JSON runtime lives beside this header and supplies the wire +// lexeme recovery used by tryGetMathematicalInteger's double branch. There +// is no cycle: Oas31ExactJson.h does not include this header. +#include "Oas31ExactJson.h" namespace org { namespace openapitools { @@ -172,6 +177,25 @@ inline bool isJsonInteger(boost::json::value const& v) { /// Attempts to extract a mathematical integer into a checked destination. /// Returns false when the value is not integral or exceeds destination bounds. +/// A double is a trustworthy integer image only inside the doubles' own +/// exact window, |value| <= 2^53. The upper edge stays open: +2^53 is also +/// the ties-to-even image of the token 9007199254740993, so accepting it +/// would decode a different wire integer as the image. The lower edge is +/// open only for a destination reaching the precision boundary (int64), +/// whose -2^53 image is likewise ambiguous with -9007199254740993; for a +/// narrower signed destination the full range lies inside the exact +/// window, so the destination's own minimum image is accepted. +/// (For an unsigned destination the window's lower edge is zero, and zero +/// is never the image of a different integral token — +0.0 and -0.0 both +/// name the integer 0 — so zero stays accepted there.) Above the window +/// every double IS integral (modf says 0) yet the token that produced it may +/// already have been rounded at parse time, and every cast-based exactness +/// check then agrees with the rounded image. Inside an ExactInstanceScope +/// the wire lexeme is authoritative and is consulted FIRST, before any +/// image-based check: it converts exactly (9007199254740993.0 names an +/// integer the binary image cannot; 1.0000000000000001 names a +/// non-integer the image cannot see). Outside any scope the window is +/// enforced and a value beyond it is refused rather than silently corrupted. template bool tryGetMathematicalInteger(boost::json::value const& v, T& out) { static_assert(std::is_integral_v, @@ -202,6 +226,17 @@ bool tryGetMathematicalInteger(boost::json::value const& v, T& out) { return true; } case boost::json::kind::double_: { + // The wire lexeme outranks the binary image whenever it exists: + // an image check would accept 1.0000000000000001 (which rounds to + // the integer 1.0) and reject 9223372036854775807.0 (which rounds + // past int64's maximum), inverting both verdicts. A lexeme beyond + // the ExactNumber limit throws std::length_error; callers map + // that to a payload error. + if (std::string const* lexeme = + org::openapitools::client::model::detail::schema_validation::exactNumericLexeme(v)) { + return org::openapitools::client::model::detail::schema_validation::exactLexemeToInteger( + *lexeme, out); + } double const value = v.as_double(); double integralPart; if (!std::isfinite(value) @@ -216,8 +251,33 @@ bool tryGetMathematicalInteger(boost::json::value const& v, T& out) { || integralPart >= upperExclusive) { return false; } - out = static_cast(integralPart); - return true; + int const trustDigits = (std::min)( + std::numeric_limits::digits, + std::numeric_limits::digits); + double const trustUpper = std::ldexp(1.0, trustDigits); + // Inside the exact window every double image names exactly one + // integer, so the window may span the destination's own bounds. + // Upper stays open: images at or past 2^digits are ambiguous + // once the destination reaches the precision boundary (int64's + // 2^63 edge). Lower closes to -2^digits only when the signed + // destination's full range fits inside the window — otherwise + // int32 would refuse its very own minimum, -2^31, whose image + // is unambiguous. Unsigned: the zero edge is unambiguous, so + // 0.0 / -0.0 stay accepted. + bool const rangeFitsExactly = std::is_signed_v + && std::numeric_limits::digits + < std::numeric_limits::digits; + double const trustLower = std::is_signed_v + ? (rangeFitsExactly ? -trustUpper : -std::nextafter(trustUpper, 0.0)) + : 0.0; + if (integralPart >= trustLower && integralPart < trustUpper) { + out = static_cast(integralPart); + return true; + } + // At or past the trust window with no lexeme to recover the + // token from: fail closed rather than return a possibly rounded + // image. + return false; } default: return false; diff --git a/samples/server/petstore/cpp-boost-beast-server/.openapi-generator-ignore b/samples/server/petstore/cpp-boost-beast-server/.openapi-generator-ignore new file mode 100644 index 000000000000..7484ee590a38 --- /dev/null +++ b/samples/server/petstore/cpp-boost-beast-server/.openapi-generator-ignore @@ -0,0 +1,23 @@ +# OpenAPI Generator Ignore +# Generated by openapi-generator https://github.com/openapitools/openapi-generator + +# Use this file to prevent files from being overwritten by the generator. +# The patterns follow closely to .gitignore or .dockerignore. + +# As an example, the C# client generator defines ApiClient.cs. +# You can make changes and tell OpenAPI Generator to ignore just this file by uncommenting the following line: +#ApiClient.cs + +# You can match any string of characters against a directory, file or extension with a single asterisk (*): +#foo/*/qux +# The above matches foo/bar/qux and foo/baz/qux, but not foo/bar/baz/qux + +# You can recursively match patterns against a directory, file or extension with a double asterisk (**): +#foo/**/qux +# This matches foo/bar/qux, foo/baz/qux, and foo/bar/baz/qux + +# You can also negate patterns with an exclamation (!). +# For example, you can ignore all files in a docs folder with the file extension .md: +#docs/*.md +# Then explicitly reverse the ignore rule for a single file: +#!docs/README.md diff --git a/samples/server/petstore/cpp-boost-beast-server/.openapi-generator/FILES b/samples/server/petstore/cpp-boost-beast-server/.openapi-generator/FILES new file mode 100644 index 000000000000..a12c9a0c3b67 --- /dev/null +++ b/samples/server/petstore/cpp-boost-beast-server/.openapi-generator/FILES @@ -0,0 +1,34 @@ +CMakeLists.txt +README.md +api/PetsApi.cpp +api/PetsApi.h +api/StoreApi.cpp +api/StoreApi.h +api/UsersApi.cpp +api/UsersApi.h +main.cpp +model/AnyType.h +model/Error.cpp +model/Error.h +model/NullableField.h +model/Oas31DeepEqual.h +model/Oas31ExactJson.h +model/Oas31ExactNumber.cpp +model/Oas31ExactNumber.h +model/Oas31SchemaIr.h +model/Oas31SchemaRegistry.h +model/Oas31Validator.h +model/Pet.cpp +model/Pet.h +model/User.cpp +model/User.h +model/ValidationTypes.h +model/schema_ir.generated.cpp +server/Authorizer.h +server/BodyJson.h +server/HttpServer.cpp +server/HttpServer.h +server/ParamCodecs.h +server/Problem.h +server/Responder.h +server/Router.h diff --git a/samples/server/petstore/cpp-boost-beast-server/.openapi-generator/VERSION b/samples/server/petstore/cpp-boost-beast-server/.openapi-generator/VERSION new file mode 100644 index 000000000000..32a8cfaceeb9 --- /dev/null +++ b/samples/server/petstore/cpp-boost-beast-server/.openapi-generator/VERSION @@ -0,0 +1 @@ +7.26.0-SNAPSHOT diff --git a/samples/server/petstore/cpp-boost-beast-server/CMakeLists.txt b/samples/server/petstore/cpp-boost-beast-server/CMakeLists.txt new file mode 100644 index 000000000000..7de08ae116c5 --- /dev/null +++ b/samples/server/petstore/cpp-boost-beast-server/CMakeLists.txt @@ -0,0 +1,132 @@ +cmake_minimum_required(VERSION 3.14) +project(CppBoostBeastPetstoreServer VERSION 1.0.0 LANGUAGES CXX) + +include(GNUInstallDirs) + +if (POLICY CMP0167) + cmake_policy(SET CMP0167 OLD) +endif () + +set(BOOST_BOOST_TARGET_PREDEFINED FALSE) +set(BOOST_JSON_TARGET_PREDEFINED FALSE) +set(BOOST_URL_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 () +if (TARGET Boost::url) + set(BOOST_URL_TARGET_PREDEFINED TRUE) +endif () + +# Boost.URL is linked as a compiled component (Boost 1.81+). +find_package(Boost 1.81 REQUIRED COMPONENTS json url) +# 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 () +if (NOT BOOST_URL_TARGET_PREDEFINED) + set_property(TARGET Boost::url PROPERTY IMPORTED_GLOBAL TRUE) +endif () +set(THREADS_TARGET_PREDEFINED FALSE) +if (TARGET Threads::Threads) + set(THREADS_TARGET_PREDEFINED TRUE) +endif () +set(THREADS_PREFER_PTHREAD_FLAG TRUE) +find_package(Threads REQUIRED) +if (NOT THREADS_TARGET_PREDEFINED) + set_property(TARGET Threads::Threads PROPERTY IMPORTED_GLOBAL TRUE) +endif () + +# Generated code is held warning-clean. GCC/Clang use -Wall/-Wextra and +# MSVC uses /W4 with conforming language mode; the default WERROR option +# promotes those warnings to errors on every compiler. +option(CPP_BOOST_BEAST_SERVER_WERROR "Treat compiler warnings as errors" ON) + +if (MSVC) + add_compile_options(/W4 /permissive-) + if (CPP_BOOST_BEAST_SERVER_WERROR) + add_compile_options(/WX) + endif () +else () + set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -fPIC -Wall -Wextra") + if (CPP_BOOST_BEAST_SERVER_WERROR) + set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -Werror") + endif () +endif () + + +add_library(CppBoostBeastPetstoreServer STATIC) + +set_property(TARGET CppBoostBeastPetstoreServer PROPERTY CXX_STANDARD 17) +set_property(TARGET CppBoostBeastPetstoreServer PROPERTY CXX_STANDARD_REQUIRED ON) +set_property(TARGET CppBoostBeastPetstoreServer PROPERTY CXX_EXTENSIONS OFF) + +target_sources(CppBoostBeastPetstoreServer PRIVATE +# models + model/Error.cpp + model/Error.h + model/Pet.cpp + model/Pet.h + model/User.cpp + model/User.h +# apis + api/PetsApi.cpp + api/PetsApi.h + api/StoreApi.cpp + api/StoreApi.h + api/UsersApi.cpp + api/UsersApi.h +# server runtime + server/Authorizer.h + server/BodyJson.h + server/HttpServer.cpp + server/HttpServer.h + server/ParamCodecs.h + server/Problem.h + server/Responder.h + server/Router.h +# shared model/validation support + model/AnyType.h + model/NullableField.h + model/Oas31DeepEqual.h + model/Oas31ExactNumber.cpp + model/Oas31ExactNumber.h + model/Oas31SchemaIr.h + model/Oas31ExactJson.h + model/Oas31Validator.h + model/ValidationTypes.h + model/schema_ir.generated.cpp + model/Oas31SchemaRegistry.h +) + +target_link_libraries(CppBoostBeastPetstoreServer + PUBLIC Boost::boost Boost::json Boost::url Threads::Threads) + +target_include_directories(CppBoostBeastPetstoreServer PUBLIC + $ + $ + $ + $ + $ + $ + $ + $) + +install(TARGETS CppBoostBeastPetstoreServer + ARCHIVE DESTINATION "${CMAKE_INSTALL_LIBDIR}") + +install(DIRECTORY api model server + DESTINATION "${CMAKE_INSTALL_INCLUDEDIR}/${PROJECT_NAME}" + FILES_MATCHING PATTERN "*.h") + +add_executable(${PROJECT_NAME}_main main.cpp) +set_property(TARGET ${PROJECT_NAME}_main PROPERTY CXX_STANDARD 17) +set_property(TARGET ${PROJECT_NAME}_main PROPERTY CXX_STANDARD_REQUIRED ON) +set_property(TARGET ${PROJECT_NAME}_main PROPERTY CXX_EXTENSIONS OFF) +target_link_libraries(${PROJECT_NAME}_main PRIVATE CppBoostBeastPetstoreServer) diff --git a/samples/server/petstore/cpp-boost-beast-server/README.md b/samples/server/petstore/cpp-boost-beast-server/README.md new file mode 100644 index 000000000000..474b5378394f --- /dev/null +++ b/samples/server/petstore/cpp-boost-beast-server/README.md @@ -0,0 +1,192 @@ +/** + * Petstore Server + * Sample petstore server for the cpp-boost-beast-server generator + * + * The version of the OpenAPI document: 1.0.0 + * + * NOTE: This class is auto generated by OpenAPI-Generator 7.26.0-SNAPSHOT. + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +# CppBoostBeastPetstoreServer — Boost.Beast server + +Sample petstore server for the cpp-boost-beast-server generator + +Generated from an OpenAPI document by **openapi-generator** (`cpp-boost-beast-server`). + +## Requirements + +- C++17 compiler +- CMake ≥ 3.14 +- Boost ≥ 1.81 (headers, `json`, and URL — Beast/Asio are header-only) + +## Layout + +- `api/` — generated service interfaces, typed request structs, per-operation + responders, and route registration (`Api::attach`) +- `model/` — generated model types plus the shared OAS 3.1 exact-validation + runtime (`Oas31*`) and schema registry +- `server/` — HTTP/1.1 runtime: `HttpServer`, `Router`, `Responder`, + `Problem` (RFC 9457), `Authorizer`, parameter codecs, JSON body conversion + +## Building + +```sh +cmake -S . -B build +cmake --build build +``` + +Warnings are errors by default (GCC/Clang `-Wall -Wextra -Werror`, MSVC +`/W4`); configure with `-DCPP_BOOST_BEAST_SERVER_WERROR=OFF` to relax this. + +## Using + +Each API class declares its per-operation contract types (the Request struct +and the Responder) as nested types. Implement the interface — the nested types +resolve unqualified inside the derived class — and attach it: + + +```cpp +namespace api = org::openapitools::server::api; +namespace model = org::openapitools::server::model; + +class MyPetsApi : public api::PetsApi { + + void createPet(CreatePetRequest request, + std::shared_ptr context, + CreatePetResponder responder) override { + (void)context; // heap-owned; keep the shared_ptr to read it later + + model::Pet value{}; + responder.send201(std::move(value)); + + } + +}; +``` + +Define one implementation per API class the same way. + + +```cpp +int main() { + boost::asio::io_context ioc; + auto router = std::make_shared(); + api::ServerOptions options; + options.authorizer = std::make_shared(); + // HttpServer routes its own lifecycle through enable_shared_from_this, so + // it can only be built through create() (which also rejects a null router). + auto server = api::HttpServer::create(ioc, router, options); + + api::PetsApi::attach(*server, std::make_shared()); + + api::StoreApi::attach(*server, std::make_shared()); + + api::UsersApi::attach(*server, std::make_shared()); + + server->listen(boost::asio::ip::tcp::endpoint{ + boost::asio::ip::make_address("0.0.0.0"), 8080}); + ioc.run(); +} +``` + +`ServerOptions` tuning: + +| Option | Default | Behavior | +| --- | --- | --- | +| `readTimeoutSeconds` | 30 | Read deadline per request AND response-write deadline. Re-armed when each response is written, so handlers may complete asynchronously (e.g. from a worker thread) long after the read deadline expired. | +| `bodyLimitBytes` | 8 MiB | Request bodies larger than this are rejected with 413 and the connection is closed (the unread remainder cannot be skipped safely on a keep-alive stream). | +| `authorizer` | none | Deny-by-default gate for declared security requirements (see below). | + +Requests are fully decoded and schema-validated before the service method +runs; serialization failures, malformed input, unknown routes (404), wrong +methods (405 + `Allow`), unsupported media types (415), oversized bodies +(413), and security denials (401) produce RFC 9457 `application/problem+json` +responses without application code. An absent optional query/header/cookie +parameter reaches the service as its declared OpenAPI `default` (or the type +zero value when none is declared); an optional request body is decoded only +when bytes arrive. + +HTTP/1.1 requests must carry exactly one syntactically valid `Host` field; +HTTP/1.0 keeps `Host` optional. Both origin-form and absolute-form HTTP(S) +targets are accepted (the absolute URI authority is authoritative), then +normalized to origin-form before routing. `HEAD` handlers produce the same +status and headers, including the GET-equivalent `Content-Length`, but never +write response body bytes. + +With `addApiImplStubs=true` a `main.cpp` and stub services (501 responses) +are generated for quick start. + +## Validation semantics + +Parameter and body validation follows these documented rules: + +- **String length** (`minLength` / `maxLength`) counts Unicode code points, + not bytes, matching JSON Schema, on both surfaces. Malformed UTF-8 + degrades to a byte count (a value the schema could not have produced), + which only shifts the length upward and never lets `maxLength` pass. +- **`pattern`** is evaluated UNANCHORED (`std::regex_search`), per JSON + Schema, but its grammar surface differs by place: + - *Parameters* (path/query/header/cookie): `std::regex` in its ECMAScript + grammar over the UTF-8 BYTES, so `\w`, `[^…]`, and `.` treat a + multi-byte character as its constituent bytes. + - *Request/response bodies* (the model validator): the value is decoded to + code points and matched with `std::wregex`, so `.` and character classes + advance one Unicode scalar at a time on platforms with 32-bit `wchar_t` + (macOS/Linux); on Windows (`wchar_t` is 16-bit) the value is encoded as + UTF-16 and `.` matches one UTF-16 code unit, which can be half of a + surrogate pair. + A pattern relying on Unicode word/property semantics (`\p{…}`) is outside + the supported subset on both surfaces and answers 400 rather than matching + approximately; patterns the grammar cannot compile are refused the same + way. Keep ASCII-only patterns unless you intend code-point (`body`) rather + than byte (`parameter`) semantics. +- **Enum reachability**: an enum member is validated only when the + parameter's C++ codec can produce a JSON-equal value for it (a string + member can never match an integer parameter). When no declared member is + reachable, the parameter fails closed with 400 instead of skipping the + check. +- **Absent optional collections** skip `minItems`/`maxItems`/`uniqueItems` + and every item-level check: the constraint applies to the array instance, + which is not on the wire. +- **Numeric grammar** is strict and locale-independent: parameters must be + exact JSON number text (`1.5` yes; `+1.5`, `.5`, `1.`, `1,5`, `0x10`, + leading/trailing space, `inf`, `nan` no). +- **Query form decoding** translates `+` to space. Array style delimiters are + identified on the encoded value before element decoding, so an escaped + comma (`%2C`) remains data inside one form-array element while literal `,` + separates elements. +- **`multipleOf`** uses exact decimal arithmetic over the parameter wire + lexeme for both scalar numbers/integers and array items; it does not apply + a binary floating-point tolerance. +- **Request bodies** are validated against their declared component schema + BEFORE decoding, so an invalid payload answers 400 instead of reaching the + service with silently defaulted fields. The exact-number-preserving parser + is shared with the model path, so numeric lexemes are compared exactly. + Bodies whose schema is inline, a composition union, or nullable are + decode-shape-only (no schema gate) — the generated decode still rejects + structurally wrong payloads. +- **Numeric representability is a whole-payload gate**: a JSON number the + finite-double domain cannot hold (e.g. `1e400`) answers 400 for the whole + body, even if it sits in a member the model ignores. This is deliberate. + Schema validity and model representability are different questions: an + unbounded `number`-typed schema admits `1e400`, but no generated C++ + field can hold it, and the shared exact-JSON parser would otherwise decode + the sanitized placeholder (0) into observable model state. Rejecting the + payload at the boundary is the only way to guarantee no silently corrupted + number reaches the service through either a declared field or preserved + additional properties. +- **Model decoding stays tolerant** (client-compatibility policy): unknown + members are ignored, and the server gate above is what enforces the + schema. Do not rely on the model constructor to reject invalid input. +- **401/404/405 responses never echo** request query strings, and credential + values are not logged. + +## Security + +Declared OpenAPI security requirements are enforced before dispatch: +credentials are extracted per scheme (API keys by location, raw +`Authorization` for HTTP schemes) and handed to your `Authorizer`. Without +an authorizer, secured operations deny by default. Credential values are +never logged. diff --git a/samples/server/petstore/cpp-boost-beast-server/api/PetsApi.cpp b/samples/server/petstore/cpp-boost-beast-server/api/PetsApi.cpp new file mode 100644 index 000000000000..04808d514947 --- /dev/null +++ b/samples/server/petstore/cpp-boost-beast-server/api/PetsApi.cpp @@ -0,0 +1,333 @@ +/** + * Petstore Server + * Sample petstore server for the cpp-boost-beast-server generator + * + * The version of the OpenAPI document: 1.0.0 + * + * NOTE: This class is auto generated by OpenAPI-Generator 7.26.0-SNAPSHOT. + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +/* + * PetsApi.cpp + */ + +#include "PetsApi.h" + +#include "server/BodyJson.h" +#include "server/ParamCodecs.h" +#include "server/Problem.h" +#include "server/Responder.h" +#include "server/Router.h" +// Body pre-validation evaluates the declared schema IR before decoding. +#include "model/Oas31ExactJson.h" +#include "model/Oas31SchemaRegistry.h" +#include "model/Oas31Validator.h" + +#include +#include +#include +#include +#include +#include +#include + +namespace org { +namespace openapitools { +namespace server { +namespace api { + +void PetsApi::attach(HttpServer& server, std::shared_ptr impl) { + auto router = server.routerPtr(); + + // ------------------------------------------------------------------ + // POST /pets (createPet) + // ------------------------------------------------------------------ + { + SecurityGroups security; + + router->add( + "POST", + "/pets", + [impl](std::shared_ptr ctx, + std::shared_ptr responderCore) { + CreatePetRequest request; + Problem problem; + bool invalid = false; + + + // ---- request body (Pet) ---- + { + auto contentTypeEntry = ctx->headers.find("content-type"); + std::string contentType = + contentTypeEntry != ctx->headers.end() + ? contentTypeEntry->second : std::string(); + std::size_t semicolon = contentType.find(';'); + if (semicolon != std::string::npos) { + contentType.resize(semicolon); + } + // Media types are case-insensitive (RFC 9110 8.3.1). + for (char& c : contentType) { + c = static_cast(std::tolower( + static_cast(c))); + } + // RFC 9110 5.6.3 allows whitespace around the type/ + // subtype and between parameters; trim it on both sides + // (' ' and HTAB) before comparing. + while (!contentType.empty() + && (contentType.front() == ' ' || contentType.front() == '\t')) { + contentType.erase(contentType.begin()); + } + while (!contentType.empty() + && (contentType.back() == ' ' || contentType.back() == '\t')) { + contentType.pop_back(); + } + static std::vector const kMediaTypes = { + "application/json" }; + bool supported = !contentType.empty() + ? std::find(kMediaTypes.begin(), kMediaTypes.end(), contentType) != kMediaTypes.end() + : kMediaTypes.size() == 1; + if (!supported) { + responderCore->sendProblem(Problem::unsupportedMediaType(contentType)); + return; + } + try { + // Validate the raw payload against the declared + // component schema BEFORE decoding. The model decode + // is deliberately tolerant (client-compat policy: + // nulls on non-nullable fields are skipped, unknown + // members ignored); the server gate enforces the + // schema so a sloppy payload cannot reach the service + // with silently defaulted fields. Exact-JSON parsing + // preserves numeric lexemes, so multipleOf and + // magnitude checks see the wire text, and the decode + // inside the scope converts numbers exactly too. + org::openapitools::server::model::detail::schema_validation::ExactJsonValue exactJson = + org::openapitools::server::model::detail::schema_validation::parseExactJson(ctx->body); + org::openapitools::server::model::detail::schema_validation::requireModelConvertibleJson(exactJson); + org::openapitools::server::model::detail::schema_validation::ExactInstanceScope exactScope(exactJson); + org::openapitools::server::model::detail::schema_validation::SchemaIndex const schemaIndex = + org::openapitools::server::model::detail::schema_validation::schemaNodeFor("Pet_component"); + if (schemaIndex == org::openapitools::server::model::detail::schema_validation::kNoSchema) { + throw std::invalid_argument( + "request body schema id is not in the generated registry"); + } + { + org::openapitools::server::model::detail::schema_validation::RawInstance instance(&exactJson.value); + org::openapitools::server::model::detail::schema_validation::ValidationPath validationPath; + org::openapitools::server::model::detail::schema_validation::ValidationContext context; + org::openapitools::server::model::detail::schema_validation::ValidationResult const result = + org::openapitools::server::model::detail::schema_validation::sharedSchemaEvaluator().validate( + schemaIndex, instance, validationPath, context); + if (!result.success) { + std::string message = "request body failed schema validation"; + if (!result.failurePath.empty()) { + message += " at '" + result.failurePath + "'"; + } + if (!result.failureMessage.empty()) { + message += ": " + result.failureMessage; + } + throw std::invalid_argument(message); + } + } + fromJsonLeaf(exactJson.value, request.body); + } catch (std::invalid_argument const& error) { + Problem parseProblem = Problem::badRequest(error.what()); + parseProblem.withError("body", error.what()); + responderCore->sendProblem(std::move(parseProblem)); + return; + } catch (std::length_error const& error) { + // A numeric lexeme beyond the implementation limit is + // a payload problem, not a server fault: answer 400. + Problem parseProblem = Problem::badRequest(error.what()); + parseProblem.withError("body", error.what()); + responderCore->sendProblem(std::move(parseProblem)); + return; + } + } + + if (invalid) { + problem.status = 400; + problem.title = "Bad Request"; + responderCore->sendProblem(std::move(problem)); + return; + } + + CreatePetResponder responder(responderCore); + impl->createPet(std::move(request), ctx, std::move(responder)); + }, + std::move(security), + "createPet"); + } + // ------------------------------------------------------------------ + // DELETE /pets/{petId} (deletePetById) + // ------------------------------------------------------------------ + { + SecurityGroups security; + + router->add( + "DELETE", + "/pets/{petId}", + [impl](std::shared_ptr ctx, + std::shared_ptr responderCore) { + DeletePetByIdRequest request; + Problem problem; + bool invalid = false; + + // ---- parameter petId (path, simple) ---- + { + auto rawSegment = ctx->pathParams.find("petId"); + std::string encoded = + rawSegment != ctx->pathParams.end() ? rawSegment->second : std::string(); + bool malformed = false; + std::string text; + text = percentDecode(encoded); + if (malformed) { + problem.withError("petId", "path parameter is not simple-encoded"); + invalid = true; + } else if (text.empty()) { + problem.withError("petId", "path parameter is missing or empty"); + invalid = true; + } else if (!parseScalar(text, request.petId)) { + problem.withError("petId", "path parameter is not a valid std::int64_t"); + invalid = true; + } + + + + + } + + + if (invalid) { + problem.status = 400; + problem.title = "Bad Request"; + responderCore->sendProblem(std::move(problem)); + return; + } + + DeletePetByIdResponder responder(responderCore); + impl->deletePetById(std::move(request), ctx, std::move(responder)); + }, + std::move(security), + "deletePetById"); + } + // ------------------------------------------------------------------ + // GET /pets (listPets) + // ------------------------------------------------------------------ + { + SecurityGroups security; + + router->add( + "GET", + "/pets", + [impl](std::shared_ptr ctx, + std::shared_ptr responderCore) { + ListPetsRequest request; + Problem problem; + bool invalid = false; + + // ---- parameter limit (query, form) ---- + { + auto values = ctx->encodedQuery.equal_range("limit"); + bool present = values.first != values.second; + std::string text; + if (!present) { + // absent optional query parameter keeps its default value + } else { + text = percentDecodeQuery(values.first->second); + if (!parseScalar(text, request.limit)) { + problem.withError("limit", "query parameter is not a valid std::int32_t"); + invalid = true; + } + } + + + + if (!invalid && present && request.limit < (1LL)) { + problem.withError("limit", "value is below the minimum"); + invalid = true; + } + + if (!invalid && present && request.limit > (100LL)) { + problem.withError("limit", "value is above the maximum"); + invalid = true; + } + + + } + + + if (invalid) { + problem.status = 400; + problem.title = "Bad Request"; + responderCore->sendProblem(std::move(problem)); + return; + } + + ListPetsResponder responder(responderCore); + impl->listPets(std::move(request), ctx, std::move(responder)); + }, + std::move(security), + "listPets"); + } + // ------------------------------------------------------------------ + // GET /pets/{petId} (showPetById) + // ------------------------------------------------------------------ + { + SecurityGroups security; + + router->add( + "GET", + "/pets/{petId}", + [impl](std::shared_ptr ctx, + std::shared_ptr responderCore) { + ShowPetByIdRequest request; + Problem problem; + bool invalid = false; + + // ---- parameter petId (path, simple) ---- + { + auto rawSegment = ctx->pathParams.find("petId"); + std::string encoded = + rawSegment != ctx->pathParams.end() ? rawSegment->second : std::string(); + bool malformed = false; + std::string text; + text = percentDecode(encoded); + if (malformed) { + problem.withError("petId", "path parameter is not simple-encoded"); + invalid = true; + } else if (text.empty()) { + problem.withError("petId", "path parameter is missing or empty"); + invalid = true; + } else if (!parseScalar(text, request.petId)) { + problem.withError("petId", "path parameter is not a valid std::int64_t"); + invalid = true; + } + + + + + } + + + if (invalid) { + problem.status = 400; + problem.title = "Bad Request"; + responderCore->sendProblem(std::move(problem)); + return; + } + + ShowPetByIdResponder responder(responderCore); + impl->showPetById(std::move(request), ctx, std::move(responder)); + }, + std::move(security), + "showPetById"); + } +} + +} +} +} +} diff --git a/samples/server/petstore/cpp-boost-beast-server/api/PetsApi.h b/samples/server/petstore/cpp-boost-beast-server/api/PetsApi.h new file mode 100644 index 000000000000..a1fd50c773a5 --- /dev/null +++ b/samples/server/petstore/cpp-boost-beast-server/api/PetsApi.h @@ -0,0 +1,253 @@ +/** + * Petstore Server + * Sample petstore server for the cpp-boost-beast-server generator + * + * The version of the OpenAPI document: 1.0.0 + * + * NOTE: This class is auto generated by OpenAPI-Generator 7.26.0-SNAPSHOT. + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +/* + * PetsApi.h + * + * + */ + +#ifndef ORG_OPENAPITOOLS_SERVER_API_PetsApi_H_ +#define ORG_OPENAPITOOLS_SERVER_API_PetsApi_H_ + +#include +#include +#include +#include +#include + +#include "server/HttpServer.h" +#include "server/Problem.h" +#include "server/Responder.h" +#include "server/Router.h" + +#include "Error.h" +#include "Pet.h" +#include +namespace org { +namespace openapitools { +namespace server { +namespace api { + + +using namespace org::openapitools::server::model; + +/** + * Service interface for . Implementations receive fully + * decoded, validated requests and own their response completion. The + * request context is heap-owned: implementations may keep the shared_ptr + * and read the request data after this call returns. + * + * The per-operation contract types are nested inside this class so an + * operation tagged under several groups produces one definition per API + * class instead of duplicate namespace-scope types. + */ +class PetsApi { +public: + // ------------------------------------------------------------------ + + /// Fully decoded request data for createPet. + struct CreatePetRequest { + // Fully-qualified field type: std::optional wrapper preserved, model + // tokens shadowed by this class's nested contract types or ambiguous + // with runtime types already model-namespace qualified by the assembler. + Pet body{}; + }; + + /// Single-shot responder for createPet. Movable, thread-safe value + /// type; the second and later completions are ignored. + class CreatePetResponder { + public: + explicit CreatePetResponder( + std::shared_ptr core) + : core_(std::move(core)) {} + + void send201(Pet value) const { + core_->sendJson(201, value, "application/json"); + } + + void sendProblem(Problem problem) const { + core_->sendProblem(std::move(problem)); + } + + void sendNotImplemented(std::string const& operationId) const { + core_->sendNotImplemented(operationId); + } + + private: + std::shared_ptr core_; + }; + + // ------------------------------------------------------------------ + + /// Fully decoded request data for deletePetById. + struct DeletePetByIdRequest { + std::int64_t petId = 0L; + }; + + /// Single-shot responder for deletePetById. Movable, thread-safe value + /// type; the second and later completions are ignored. + class DeletePetByIdResponder { + public: + explicit DeletePetByIdResponder( + std::shared_ptr core) + : core_(std::move(core)) {} + + void send204() const { + core_->sendEmpty(204); + } + + void sendProblem(Problem problem) const { + core_->sendProblem(std::move(problem)); + } + + void sendNotImplemented(std::string const& operationId) const { + core_->sendNotImplemented(operationId); + } + + private: + std::shared_ptr core_; + }; + + // ------------------------------------------------------------------ + + /// Fully decoded request data for listPets. + struct ListPetsRequest { + std::int32_t limit = 0; + }; + + /// Single-shot responder for listPets. Movable, thread-safe value + /// type; the second and later completions are ignored. + class ListPetsResponder { + public: + explicit ListPetsResponder( + std::shared_ptr core) + : core_(std::move(core)) {} + + void send200(std::vector> value) const { + core_->sendJson(200, value, "application/json"); + } + + void sendProblem(Problem problem) const { + core_->sendProblem(std::move(problem)); + } + + void sendNotImplemented(std::string const& operationId) const { + core_->sendNotImplemented(operationId); + } + + private: + std::shared_ptr core_; + }; + + // ------------------------------------------------------------------ + + /// Fully decoded request data for showPetById. + struct ShowPetByIdRequest { + std::int64_t petId = 0L; + }; + + /// Single-shot responder for showPetById. Movable, thread-safe value + /// type; the second and later completions are ignored. + class ShowPetByIdResponder { + public: + explicit ShowPetByIdResponder( + std::shared_ptr core) + : core_(std::move(core)) {} + + void send200(Pet value) const { + core_->sendJson(200, value, "application/json"); + } + void send404(Error value) const { + core_->sendJson(404, value, "application/json"); + } + + void sendProblem(Problem problem) const { + core_->sendProblem(std::move(problem)); + } + + void sendNotImplemented(std::string const& operationId) const { + core_->sendNotImplemented(operationId); + } + + private: + std::shared_ptr core_; + }; + + virtual ~PetsApi() = default; + + virtual void createPet( + CreatePetRequest request, + std::shared_ptr context, + CreatePetResponder responder) = 0; + virtual void deletePetById( + DeletePetByIdRequest request, + std::shared_ptr context, + DeletePetByIdResponder responder) = 0; + virtual void listPets( + ListPetsRequest request, + std::shared_ptr context, + ListPetsResponder responder) = 0; + virtual void showPetById( + ShowPetByIdRequest request, + std::shared_ptr context, + ShowPetByIdResponder responder) = 0; + + /// Registers every PetsApi route on the server. + static void attach(HttpServer& server, std::shared_ptr impl); +}; + +/** + * Quick-start stub service: every operation answers 501 problem+json. + */ +class PetsApiStub : public PetsApi { +public: + void createPet( + CreatePetRequest request, + std::shared_ptr context, + CreatePetResponder responder) override { + (void)request; + (void)context; + responder.sendNotImplemented("createPet"); + } + void deletePetById( + DeletePetByIdRequest request, + std::shared_ptr context, + DeletePetByIdResponder responder) override { + (void)request; + (void)context; + responder.sendNotImplemented("deletePetById"); + } + void listPets( + ListPetsRequest request, + std::shared_ptr context, + ListPetsResponder responder) override { + (void)request; + (void)context; + responder.sendNotImplemented("listPets"); + } + void showPetById( + ShowPetByIdRequest request, + std::shared_ptr context, + ShowPetByIdResponder responder) override { + (void)request; + (void)context; + responder.sendNotImplemented("showPetById"); + } +}; + + +} +} +} +} + +#endif // ORG_OPENAPITOOLS_SERVER_API_PetsApi_H_ diff --git a/samples/server/petstore/cpp-boost-beast-server/api/StoreApi.cpp b/samples/server/petstore/cpp-boost-beast-server/api/StoreApi.cpp new file mode 100644 index 000000000000..a848fe26acbf --- /dev/null +++ b/samples/server/petstore/cpp-boost-beast-server/api/StoreApi.cpp @@ -0,0 +1,79 @@ +/** + * Petstore Server + * Sample petstore server for the cpp-boost-beast-server generator + * + * The version of the OpenAPI document: 1.0.0 + * + * NOTE: This class is auto generated by OpenAPI-Generator 7.26.0-SNAPSHOT. + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +/* + * StoreApi.cpp + */ + +#include "StoreApi.h" + +#include "server/BodyJson.h" +#include "server/ParamCodecs.h" +#include "server/Problem.h" +#include "server/Responder.h" +#include "server/Router.h" +// Body pre-validation evaluates the declared schema IR before decoding. +#include "model/Oas31ExactJson.h" +#include "model/Oas31SchemaRegistry.h" +#include "model/Oas31Validator.h" + +#include +#include +#include +#include +#include +#include +#include + +namespace org { +namespace openapitools { +namespace server { +namespace api { + +void StoreApi::attach(HttpServer& server, std::shared_ptr impl) { + auto router = server.routerPtr(); + + // ------------------------------------------------------------------ + // GET /store/inventory (getInventory) + // ------------------------------------------------------------------ + { + SecurityGroups security; + + router->add( + "GET", + "/store/inventory", + [impl](std::shared_ptr ctx, + std::shared_ptr responderCore) { + GetInventoryRequest request; + Problem problem; + bool invalid = false; + + + + if (invalid) { + problem.status = 400; + problem.title = "Bad Request"; + responderCore->sendProblem(std::move(problem)); + return; + } + + GetInventoryResponder responder(responderCore); + impl->getInventory(std::move(request), ctx, std::move(responder)); + }, + std::move(security), + "getInventory"); + } +} + +} +} +} +} diff --git a/samples/server/petstore/cpp-boost-beast-server/api/StoreApi.h b/samples/server/petstore/cpp-boost-beast-server/api/StoreApi.h new file mode 100644 index 000000000000..27c6c283eee5 --- /dev/null +++ b/samples/server/petstore/cpp-boost-beast-server/api/StoreApi.h @@ -0,0 +1,114 @@ +/** + * Petstore Server + * Sample petstore server for the cpp-boost-beast-server generator + * + * The version of the OpenAPI document: 1.0.0 + * + * NOTE: This class is auto generated by OpenAPI-Generator 7.26.0-SNAPSHOT. + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +/* + * StoreApi.h + * + * + */ + +#ifndef ORG_OPENAPITOOLS_SERVER_API_StoreApi_H_ +#define ORG_OPENAPITOOLS_SERVER_API_StoreApi_H_ + +#include +#include +#include +#include +#include + +#include "server/HttpServer.h" +#include "server/Problem.h" +#include "server/Responder.h" +#include "server/Router.h" + +#include +namespace org { +namespace openapitools { +namespace server { +namespace api { + + + +/** + * Service interface for . Implementations receive fully + * decoded, validated requests and own their response completion. The + * request context is heap-owned: implementations may keep the shared_ptr + * and read the request data after this call returns. + * + * The per-operation contract types are nested inside this class so an + * operation tagged under several groups produces one definition per API + * class instead of duplicate namespace-scope types. + */ +class StoreApi { +public: + // ------------------------------------------------------------------ + + /// Fully decoded request data for getInventory. + struct GetInventoryRequest { + }; + + /// Single-shot responder for getInventory. Movable, thread-safe value + /// type; the second and later completions are ignored. + class GetInventoryResponder { + public: + explicit GetInventoryResponder( + std::shared_ptr core) + : core_(std::move(core)) {} + + void send200(std::map value) const { + core_->sendJson(200, value, "application/json"); + } + + void sendProblem(Problem problem) const { + core_->sendProblem(std::move(problem)); + } + + void sendNotImplemented(std::string const& operationId) const { + core_->sendNotImplemented(operationId); + } + + private: + std::shared_ptr core_; + }; + + virtual ~StoreApi() = default; + + virtual void getInventory( + GetInventoryRequest request, + std::shared_ptr context, + GetInventoryResponder responder) = 0; + + /// Registers every StoreApi route on the server. + static void attach(HttpServer& server, std::shared_ptr impl); +}; + +/** + * Quick-start stub service: every operation answers 501 problem+json. + */ +class StoreApiStub : public StoreApi { +public: + void getInventory( + GetInventoryRequest request, + std::shared_ptr context, + GetInventoryResponder responder) override { + (void)request; + (void)context; + responder.sendNotImplemented("getInventory"); + } +}; + + +} +} +} +} + +#endif // ORG_OPENAPITOOLS_SERVER_API_StoreApi_H_ diff --git a/samples/server/petstore/cpp-boost-beast-server/api/UsersApi.cpp b/samples/server/petstore/cpp-boost-beast-server/api/UsersApi.cpp new file mode 100644 index 000000000000..c5a309ee9dd9 --- /dev/null +++ b/samples/server/petstore/cpp-boost-beast-server/api/UsersApi.cpp @@ -0,0 +1,102 @@ +/** + * Petstore Server + * Sample petstore server for the cpp-boost-beast-server generator + * + * The version of the OpenAPI document: 1.0.0 + * + * NOTE: This class is auto generated by OpenAPI-Generator 7.26.0-SNAPSHOT. + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +/* + * UsersApi.cpp + */ + +#include "UsersApi.h" + +#include "server/BodyJson.h" +#include "server/ParamCodecs.h" +#include "server/Problem.h" +#include "server/Responder.h" +#include "server/Router.h" +// Body pre-validation evaluates the declared schema IR before decoding. +#include "model/Oas31ExactJson.h" +#include "model/Oas31SchemaRegistry.h" +#include "model/Oas31Validator.h" + +#include +#include +#include +#include +#include +#include +#include + +namespace org { +namespace openapitools { +namespace server { +namespace api { + +void UsersApi::attach(HttpServer& server, std::shared_ptr impl) { + auto router = server.routerPtr(); + + // ------------------------------------------------------------------ + // GET /users/{username} (getUserByName) + // ------------------------------------------------------------------ + { + SecurityGroups security; + + router->add( + "GET", + "/users/{username}", + [impl](std::shared_ptr ctx, + std::shared_ptr responderCore) { + GetUserByNameRequest request; + Problem problem; + bool invalid = false; + + // ---- parameter username (path, simple) ---- + { + auto rawSegment = ctx->pathParams.find("username"); + std::string encoded = + rawSegment != ctx->pathParams.end() ? rawSegment->second : std::string(); + bool malformed = false; + std::string text; + text = percentDecode(encoded); + if (malformed) { + problem.withError("username", "path parameter is not simple-encoded"); + invalid = true; + } else if (text.empty()) { + problem.withError("username", "path parameter is missing or empty"); + invalid = true; + } else if (!parseScalar(text, request.username)) { + problem.withError("username", "path parameter is not a valid std::string"); + invalid = true; + } + + + + + } + + + if (invalid) { + problem.status = 400; + problem.title = "Bad Request"; + responderCore->sendProblem(std::move(problem)); + return; + } + + GetUserByNameResponder responder(responderCore); + impl->getUserByName(std::move(request), ctx, std::move(responder)); + }, + std::move(security), + "getUserByName"); + } +} + +} +} +} +} diff --git a/samples/server/petstore/cpp-boost-beast-server/api/UsersApi.h b/samples/server/petstore/cpp-boost-beast-server/api/UsersApi.h new file mode 100644 index 000000000000..2478353180a2 --- /dev/null +++ b/samples/server/petstore/cpp-boost-beast-server/api/UsersApi.h @@ -0,0 +1,121 @@ +/** + * Petstore Server + * Sample petstore server for the cpp-boost-beast-server generator + * + * The version of the OpenAPI document: 1.0.0 + * + * NOTE: This class is auto generated by OpenAPI-Generator 7.26.0-SNAPSHOT. + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +/* + * UsersApi.h + * + * + */ + +#ifndef ORG_OPENAPITOOLS_SERVER_API_UsersApi_H_ +#define ORG_OPENAPITOOLS_SERVER_API_UsersApi_H_ + +#include +#include +#include +#include +#include + +#include "server/HttpServer.h" +#include "server/Problem.h" +#include "server/Responder.h" +#include "server/Router.h" + +#include "Error.h" +#include "User.h" +#include +namespace org { +namespace openapitools { +namespace server { +namespace api { + + +using namespace org::openapitools::server::model; + +/** + * Service interface for . Implementations receive fully + * decoded, validated requests and own their response completion. The + * request context is heap-owned: implementations may keep the shared_ptr + * and read the request data after this call returns. + * + * The per-operation contract types are nested inside this class so an + * operation tagged under several groups produces one definition per API + * class instead of duplicate namespace-scope types. + */ +class UsersApi { +public: + // ------------------------------------------------------------------ + + /// Fully decoded request data for getUserByName. + struct GetUserByNameRequest { + std::string username = ""; + }; + + /// Single-shot responder for getUserByName. Movable, thread-safe value + /// type; the second and later completions are ignored. + class GetUserByNameResponder { + public: + explicit GetUserByNameResponder( + std::shared_ptr core) + : core_(std::move(core)) {} + + void send200(User value) const { + core_->sendJson(200, value, "application/json"); + } + void send404(Error value) const { + core_->sendJson(404, value, "application/json"); + } + + void sendProblem(Problem problem) const { + core_->sendProblem(std::move(problem)); + } + + void sendNotImplemented(std::string const& operationId) const { + core_->sendNotImplemented(operationId); + } + + private: + std::shared_ptr core_; + }; + + virtual ~UsersApi() = default; + + virtual void getUserByName( + GetUserByNameRequest request, + std::shared_ptr context, + GetUserByNameResponder responder) = 0; + + /// Registers every UsersApi route on the server. + static void attach(HttpServer& server, std::shared_ptr impl); +}; + +/** + * Quick-start stub service: every operation answers 501 problem+json. + */ +class UsersApiStub : public UsersApi { +public: + void getUserByName( + GetUserByNameRequest request, + std::shared_ptr context, + GetUserByNameResponder responder) override { + (void)request; + (void)context; + responder.sendNotImplemented("getUserByName"); + } +}; + + +} +} +} +} + +#endif // ORG_OPENAPITOOLS_SERVER_API_UsersApi_H_ diff --git a/samples/server/petstore/cpp-boost-beast-server/main.cpp b/samples/server/petstore/cpp-boost-beast-server/main.cpp new file mode 100644 index 000000000000..ed0dd8a96328 --- /dev/null +++ b/samples/server/petstore/cpp-boost-beast-server/main.cpp @@ -0,0 +1,94 @@ +/** + * Petstore Server + * Sample petstore server for the cpp-boost-beast-server generator + * + * The version of the OpenAPI document: 1.0.0 + * + * NOTE: This class is auto generated by OpenAPI-Generator 7.26.0-SNAPSHOT. + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +// ============================================================================ +// main.cpp - quick-start server entry point (generated with +// addApiImplStubs=true). Every operation answers 501 problem+json until you +// provide a real service implementation. +// ============================================================================ +#include + +#include +#include +#include +#include + +#include "server/HttpServer.h" +#include "server/Router.h" + +#include "api/PetsApi.h" +#include "api/StoreApi.h" +#include "api/UsersApi.h" +using namespace org; +using namespace openapitools; +using namespace server; +using namespace api; + +static void attachPetsApi(HttpServer& server) { + PetsApi::attach(server, std::make_shared()); +} +static void attachStoreApi(HttpServer& server) { + StoreApi::attach(server, std::make_shared()); +} +static void attachUsersApi(HttpServer& server) { + UsersApi::attach(server, std::make_shared()); +} + +int main() { + unsigned port = 8080; +#if defined(_MSC_VER) + char* rawPortText = nullptr; + std::size_t rawPortSize = 0; + if (_dupenv_s(&rawPortText, &rawPortSize, "PORT") != 0) { + std::cerr << "fatal: could not read PORT from the environment\n"; + return EXIT_FAILURE; + } + std::unique_ptr portTextOwner( + rawPortText, &std::free); + char const* portText = portTextOwner.get(); +#else + char const* portText = std::getenv("PORT"); +#endif + if (portText != nullptr) { + // Validate the whole value: a partial parse (e.g. "80x") or an + // out-of-range number must fail loudly rather than silently bind a + // different port than the operator intended. Port 0 would bind an + // ephemeral port whose number the log line below cannot know. + char* end = nullptr; + errno = 0; + unsigned long parsed = std::strtoul(portText, &end, 10); + if (errno != 0 || end == portText || *end != '\0' + || parsed < 1 || parsed > 65535) { + std::cerr << "fatal: invalid PORT value '" << portText + << "' (expected an integer between 1 and 65535)\n"; + return EXIT_FAILURE; + } + port = static_cast(parsed); + } + + try { + boost::asio::io_context ioc; + auto router = std::make_shared(); + auto server = HttpServer::create(ioc, router); + attachPetsApi(*server); + attachStoreApi(*server); + attachUsersApi(*server); + server->listen(boost::asio::ip::tcp::endpoint{ + boost::asio::ip::make_address("0.0.0.0"), + static_cast(port)}); + std::cout << "CppBoostBeastPetstoreServer listening on 0.0.0.0:" << port << "\n"; + ioc.run(); + } catch (std::exception const& error) { + std::cerr << "fatal: " << error.what() << "\n"; + return EXIT_FAILURE; + } + return EXIT_SUCCESS; +} diff --git a/samples/server/petstore/cpp-boost-beast-server/model/AnyType.h b/samples/server/petstore/cpp-boost-beast-server/model/AnyType.h new file mode 100644 index 000000000000..86f5ca6f9f57 --- /dev/null +++ b/samples/server/petstore/cpp-boost-beast-server/model/AnyType.h @@ -0,0 +1,38 @@ +/** + * Petstore Server + * Sample petstore server for the cpp-boost-beast-server generator + * + * The version of the OpenAPI document: 1.0.0 + * + * NOTE: This class is auto generated by OpenAPI-Generator 7.26.0-SNAPSHOT. + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +/* + * AnyType.h + * + * Represents any JSON type using boost::json::value + */ + +#ifndef ORG_OPENAPITOOLS_SERVER_MODEL_ANYTYPE_H_ +#define ORG_OPENAPITOOLS_SERVER_MODEL_ANYTYPE_H_ + +#include + +namespace org { +namespace openapitools { +namespace server { +namespace model { + +/** + * AnyType is an alias for boost::json::value to represent any JSON value. + */ +using AnyType = boost::json::value; + +} +} +} +} + +#endif /* ORG_OPENAPITOOLS_SERVER_MODEL_ANYTYPE_H_ */ diff --git a/samples/server/petstore/cpp-boost-beast-server/model/Error.cpp b/samples/server/petstore/cpp-boost-beast-server/model/Error.cpp new file mode 100644 index 000000000000..a1abc517fab1 --- /dev/null +++ b/samples/server/petstore/cpp-boost-beast-server/model/Error.cpp @@ -0,0 +1,790 @@ +/** + * Petstore Server + * Sample petstore server for the cpp-boost-beast-server generator + * + * The version of the OpenAPI document: 1.0.0 + * + * NOTE: This class is auto generated by OpenAPI-Generator 7.26.0-SNAPSHOT. + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +// ============================================================================ +// Validation scope (client-side vs full JSON Schema validation) +// ============================================================================ +// The generated client performs structural and composition validation to +// ensure wire-format correctness, but is NOT a full JSON Schema meta-schema +// validator: +// +// ✓ oneOf exactly-one match enforcement (shared schema evaluator per branch) +// ✓ anyOf at-least-one match enforcement (shared schema evaluator per branch) +// ✓ discriminator value enforcement (unknown → fall through to structural) +// ✓ type validation, incl. type arrays with a literal "null" member +// ✓ mathematical integer semantics (1 and 1.0 both accepted as integer) +// ✓ enum / const membership, incl. deep (array/object) JSON members +// ✓ boolean value-schemas (true always matches, false never matches) +// ✓ required property presence in object JSON +// ✓ numeric range validation (minimum, maximum, exclusiveMin, exclusiveMax) +// ✓ numeric multipleOf validation (exact decimal lexemes) +// ✓ string length validation (minLength, maxLength) +// ✓ string pattern validation (ECMA-262 regex subset; fail-closed outside) +// ✓ patternProperties and propertyNames +// ✓ additionalProperties (false → reject; typed schemas densified) +// ✓ array length (minItems / maxItems), items and prefixItems validation +// ✓ array uniqueItems validation +// ✓ minProperties / maxProperties (exact count bounds) +// ✓ dependentRequired, contains (min/maxContains as exact count bounds) +// ✓ `not` subschemas via the shared IR evaluator +// ✓ if/then/else subschemas densified into the IR (bare unreferenced +// conditionals are annotated, not asserted) +// ✓ nested error-path diagnostics (e.g. ".field[0].nested") +// +// Annotation-only per JSON Schema 2020-12 §8.2.6 (no output assertions): +// format, contentEncoding, contentMediaType, contentSchema, $comment, and +// other annotation-vocabulary keywords. Unknown keywords are preserved as +// annotations and never affect accept/reject verdicts. +// +// For full JSON Schema validation, use a dedicated validator library +// (e.g. valijson, nlohmann/json-schema-validator) on the deserialized +// value before application use. The client's checks guarantee correct +// parse/serialization of valid instances matching the generated types. +// ============================================================================ + +#include "Error.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "ValidationTypes.h" +#include "Oas31ExactJson.h" +#include "Oas31Validator.h" +#include "Oas31SchemaRegistry.h" + +namespace org { +namespace openapitools { +namespace server { +namespace model { + +namespace { + +// Trait to detect types with toJsonValue() const member. +template +struct HasModelToJsonValue : std::false_type {}; + +template +struct HasModelToJsonValue().toJsonValue())>> : std::true_type {}; + +// Trait: detects whether a type has fromJsonValue member +template +struct HasFromJsonValueMethod : std::false_type {}; + +template +struct HasFromJsonValueMethod().fromJsonValue(std::declval()))>> + : std::true_type {}; + +// Trait: detects specialization of a template +template class Template> +struct IsSpecialization : std::false_type {}; + +template