From 2820b40d791c0b624ac8e099a34f81f23de3b2f6 Mon Sep 17 00:00:00 2001 From: Jachym Metlicka Date: Wed, 26 Aug 2026 09:58:54 +0200 Subject: [PATCH 01/20] feat: add support for x-request-body-extra-annotation to merge operation-level annotations into body parameters --- docs/generators/kotlin-spring.md | 1 + docs/generators/spring.md | 1 + .../openapitools/codegen/DefaultCodegen.java | 37 +++++++++++ .../openapitools/codegen/VendorExtension.java | 1 + .../languages/KotlinSpringServerCodegen.java | 39 +++++++++++ .../codegen/languages/SpringCodegen.java | 6 ++ .../resources/JavaSpring/bodyParams.mustache | 2 +- .../kotlin-spring/bodyParams.mustache | 2 +- .../kotlin-spring/formParams.mustache | 2 +- .../kotlin-spring/pathParams.mustache | 2 +- .../kotlin-spring/queryParams.mustache | 2 +- .../java/spring/SpringCodegenTest.java | 45 +++++++++++++ .../spring/KotlinSpringServerCodegenTest.java | 37 +++++++++++ .../kotlin-spring-param-extra-annotation.yaml | 65 +++++++++++++++++++ .../3_0/request-body-extra-annotation.yaml | 58 +++++++++++++++++ 15 files changed, 295 insertions(+), 5 deletions(-) create mode 100644 modules/openapi-generator/src/test/resources/3_0/kotlin/kotlin-spring-param-extra-annotation.yaml create mode 100644 modules/openapi-generator/src/test/resources/3_0/request-body-extra-annotation.yaml diff --git a/docs/generators/kotlin-spring.md b/docs/generators/kotlin-spring.md index e4a0074ee562..bee6a3964858 100644 --- a/docs/generators/kotlin-spring.md +++ b/docs/generators/kotlin-spring.md @@ -91,6 +91,7 @@ These options may be applied as additional-properties (cli) or configOptions (pl |x-discriminator-value|Used with model inheritance to specify value for discriminator that identifies current model|MODEL| |x-field-extra-annotation|Custom annotation(s) to be added to property; accepts a string or list of strings|FIELD, OPERATION_PARAMETER|null |x-operation-extra-annotation|Custom annotation(s) to be added to operation; accepts a string or list of strings|OPERATION|null +|x-request-body-extra-annotation|Custom annotation(s) to be added to the request body parameter; accepts a string or list of strings. Declared on the operation because the request body typically `$ref`s a shared model (so the annotation cannot be placed next to the `$ref`); the value is rendered by being merged into the body parameter's `x-field-extra-annotation`|OPERATION|null |x-extra-imports|Custom import(s) to add to the generated file that declares the annotated model, property, operation, or parameter (e.g. so custom annotations can be referenced by their short name); accepts a string or list of strings. Values are emitted verbatim (Kotlin alias imports supported) and only exact duplicates are removed|MODEL, FIELD, OPERATION, OPERATION_PARAMETER|null |x-pattern-message|Add this property whenever you need to customize the invalidation error message for the regex pattern of a variable|FIELD, OPERATION_PARAMETER|null |x-size-message|Add this property whenever you need to customize the invalidation error message for the size or length of a variable|FIELD, OPERATION_PARAMETER|null diff --git a/docs/generators/spring.md b/docs/generators/spring.md index adf5726a9d8a..92e7da29bd20 100644 --- a/docs/generators/spring.md +++ b/docs/generators/spring.md @@ -145,6 +145,7 @@ These options may be applied as additional-properties (cli) or configOptions (pl |x-class-extra-annotation|Custom annotation(s) to be added to model; accepts a string or list of strings|MODEL|null |x-field-extra-annotation|Custom annotation(s) to be added to property; accepts a string or list of strings|FIELD, OPERATION_PARAMETER|null |x-operation-extra-annotation|Custom annotation(s) to be added to operation; accepts a string or list of strings|OPERATION|null +|x-request-body-extra-annotation|Custom annotation(s) to be added to the request body parameter; accepts a string or list of strings. Declared on the operation because the request body typically `$ref`s a shared model (so the annotation cannot be placed next to the `$ref`); the value is rendered by being merged into the body parameter's `x-field-extra-annotation`|OPERATION|null |x-spring-paginated|Add `org.springframework.data.domain.Pageable` to controller method. Can be used to handle `page`, `size` and `sort` query parameters. If these query parameters are also specified in the operation spec, they will be removed from the controller method as their values can be obtained from the `Pageable` object. Applies when `library=spring-boot` or `library=spring-cloud`; ignored for other (client) libraries.|OPERATION|false |x-version-param|Marker property that tells that this parameter would be used for endpoint versioning. Applicable for headers & query params. true/false|OPERATION_PARAMETER|null |x-pattern-message|Add this property whenever you need to customize the invalidation error message for the regex pattern of a variable|FIELD, OPERATION_PARAMETER|null 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 085f1d981597..78f192ee0043 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 @@ -7399,6 +7399,43 @@ public static void normalizeVendorExtensionWithStringList(Map ve vendorExtensions.put(name, new ArrayList<>(getObjectAsStringList(vendorExtensions.get(name)))); } + /** + * Merges the values of an operation-level vendor extension into a list-valued vendor extension of + * each request body parameter. This lets an operation-level annotation extension (for example + * {@code x-request-body-extra-annotation}) be rendered through the same template path as the + * per-parameter {@code x-field-extra-annotation}. It is required because a request body typically + * {@code $ref}s a shared model, so the annotation cannot be placed next to the {@code $ref} and + * must instead be authored on the operation. Any annotations already present on the body parameter + * are preserved (they appear first), then the operation-level values are appended. This is a no-op + * when the source extension is absent or empty, leaving existing body-parameter extensions untouched. + * + * @param operation operation whose {@code bodyParams} should receive the merged values + * @param sourceName operation-level vendor extension name to read the values from + * @param targetName body-parameter vendor extension name to merge the values into + */ + public static void mergeOperationVendorExtensionIntoBodyParams(CodegenOperation operation, String sourceName, String targetName) { + List sourceValues = getObjectAsStringList(operation.vendorExtensions.get(sourceName)); + if (sourceValues.isEmpty()) { + return; + } + // The body parameter can be represented by distinct CodegenParameter instances across the + // operation's collections (e.g. allParams vs bodyParams), and templates may iterate either; + // de-duplicate by identity and update every body-parameter instance so the merge is visible + // regardless of which collection the template renders. + Set bodyParameters = Collections.newSetFromMap(new IdentityHashMap<>()); + bodyParameters.addAll(operation.bodyParams); + for (CodegenParameter param : operation.allParams) { + if (param.isBodyParam) { + bodyParameters.add(param); + } + } + for (CodegenParameter bodyParam : bodyParameters) { + List merged = new ArrayList<>(getObjectAsStringList(bodyParam.vendorExtensions.get(targetName))); + merged.addAll(sourceValues); + bodyParam.vendorExtensions.put(targetName, merged); + } + } + public Map getPropertyAsStringMap(String propertyKey) { final Object value = additionalProperties.get(propertyKey); return getObjectAsStringMap(value); diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/VendorExtension.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/VendorExtension.java index ac8bfbe87b3b..afde725c185f 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/VendorExtension.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/VendorExtension.java @@ -26,6 +26,7 @@ public enum VendorExtension { X_CLASS_EXTRA_ANNOTATION("x-class-extra-annotation", ExtensionLevel.MODEL, "Custom annotation(s) to be added to model; accepts a string or list of strings", null), X_FIELD_EXTRA_ANNOTATION("x-field-extra-annotation", Arrays.asList(ExtensionLevel.FIELD, ExtensionLevel.OPERATION_PARAMETER), "Custom annotation(s) to be added to property; accepts a string or list of strings", null), X_OPERATION_EXTRA_ANNOTATION("x-operation-extra-annotation", ExtensionLevel.OPERATION, "Custom annotation(s) to be added to operation; accepts a string or list of strings", null), + X_REQUEST_BODY_EXTRA_ANNOTATION("x-request-body-extra-annotation", ExtensionLevel.OPERATION, "Custom annotation(s) to be added to the request body parameter; accepts a string or list of strings. Declared on the operation because the request body typically `$ref`s a shared model (so the annotation cannot be placed next to the `$ref`); the value is rendered by being merged into the body parameter's `x-field-extra-annotation`", null), X_EXTRA_IMPORTS("x-extra-imports", Arrays.asList(ExtensionLevel.MODEL, ExtensionLevel.FIELD, ExtensionLevel.OPERATION, ExtensionLevel.OPERATION_PARAMETER), "Custom import(s) to add to the generated file that declares the annotated model, property, operation, or parameter (e.g. so custom annotations can be referenced by their short name); accepts a string or list of strings. Values are emitted verbatim (Kotlin alias imports supported) and only exact duplicates are removed", null), X_VERSION_PARAM("x-version-param", ExtensionLevel.OPERATION_PARAMETER, "Marker property that tells that this parameter would be used for endpoint versioning. Applicable for headers & query params. true/false", null), X_PATTERN_MESSAGE("x-pattern-message", Arrays.asList(ExtensionLevel.FIELD, ExtensionLevel.OPERATION_PARAMETER), "Add this property whenever you need to customize the invalidation error message for the regex pattern of a variable", null), diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/KotlinSpringServerCodegen.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/KotlinSpringServerCodegen.java index 12047a88e12f..0b7e81b02f5f 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/KotlinSpringServerCodegen.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/KotlinSpringServerCodegen.java @@ -1700,12 +1700,50 @@ public ModelsMap postProcessModelsEnum(ModelsMap objs) { return objs; } + /** + * Normalizes a vendor extension across all of an operation's parameter collections into a mutable + * {@code List}. Mirrors {@code AbstractJavaCodegen.normalizeOperationParameterVendorExtensions} + * (which this Kotlin generator does not inherit) so parameter annotation extensions such as + * {@code x-field-extra-annotation} have a single, predictable shape for templates and downstream code. + * The same parameter can appear in several collections, so they are de-duplicated by identity. + * + * @param operation operation whose parameters should be updated + * @param name vendor extension name + */ + private void normalizeParameterVendorExtensionWithStringList(CodegenOperation operation, String name) { + Set parameters = Collections.newSetFromMap(new IdentityHashMap<>()); + parameters.addAll(operation.allParams); + parameters.addAll(operation.bodyParams); + parameters.addAll(operation.pathParams); + parameters.addAll(operation.queryParams); + parameters.addAll(operation.headerParams); + parameters.addAll(operation.implicitHeadersParams); + parameters.addAll(operation.constantParams); + parameters.addAll(operation.formParams); + parameters.addAll(operation.cookieParams); + parameters.addAll(operation.requiredParams); + parameters.addAll(operation.optionalParams); + parameters.addAll(operation.requiredAndNotNullableParams); + parameters.addAll(operation.notNullableParams); + for (CodegenParameter parameter : parameters) { + normalizeVendorExtensionWithStringList(parameter.vendorExtensions, name); + } + } + @Override public OperationsMap postProcessOperationsWithModels(OperationsMap objs, List allModels) { OperationMap operations = objs.getOperations(); if (operations != null) { List ops = operations.getOperation(); ops.forEach(operation -> { + // Normalize x-field-extra-annotation on each parameter (incl. body) to a mutable + // List so templates iterate a single shape and code can append annotations. + normalizeParameterVendorExtensionWithStringList(operation, VendorExtension.X_FIELD_EXTRA_ANNOTATION.getName()); + // x-request-body-extra-annotation is authored on the operation (the request body usually + // $refs a shared model, so it cannot be placed next to the $ref). Merge its values into the + // body parameter's x-field-extra-annotation so it renders through the same template path. + normalizeVendorExtensionWithStringList(operation.vendorExtensions, VendorExtension.X_REQUEST_BODY_EXTRA_ANNOTATION.getName()); + mergeOperationVendorExtensionIntoBodyParams(operation, VendorExtension.X_REQUEST_BODY_EXTRA_ANNOTATION.getName(), VendorExtension.X_FIELD_EXTRA_ANNOTATION.getName()); List responses = operation.responses; if (responses != null) { responses.forEach(resp -> { @@ -1948,6 +1986,7 @@ public List getSupportedVendorExtensions() { extensions.add(VendorExtension.X_DISCRIMINATOR_VALUE); extensions.add(VendorExtension.X_FIELD_EXTRA_ANNOTATION); extensions.add(VendorExtension.X_OPERATION_EXTRA_ANNOTATION); + extensions.add(VendorExtension.X_REQUEST_BODY_EXTRA_ANNOTATION); extensions.add(VendorExtension.X_EXTRA_IMPORTS); extensions.add(VendorExtension.X_PATTERN_MESSAGE); extensions.add(VendorExtension.X_SIZE_MESSAGE); diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/SpringCodegen.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/SpringCodegen.java index 4d8b7f462fcc..60806230efc7 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/SpringCodegen.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/SpringCodegen.java @@ -1102,6 +1102,11 @@ public void setIsVoid(boolean isVoid) { handleImplicitHeaders(operation); normalizeVendorExtensionWithStringList(operation.vendorExtensions, VendorExtension.X_OPERATION_EXTRA_ANNOTATION.getName()); normalizeOperationParameterVendorExtensions(operation, VendorExtension.X_FIELD_EXTRA_ANNOTATION.getName()); + // x-request-body-extra-annotation is authored on the operation (the request body usually + // $refs a shared model, so it cannot be placed next to the $ref). Merge its values into the + // body parameter's x-field-extra-annotation so it renders through the same template path. + normalizeVendorExtensionWithStringList(operation.vendorExtensions, VendorExtension.X_REQUEST_BODY_EXTRA_ANNOTATION.getName()); + mergeOperationVendorExtensionIntoBodyParams(operation, VendorExtension.X_REQUEST_BODY_EXTRA_ANNOTATION.getName(), VendorExtension.X_FIELD_EXTRA_ANNOTATION.getName()); if (useSpringSecurityPreAuthorize) { addSpringSecurityPreAuthorize(operation); @@ -1722,6 +1727,7 @@ public void setUseSwaggerUI(boolean useSwaggerUI) { public List getSupportedVendorExtensions() { List extensions = super.getSupportedVendorExtensions(); extensions.add(VendorExtension.X_OPERATION_EXTRA_ANNOTATION); + extensions.add(VendorExtension.X_REQUEST_BODY_EXTRA_ANNOTATION); extensions.add(VendorExtension.X_SPRING_PAGINATED); extensions.add(VendorExtension.X_VERSION_PARAM); extensions.add(VendorExtension.X_PATTERN_MESSAGE); diff --git a/modules/openapi-generator/src/main/resources/JavaSpring/bodyParams.mustache b/modules/openapi-generator/src/main/resources/JavaSpring/bodyParams.mustache index 26bea8c9b706..2ffb02d98100 100644 --- a/modules/openapi-generator/src/main/resources/JavaSpring/bodyParams.mustache +++ b/modules/openapi-generator/src/main/resources/JavaSpring/bodyParams.mustache @@ -1 +1 @@ -{{#isBodyParam}}{{>paramDoc}}{{#useBeanValidation}} {{>beanValidationBodyParams}}@Valid{{/useBeanValidation}} @RequestBody{{^required}}(required = false){{/required}} {{^reactive}}{{>nullableAnnotation}}{{>optionalDataType}}{{/reactive}}{{#reactive}}{{^isArray}}Mono<{{{dataType}}}>{{/isArray}}{{#isArray}}Flux<{{{baseType}}}>{{/isArray}}{{/reactive}} {{paramName}}{{/isBodyParam}} \ No newline at end of file +{{#isBodyParam}}{{>paramDoc}}{{#vendorExtensions.x-field-extra-annotation}}{{{.}}} {{/vendorExtensions.x-field-extra-annotation}}{{#useBeanValidation}} {{>beanValidationBodyParams}}@Valid{{/useBeanValidation}} @RequestBody{{^required}}(required = false){{/required}} {{^reactive}}{{>nullableAnnotation}}{{>optionalDataType}}{{/reactive}}{{#reactive}}{{^isArray}}Mono<{{{dataType}}}>{{/isArray}}{{#isArray}}Flux<{{{baseType}}}>{{/isArray}}{{/reactive}} {{paramName}}{{/isBodyParam}} \ No newline at end of file diff --git a/modules/openapi-generator/src/main/resources/kotlin-spring/bodyParams.mustache b/modules/openapi-generator/src/main/resources/kotlin-spring/bodyParams.mustache index b4f03475cd0b..c44f1a1740bc 100644 --- a/modules/openapi-generator/src/main/resources/kotlin-spring/bodyParams.mustache +++ b/modules/openapi-generator/src/main/resources/kotlin-spring/bodyParams.mustache @@ -1 +1 @@ -{{#isBodyParam}}{{#swagger2AnnotationLibrary}}@Parameter(description = "{{{description}}}"{{#required}}, required = true{{/required}}{{^isContainer}}{{#allowableValues}}{{#defaultValue}}, schema = Schema(allowableValues = ["{{{allowableValues}}}"], defaultValue = {{^isString}}"{{/isString}}{{#isString}}{{#isEnum}}"{{/isEnum}}{{/isString}}{{{defaultValue}}}{{^isString}}"{{/isString}}{{#isString}}{{#isEnum}}"{{/isEnum}}{{/isString}}){{/defaultValue}}{{/allowableValues}}{{^allowableValues}}{{#defaultValue}}, schema = Schema(defaultValue = {{^isString}}"{{/isString}}{{#isString}}{{#isEnum}}"{{/isEnum}}{{/isString}}{{{defaultValue}}}{{^isString}}"{{/isString}}{{#isString}}{{#isEnum}}"{{/isEnum}}{{/isString}}){{/defaultValue}}{{/allowableValues}}{{#allowableValues}}{{^defaultValue}}, schema = Schema(allowableValues = ["{{{allowableValues}}}"]){{/defaultValue}}{{/allowableValues}}{{/isContainer}}) {{/swagger2AnnotationLibrary}}{{#swagger1AnnotationLibrary}}@ApiParam(value = "{{{description}}}"{{#required}}, required = true{{/required}}{{^isContainer}}{{#allowableValues}}, allowableValues = "{{{.}}}"{{/allowableValues}}{{/isContainer}}{{#defaultValue}}, defaultValue = "{{{.}}}"{{/defaultValue}}) {{/swagger1AnnotationLibrary}}{{#useBeanValidation}}@Valid{{>beanValidationBodyParams}}{{/useBeanValidation}} @RequestBody{{^required}}(required = false){{/required}} {{{paramName}}}: {{^reactive}}{{>optionalDataType}}{{/reactive}}{{#reactive}}{{^isArray}}{{>optionalDataType}}{{/isArray}}{{#isArray}}Flow<{{{baseType}}}>{{/isArray}}{{/reactive}}{{/isBodyParam}} \ No newline at end of file +{{#isBodyParam}}{{#swagger2AnnotationLibrary}}@Parameter(description = "{{{description}}}"{{#required}}, required = true{{/required}}{{^isContainer}}{{#allowableValues}}{{#defaultValue}}, schema = Schema(allowableValues = ["{{{allowableValues}}}"], defaultValue = {{^isString}}"{{/isString}}{{#isString}}{{#isEnum}}"{{/isEnum}}{{/isString}}{{{defaultValue}}}{{^isString}}"{{/isString}}{{#isString}}{{#isEnum}}"{{/isEnum}}{{/isString}}){{/defaultValue}}{{/allowableValues}}{{^allowableValues}}{{#defaultValue}}, schema = Schema(defaultValue = {{^isString}}"{{/isString}}{{#isString}}{{#isEnum}}"{{/isEnum}}{{/isString}}{{{defaultValue}}}{{^isString}}"{{/isString}}{{#isString}}{{#isEnum}}"{{/isEnum}}{{/isString}}){{/defaultValue}}{{/allowableValues}}{{#allowableValues}}{{^defaultValue}}, schema = Schema(allowableValues = ["{{{allowableValues}}}"]){{/defaultValue}}{{/allowableValues}}{{/isContainer}}) {{/swagger2AnnotationLibrary}}{{#swagger1AnnotationLibrary}}@ApiParam(value = "{{{description}}}"{{#required}}, required = true{{/required}}{{^isContainer}}{{#allowableValues}}, allowableValues = "{{{.}}}"{{/allowableValues}}{{/isContainer}}{{#defaultValue}}, defaultValue = "{{{.}}}"{{/defaultValue}}) {{/swagger1AnnotationLibrary}}{{#vendorExtensions.x-field-extra-annotation}}{{{.}}} {{/vendorExtensions.x-field-extra-annotation}}{{#useBeanValidation}}@Valid{{>beanValidationBodyParams}}{{/useBeanValidation}} @RequestBody{{^required}}(required = false){{/required}} {{{paramName}}}: {{^reactive}}{{>optionalDataType}}{{/reactive}}{{#reactive}}{{^isArray}}{{>optionalDataType}}{{/isArray}}{{#isArray}}Flow<{{{baseType}}}>{{/isArray}}{{/reactive}}{{/isBodyParam}} \ No newline at end of file diff --git a/modules/openapi-generator/src/main/resources/kotlin-spring/formParams.mustache b/modules/openapi-generator/src/main/resources/kotlin-spring/formParams.mustache index 456af893718f..4cc39f195fb8 100644 --- a/modules/openapi-generator/src/main/resources/kotlin-spring/formParams.mustache +++ b/modules/openapi-generator/src/main/resources/kotlin-spring/formParams.mustache @@ -1 +1 @@ -{{#isFormParam}}{{^isFile}}{{#swagger2AnnotationLibrary}}@Parameter(description = "{{{description}}}"{{#required}}, required = true{{/required}}{{#allowableValues}}{{#defaultValue}}, schema = Schema(allowableValues = [{{#values}}"{{{.}}}"{{^-last}}, {{/-last}}{{/values}}]{{^isContainer}}, defaultValue = {{^isString}}"{{/isString}}{{#isString}}{{#isEnum}}"{{/isEnum}}{{/isString}}{{{defaultValue}}}{{^isString}}"{{/isString}}{{#isString}}{{#isEnum}}"{{/isEnum}}{{/isString}}{{/isContainer}}){{/defaultValue}}{{/allowableValues}}{{#allowableValues}}{{^defaultValue}}, schema = Schema(allowableValues = [{{#values}}"{{{.}}}"{{^-last}}, {{/-last}}{{/values}}]){{/defaultValue}}{{/allowableValues}}{{^allowableValues}}{{#defaultValue}}{{^isContainer}}, schema = Schema(defaultValue = {{^isString}}"{{/isString}}{{#isString}}{{#isEnum}}"{{/isEnum}}{{/isString}}{{{defaultValue}}}{{^isString}}"{{/isString}}{{#isString}}{{#isEnum}}"{{/isEnum}}{{/isString}}){{/isContainer}}{{/defaultValue}}{{/allowableValues}}) {{/swagger2AnnotationLibrary}}{{#swagger1AnnotationLibrary}}@ApiParam(value = "{{{description}}}"{{#required}}, required = true{{/required}}{{#allowableValues}}, allowableValues = "{{#values}}{{{.}}}{{^-last}}, {{/-last}}{{/values}}"{{/allowableValues}}{{#defaultValue}}, defaultValue = "{{{.}}}"{{/defaultValue}}) {{/swagger1AnnotationLibrary}}{{#useBeanValidation}}@Valid {{/useBeanValidation}}{{#isModel}}@RequestPart{{/isModel}}{{^isModel}}@RequestParam{{/isModel}}(value = "{{baseName}}"{{#required}}, required = true{{/required}}{{^required}}, required = false{{/required}}) {{{paramName}}}: {{>optionalDataType}}{{/isFile}}{{#isFile}}{{#swagger2AnnotationLibrary}}@Parameter(description = "{{{description}}}") {{/swagger2AnnotationLibrary}}{{#swagger1AnnotationLibrary}}@ApiParam(value = "file detail") {{/swagger1AnnotationLibrary}}{{#useBeanValidation}}@Valid{{/useBeanValidation}} @RequestPart("{{baseName}}"{{#required}}, required = true{{/required}}{{^required}}, required = false{{/required}}) {{{paramName}}}: {{>optionalDataType}}{{/isFile}}{{/isFormParam}} \ No newline at end of file +{{#isFormParam}}{{^isFile}}{{#vendorExtensions.x-field-extra-annotation}}{{{.}}} {{/vendorExtensions.x-field-extra-annotation}}{{#swagger2AnnotationLibrary}}@Parameter(description = "{{{description}}}"{{#required}}, required = true{{/required}}{{#allowableValues}}{{#defaultValue}}, schema = Schema(allowableValues = [{{#values}}"{{{.}}}"{{^-last}}, {{/-last}}{{/values}}]{{^isContainer}}, defaultValue = {{^isString}}"{{/isString}}{{#isString}}{{#isEnum}}"{{/isEnum}}{{/isString}}{{{defaultValue}}}{{^isString}}"{{/isString}}{{#isString}}{{#isEnum}}"{{/isEnum}}{{/isString}}{{/isContainer}}){{/defaultValue}}{{/allowableValues}}{{#allowableValues}}{{^defaultValue}}, schema = Schema(allowableValues = [{{#values}}"{{{.}}}"{{^-last}}, {{/-last}}{{/values}}]){{/defaultValue}}{{/allowableValues}}{{^allowableValues}}{{#defaultValue}}{{^isContainer}}, schema = Schema(defaultValue = {{^isString}}"{{/isString}}{{#isString}}{{#isEnum}}"{{/isEnum}}{{/isString}}{{{defaultValue}}}{{^isString}}"{{/isString}}{{#isString}}{{#isEnum}}"{{/isEnum}}{{/isString}}){{/isContainer}}{{/defaultValue}}{{/allowableValues}}) {{/swagger2AnnotationLibrary}}{{#swagger1AnnotationLibrary}}@ApiParam(value = "{{{description}}}"{{#required}}, required = true{{/required}}{{#allowableValues}}, allowableValues = "{{#values}}{{{.}}}{{^-last}}, {{/-last}}{{/values}}"{{/allowableValues}}{{#defaultValue}}, defaultValue = "{{{.}}}"{{/defaultValue}}) {{/swagger1AnnotationLibrary}}{{#useBeanValidation}}@Valid {{/useBeanValidation}}{{#isModel}}@RequestPart{{/isModel}}{{^isModel}}@RequestParam{{/isModel}}(value = "{{baseName}}"{{#required}}, required = true{{/required}}{{^required}}, required = false{{/required}}) {{{paramName}}}: {{>optionalDataType}}{{/isFile}}{{#isFile}}{{#vendorExtensions.x-field-extra-annotation}}{{{.}}} {{/vendorExtensions.x-field-extra-annotation}}{{#swagger2AnnotationLibrary}}@Parameter(description = "{{{description}}}") {{/swagger2AnnotationLibrary}}{{#swagger1AnnotationLibrary}}@ApiParam(value = "file detail") {{/swagger1AnnotationLibrary}}{{#useBeanValidation}}@Valid{{/useBeanValidation}} @RequestPart("{{baseName}}"{{#required}}, required = true{{/required}}{{^required}}, required = false{{/required}}) {{{paramName}}}: {{>optionalDataType}}{{/isFile}}{{/isFormParam}} \ No newline at end of file diff --git a/modules/openapi-generator/src/main/resources/kotlin-spring/pathParams.mustache b/modules/openapi-generator/src/main/resources/kotlin-spring/pathParams.mustache index 2e28d18c78fa..3f22fecbbd9b 100644 --- a/modules/openapi-generator/src/main/resources/kotlin-spring/pathParams.mustache +++ b/modules/openapi-generator/src/main/resources/kotlin-spring/pathParams.mustache @@ -1 +1 @@ -{{#isPathParam}}{{#useBeanValidation}}{{>beanValidationPathParams}}{{/useBeanValidation}}{{#swagger2AnnotationLibrary}}@Parameter(description = "{{{description}}}"{{#required}}, required = true{{/required}}{{#allowableValues}}{{#defaultValue}}, schema = Schema(allowableValues = [{{#values}}"{{{.}}}"{{^-last}}, {{/-last}}{{/values}}]{{^isContainer}}, defaultValue = {{^isString}}"{{/isString}}{{#isString}}{{#isEnum}}"{{/isEnum}}{{/isString}}{{{defaultValue}}}{{^isString}}"{{/isString}}{{#isString}}{{#isEnum}}"{{/isEnum}}{{/isString}}{{/isContainer}}){{/defaultValue}}{{/allowableValues}}{{#allowableValues}}{{^defaultValue}}, schema = Schema(allowableValues = [{{#values}}"{{{.}}}"{{^-last}}, {{/-last}}{{/values}}]){{/defaultValue}}{{/allowableValues}}{{^allowableValues}}{{#defaultValue}}{{^isContainer}}, schema = Schema(defaultValue = {{^isString}}"{{/isString}}{{#isString}}{{#isEnum}}"{{/isEnum}}{{/isString}}{{{defaultValue}}}{{^isString}}"{{/isString}}{{#isString}}{{#isEnum}}"{{/isEnum}}{{/isString}}){{/isContainer}}{{/defaultValue}}{{/allowableValues}}) {{/swagger2AnnotationLibrary}}{{#swagger1AnnotationLibrary}}@ApiParam(value = "{{{description}}}"{{#required}}, required = true{{/required}}{{#allowableValues}}, allowableValues = "{{#values}}{{{.}}}{{^-last}}, {{/-last}}{{/values}}"{{/allowableValues}}{{#defaultValue}}, defaultValue = "{{{.}}}"{{/defaultValue}}) {{/swagger1AnnotationLibrary}}@PathVariable("{{baseName}}") {{{paramName}}}: {{>optionalDataType}}{{/isPathParam}} \ No newline at end of file +{{#isPathParam}}{{#vendorExtensions.x-field-extra-annotation}}{{{.}}} {{/vendorExtensions.x-field-extra-annotation}}{{#useBeanValidation}}{{>beanValidationPathParams}}{{/useBeanValidation}}{{#swagger2AnnotationLibrary}}@Parameter(description = "{{{description}}}"{{#required}}, required = true{{/required}}{{#allowableValues}}{{#defaultValue}}, schema = Schema(allowableValues = [{{#values}}"{{{.}}}"{{^-last}}, {{/-last}}{{/values}}]{{^isContainer}}, defaultValue = {{^isString}}"{{/isString}}{{#isString}}{{#isEnum}}"{{/isEnum}}{{/isString}}{{{defaultValue}}}{{^isString}}"{{/isString}}{{#isString}}{{#isEnum}}"{{/isEnum}}{{/isString}}{{/isContainer}}){{/defaultValue}}{{/allowableValues}}{{#allowableValues}}{{^defaultValue}}, schema = Schema(allowableValues = [{{#values}}"{{{.}}}"{{^-last}}, {{/-last}}{{/values}}]){{/defaultValue}}{{/allowableValues}}{{^allowableValues}}{{#defaultValue}}{{^isContainer}}, schema = Schema(defaultValue = {{^isString}}"{{/isString}}{{#isString}}{{#isEnum}}"{{/isEnum}}{{/isString}}{{{defaultValue}}}{{^isString}}"{{/isString}}{{#isString}}{{#isEnum}}"{{/isEnum}}{{/isString}}){{/isContainer}}{{/defaultValue}}{{/allowableValues}}) {{/swagger2AnnotationLibrary}}{{#swagger1AnnotationLibrary}}@ApiParam(value = "{{{description}}}"{{#required}}, required = true{{/required}}{{#allowableValues}}, allowableValues = "{{#values}}{{{.}}}{{^-last}}, {{/-last}}{{/values}}"{{/allowableValues}}{{#defaultValue}}, defaultValue = "{{{.}}}"{{/defaultValue}}) {{/swagger1AnnotationLibrary}}@PathVariable("{{baseName}}") {{{paramName}}}: {{>optionalDataType}}{{/isPathParam}} \ No newline at end of file diff --git a/modules/openapi-generator/src/main/resources/kotlin-spring/queryParams.mustache b/modules/openapi-generator/src/main/resources/kotlin-spring/queryParams.mustache index 27d7e286bb33..551811ddbe23 100644 --- a/modules/openapi-generator/src/main/resources/kotlin-spring/queryParams.mustache +++ b/modules/openapi-generator/src/main/resources/kotlin-spring/queryParams.mustache @@ -1 +1 @@ -{{#isQueryParam}}{{#useBeanValidation}}{{>beanValidationQueryParams}}{{/useBeanValidation}}{{#swagger2AnnotationLibrary}}@Parameter(description = "{{{description}}}"{{#required}}, required = true{{/required}}{{#allowableValues}}{{#defaultValue}}, schema = Schema(allowableValues = [{{#values}}"{{{.}}}"{{^-last}}, {{/-last}}{{/values}}]{{^isContainer}}, defaultValue = {{^isString}}"{{/isString}}{{#isString}}{{#isEnum}}"{{/isEnum}}{{/isString}}{{{defaultValue}}}{{^isString}}"{{/isString}}{{#isString}}{{#isEnum}}"{{/isEnum}}{{/isString}}{{/isContainer}}){{/defaultValue}}{{/allowableValues}}{{#allowableValues}}{{^defaultValue}}, schema = Schema(allowableValues = [{{#values}}"{{{.}}}"{{^-last}}, {{/-last}}{{/values}}]){{/defaultValue}}{{/allowableValues}}{{^allowableValues}}{{#defaultValue}}{{^isContainer}}, schema = Schema(defaultValue = {{^isString}}"{{/isString}}{{#isString}}{{#isEnum}}"{{/isEnum}}{{/isString}}{{{defaultValue}}}{{^isString}}"{{/isString}}{{#isString}}{{#isEnum}}"{{/isEnum}}{{/isString}}){{/isContainer}}{{/defaultValue}}{{/allowableValues}}) {{/swagger2AnnotationLibrary}}{{#swagger1AnnotationLibrary}}@ApiParam(value = "{{{description}}}"{{#required}}, required = true{{/required}}{{#allowableValues}}, allowableValues = "{{#values}}{{{.}}}{{^-last}}, {{/-last}}{{/values}}"{{/allowableValues}}{{^isContainer}}{{#defaultValue}}, defaultValue = {{^isString}}"{{/isString}}{{#isString}}{{#isEnum}}"{{/isEnum}}{{/isString}}{{{defaultValue}}}{{^isString}}"{{/isString}}{{#isString}}{{#isEnum}}"{{/isEnum}}{{/isString}}{{/defaultValue}}{{/isContainer}}) {{/swagger1AnnotationLibrary}}{{#useBeanValidation}}@Valid{{/useBeanValidation}}{{^isModel}} @RequestParam(value = "{{baseName}}"{{#required}}, required = true{{/required}}{{^required}}, required = false{{/required}}{{^isContainer}}{{#defaultValue}}, defaultValue = {{^isString}}"{{/isString}}{{#isString}}{{#isEnum}}"{{/isEnum}}{{/isString}}{{{defaultValue}}}{{^isString}}"{{/isString}}{{#isString}}{{#isEnum}}"{{/isEnum}}{{/isString}}{{/defaultValue}}{{/isContainer}}){{/isModel}}{{#isDate}} @org.springframework.format.annotation.DateTimeFormat(iso = org.springframework.format.annotation.DateTimeFormat.ISO.DATE){{/isDate}}{{#isDateTime}} @org.springframework.format.annotation.DateTimeFormat(iso = org.springframework.format.annotation.DateTimeFormat.ISO.DATE_TIME){{/isDateTime}} {{{paramName}}}: {{>optionalDataType}}{{/isQueryParam}} \ No newline at end of file +{{#isQueryParam}}{{#vendorExtensions.x-field-extra-annotation}}{{{.}}} {{/vendorExtensions.x-field-extra-annotation}}{{#useBeanValidation}}{{>beanValidationQueryParams}}{{/useBeanValidation}}{{#swagger2AnnotationLibrary}}@Parameter(description = "{{{description}}}"{{#required}}, required = true{{/required}}{{#allowableValues}}{{#defaultValue}}, schema = Schema(allowableValues = [{{#values}}"{{{.}}}"{{^-last}}, {{/-last}}{{/values}}]{{^isContainer}}, defaultValue = {{^isString}}"{{/isString}}{{#isString}}{{#isEnum}}"{{/isEnum}}{{/isString}}{{{defaultValue}}}{{^isString}}"{{/isString}}{{#isString}}{{#isEnum}}"{{/isEnum}}{{/isString}}{{/isContainer}}){{/defaultValue}}{{/allowableValues}}{{#allowableValues}}{{^defaultValue}}, schema = Schema(allowableValues = [{{#values}}"{{{.}}}"{{^-last}}, {{/-last}}{{/values}}]){{/defaultValue}}{{/allowableValues}}{{^allowableValues}}{{#defaultValue}}{{^isContainer}}, schema = Schema(defaultValue = {{^isString}}"{{/isString}}{{#isString}}{{#isEnum}}"{{/isEnum}}{{/isString}}{{{defaultValue}}}{{^isString}}"{{/isString}}{{#isString}}{{#isEnum}}"{{/isEnum}}{{/isString}}){{/isContainer}}{{/defaultValue}}{{/allowableValues}}) {{/swagger2AnnotationLibrary}}{{#swagger1AnnotationLibrary}}@ApiParam(value = "{{{description}}}"{{#required}}, required = true{{/required}}{{#allowableValues}}, allowableValues = "{{#values}}{{{.}}}{{^-last}}, {{/-last}}{{/values}}"{{/allowableValues}}{{^isContainer}}{{#defaultValue}}, defaultValue = {{^isString}}"{{/isString}}{{#isString}}{{#isEnum}}"{{/isEnum}}{{/isString}}{{{defaultValue}}}{{^isString}}"{{/isString}}{{#isString}}{{#isEnum}}"{{/isEnum}}{{/isString}}{{/defaultValue}}{{/isContainer}}) {{/swagger1AnnotationLibrary}}{{#useBeanValidation}}@Valid{{/useBeanValidation}}{{^isModel}} @RequestParam(value = "{{baseName}}"{{#required}}, required = true{{/required}}{{^required}}, required = false{{/required}}{{^isContainer}}{{#defaultValue}}, defaultValue = {{^isString}}"{{/isString}}{{#isString}}{{#isEnum}}"{{/isEnum}}{{/isString}}{{{defaultValue}}}{{^isString}}"{{/isString}}{{#isString}}{{#isEnum}}"{{/isEnum}}{{/isString}}{{/defaultValue}}{{/isContainer}}){{/isModel}}{{#isDate}} @org.springframework.format.annotation.DateTimeFormat(iso = org.springframework.format.annotation.DateTimeFormat.ISO.DATE){{/isDate}}{{#isDateTime}} @org.springframework.format.annotation.DateTimeFormat(iso = org.springframework.format.annotation.DateTimeFormat.ISO.DATE_TIME){{/isDateTime}} {{{paramName}}}: {{>optionalDataType}}{{/isQueryParam}} \ No newline at end of file diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/java/spring/SpringCodegenTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/java/spring/SpringCodegenTest.java index b7184f853396..f06ec550069a 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/java/spring/SpringCodegenTest.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/java/spring/SpringCodegenTest.java @@ -3962,6 +3962,51 @@ public void testHasOperationParameterExtraAnnotation_issue18224() throws IOExcep .containsWithName("com.test.MyAnnotationInHeader"); } + @Test + public void testRequestBodyExtraAnnotation() throws IOException { + File output = Files.createTempDirectory("test").toFile().getCanonicalFile(); + output.deleteOnExit(); + + final OpenAPI openAPI = TestUtils.parseFlattenSpec("src/test/resources/3_0/request-body-extra-annotation.yaml"); + final SpringCodegen codegen = new SpringCodegen(); + codegen.setOpenAPI(openAPI); + codegen.setOutputDir(output.getAbsolutePath()); + codegen.additionalProperties().put(INTERFACE_ONLY, "true"); + + ClientOptInput input = new ClientOptInput(); + input.openAPI(openAPI); + input.config(codegen); + + DefaultGenerator generator = new DefaultGenerator(); + generator.setGenerateMetadata(false); + generator.setGeneratorPropertyDefault(CodegenConstants.APIS, "true"); + + Map files = generator.opts(input).generate().stream() + .collect(Collectors.toMap(File::getName, Function.identity())); + + JavaFileAssert.assertThat(files.get("EmployeesApi.java")) + // single string value renders before the body param + .assertMethod("createEmployee") + .assertParameter("employee") + .assertParameterAnnotations() + .containsWithName("com.example.MyValidation") + .containsWithName("RequestBody") + .toParameter().toMethod().toFileAssert() + // list value renders all annotations before the body param + .assertMethod("createEmployeeBulk") + .assertParameter("employee") + .assertParameterAnnotations() + .containsWithName("com.example.MyValidation") + .containsWithName("com.example.AuditLogged") + .toParameter().toMethod().toFileAssert() + // selectivity: operation without the extension referencing the same model is unaffected + .assertMethod("createEmployeePlain") + .assertParameter("employee") + .assertParameterAnnotations() + .doesNotContainWithName("com.example.MyValidation") + .doesNotContainWithName("com.example.AuditLogged"); + } + @Test public void testModelHasParameterExtraAnnotations_issue19953() { Path output = TestUtils.newTempFolder(); diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/kotlin/spring/KotlinSpringServerCodegenTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/kotlin/spring/KotlinSpringServerCodegenTest.java index 7671d2683121..a0c048aeacd6 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/kotlin/spring/KotlinSpringServerCodegenTest.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/kotlin/spring/KotlinSpringServerCodegenTest.java @@ -4482,6 +4482,43 @@ public void declarativeReactorArrayOfStringReturnsMonoResponseEntity() throws Ex "kotlin.collections.Set<", "Mono files = generateFromContract( + "src/test/resources/3_0/kotlin/kotlin-spring-param-extra-annotation.yaml", + Map.of(INTERFACE_ONLY, true)); + + Path locationApi = files.get("OrgsApi.kt").toPath(); + // path param (single value), path param (list value -> both render), query param + assertFileContains(locationApi, + "@com.example.ValidOrgId ", + "@com.example.ValidLocId @com.example.Trimmed ", + "@com.example.ValidFilter "); + + // form param + assertFileContains(files.get("DevicesApi.kt").toPath(), "@com.example.ValidDeviceId "); + } + + @Test + public void testRequestBodyExtraAnnotation() throws IOException { + Map files = generateFromContract( + "src/test/resources/3_0/request-body-extra-annotation.yaml", + Map.of(INTERFACE_ONLY, true)); + + Path employeeApi = files.get("EmployeesApi.kt").toPath(); + // single string value and list value both render before the body binding + assertFileContains(employeeApi, + "@com.example.MyValidation ", + "@com.example.AuditLogged "); + + // selectivity: the annotation is applied per-operation only, even though all three + // operations reference the same shared Employee model. It must render exactly twice + // (createEmployee + createEmployeeBulk), never for createEmployeePlain. + String content = Files.readString(employeeApi); + int myValidationCount = content.split("@com.example.MyValidation", -1).length - 1; + assertThat(myValidationCount).isEqualTo(2); + } + private Map generateFromContract(String url) throws IOException { return generateFromContract(url, new HashMap<>(), new HashMap<>()); } diff --git a/modules/openapi-generator/src/test/resources/3_0/kotlin/kotlin-spring-param-extra-annotation.yaml b/modules/openapi-generator/src/test/resources/3_0/kotlin/kotlin-spring-param-extra-annotation.yaml new file mode 100644 index 000000000000..100d7564ad74 --- /dev/null +++ b/modules/openapi-generator/src/test/resources/3_0/kotlin/kotlin-spring-param-extra-annotation.yaml @@ -0,0 +1,65 @@ +openapi: 3.0.3 +info: + title: kotlin-spring param extra annotation + version: 1.0.0 +paths: + /orgs/{orgId}/locations/{locId}: + get: + tags: + - location + operationId: getLocation + parameters: + - name: orgId + in: path + required: true + x-field-extra-annotation: "@com.example.ValidOrgId" + schema: + $ref: '#/components/schemas/OrgId' + - name: locId + in: path + required: true + x-field-extra-annotation: + - "@com.example.ValidLocId" + - "@com.example.Trimmed" + schema: + $ref: '#/components/schemas/LocId' + - name: filter + in: query + required: false + x-field-extra-annotation: "@com.example.ValidFilter" + schema: + $ref: '#/components/schemas/Filter' + responses: + '200': + description: ok + /devices: + post: + tags: + - device + operationId: registerDevice + requestBody: + content: + application/x-www-form-urlencoded: + schema: + type: object + required: + - deviceId + properties: + deviceId: + x-field-extra-annotation: "@com.example.ValidDeviceId" + allOf: + - $ref: '#/components/schemas/DeviceId' + responses: + '200': + description: ok +components: + schemas: + OrgId: + type: string + format: uuid + LocId: + type: string + DeviceId: + type: string + Filter: + type: string diff --git a/modules/openapi-generator/src/test/resources/3_0/request-body-extra-annotation.yaml b/modules/openapi-generator/src/test/resources/3_0/request-body-extra-annotation.yaml new file mode 100644 index 000000000000..554624bcf416 --- /dev/null +++ b/modules/openapi-generator/src/test/resources/3_0/request-body-extra-annotation.yaml @@ -0,0 +1,58 @@ +openapi: 3.0.3 +info: + title: request body extra annotation + version: 1.0.0 +paths: + /employees: + post: + tags: + - employee + operationId: createEmployee + x-request-body-extra-annotation: "@com.example.MyValidation" + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Employee' + responses: + '201': + description: created + /employees/bulk: + post: + tags: + - employee + operationId: createEmployeeBulk + x-request-body-extra-annotation: + - "@com.example.MyValidation" + - "@com.example.AuditLogged" + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Employee' + responses: + '201': + description: created + /employees/plain: + post: + tags: + - employee + operationId: createEmployeePlain + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Employee' + responses: + '201': + description: created +components: + schemas: + Employee: + type: object + properties: + name: + type: string From 813b7a4682e4617844e9448f9eb53f8c6ef2dc01 Mon Sep 17 00:00:00 2001 From: Jachym Metlicka Date: Wed, 26 Aug 2026 10:11:03 +0200 Subject: [PATCH 02/20] update comments and docs --- .../org/openapitools/codegen/DefaultCodegen.java | 16 ++++++---------- .../languages/KotlinSpringServerCodegen.java | 13 ++++++------- 2 files changed, 12 insertions(+), 17 deletions(-) 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 78f192ee0043..162bae646b84 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 @@ -7400,12 +7400,10 @@ public static void normalizeVendorExtensionWithStringList(Map ve } /** - * Merges the values of an operation-level vendor extension into a list-valued vendor extension of - * each request body parameter. This lets an operation-level annotation extension (for example - * {@code x-request-body-extra-annotation}) be rendered through the same template path as the - * per-parameter {@code x-field-extra-annotation}. It is required because a request body typically - * {@code $ref}s a shared model, so the annotation cannot be placed next to the {@code $ref} and - * must instead be authored on the operation. Any annotations already present on the body parameter + * Merges the values of an operation-level vendor extension into a list-valued vendor extension on + * each request body parameter. This lets an annotation authored on the operation be applied to the + * request body parameter, which is needed because a request body typically {@code $ref}s a shared + * model and so cannot carry the annotation itself. Any values already present on the body parameter * are preserved (they appear first), then the operation-level values are appended. This is a no-op * when the source extension is absent or empty, leaving existing body-parameter extensions untouched. * @@ -7418,10 +7416,8 @@ public static void mergeOperationVendorExtensionIntoBodyParams(CodegenOperation if (sourceValues.isEmpty()) { return; } - // The body parameter can be represented by distinct CodegenParameter instances across the - // operation's collections (e.g. allParams vs bodyParams), and templates may iterate either; - // de-duplicate by identity and update every body-parameter instance so the merge is visible - // regardless of which collection the template renders. + // A request body parameter may be represented by more than one object; update every + // instance so the merged values are applied consistently. Set bodyParameters = Collections.newSetFromMap(new IdentityHashMap<>()); bodyParameters.addAll(operation.bodyParams); for (CodegenParameter param : operation.allParams) { diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/KotlinSpringServerCodegen.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/KotlinSpringServerCodegen.java index 0b7e81b02f5f..423ebf24339e 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/KotlinSpringServerCodegen.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/KotlinSpringServerCodegen.java @@ -1701,11 +1701,10 @@ public ModelsMap postProcessModelsEnum(ModelsMap objs) { } /** - * Normalizes a vendor extension across all of an operation's parameter collections into a mutable - * {@code List}. Mirrors {@code AbstractJavaCodegen.normalizeOperationParameterVendorExtensions} - * (which this Kotlin generator does not inherit) so parameter annotation extensions such as - * {@code x-field-extra-annotation} have a single, predictable shape for templates and downstream code. - * The same parameter can appear in several collections, so they are de-duplicated by identity. + * Normalizes a vendor extension on all of an operation's parameters into a mutable + * {@code List}, so that a value authored as either a single string or a list is + * handled uniformly and further values can be appended. The same parameter can appear in + * several of the operation's parameter collections, so they are de-duplicated by identity. * * @param operation operation whose parameters should be updated * @param name vendor extension name @@ -1736,8 +1735,8 @@ public OperationsMap postProcessOperationsWithModels(OperationsMap objs, List ops = operations.getOperation(); ops.forEach(operation -> { - // Normalize x-field-extra-annotation on each parameter (incl. body) to a mutable - // List so templates iterate a single shape and code can append annotations. + // Normalize x-field-extra-annotation on each parameter (including the body) so a + // string or list value is handled uniformly and further values can be appended. normalizeParameterVendorExtensionWithStringList(operation, VendorExtension.X_FIELD_EXTRA_ANNOTATION.getName()); // x-request-body-extra-annotation is authored on the operation (the request body usually // $refs a shared model, so it cannot be placed next to the $ref). Merge its values into the From 55e317cb7045922cae871332d9e4a87f3d7eb0ec Mon Sep 17 00:00:00 2001 From: Jachym Metlicka Date: Wed, 26 Aug 2026 10:38:32 +0200 Subject: [PATCH 03/20] support also header params and cookie params --- .../resources/JavaSpring/cookieParams.mustache | 2 +- .../kotlin-spring/cookieParams.mustache | 2 +- .../kotlin-spring/headerParams.mustache | 2 +- .../codegen/java/spring/SpringCodegenTest.java | 7 ++++++- .../spring/KotlinSpringServerCodegenTest.java | 7 +++++-- .../src/test/resources/3_0/issue_18224.yaml | 8 ++++++++ .../kotlin-spring-param-extra-annotation.yaml | 18 ++++++++++++++++++ 7 files changed, 40 insertions(+), 6 deletions(-) diff --git a/modules/openapi-generator/src/main/resources/JavaSpring/cookieParams.mustache b/modules/openapi-generator/src/main/resources/JavaSpring/cookieParams.mustache index a255b5c7daf2..87865ce82f1b 100644 --- a/modules/openapi-generator/src/main/resources/JavaSpring/cookieParams.mustache +++ b/modules/openapi-generator/src/main/resources/JavaSpring/cookieParams.mustache @@ -1 +1 @@ -{{#isCookieParam}}{{#useBeanValidation}}{{>beanValidationQueryParams}}{{/useBeanValidation}}{{>paramDoc}} @CookieValue(name = "{{baseName}}"{{^required}}, required = false{{/required}}{{#defaultValue}}, defaultValue = "{{{.}}}"{{/defaultValue}}){{>dateTimeParam}} {{>nullableAnnotation}}{{>optionalDataType}} {{paramName}}{{/isCookieParam}} \ No newline at end of file +{{#isCookieParam}}{{#vendorExtensions.x-field-extra-annotation}}{{{.}}} {{/vendorExtensions.x-field-extra-annotation}}{{#useBeanValidation}}{{>beanValidationQueryParams}}{{/useBeanValidation}}{{>paramDoc}} @CookieValue(name = "{{baseName}}"{{^required}}, required = false{{/required}}{{#defaultValue}}, defaultValue = "{{{.}}}"{{/defaultValue}}){{>dateTimeParam}} {{>nullableAnnotation}}{{>optionalDataType}} {{paramName}}{{/isCookieParam}} \ No newline at end of file diff --git a/modules/openapi-generator/src/main/resources/kotlin-spring/cookieParams.mustache b/modules/openapi-generator/src/main/resources/kotlin-spring/cookieParams.mustache index 028264a18bcf..acce5461e358 100644 --- a/modules/openapi-generator/src/main/resources/kotlin-spring/cookieParams.mustache +++ b/modules/openapi-generator/src/main/resources/kotlin-spring/cookieParams.mustache @@ -1 +1 @@ -{{#isCookieParam}}{{#useBeanValidation}}{{>beanValidationQueryParams}}{{/useBeanValidation}}@CookieValue(name = "{{baseName}}"{{^required}}, required = false{{/required}}{{#defaultValue}}, defaultValue = "{{{.}}}"{{/defaultValue}}) {{{paramName}}}: {{>optionalDataType}}{{/isCookieParam}} \ No newline at end of file +{{#isCookieParam}}{{#vendorExtensions.x-field-extra-annotation}}{{{.}}} {{/vendorExtensions.x-field-extra-annotation}}{{#useBeanValidation}}{{>beanValidationQueryParams}}{{/useBeanValidation}}@CookieValue(name = "{{baseName}}"{{^required}}, required = false{{/required}}{{#defaultValue}}, defaultValue = "{{{.}}}"{{/defaultValue}}) {{{paramName}}}: {{>optionalDataType}}{{/isCookieParam}} \ No newline at end of file diff --git a/modules/openapi-generator/src/main/resources/kotlin-spring/headerParams.mustache b/modules/openapi-generator/src/main/resources/kotlin-spring/headerParams.mustache index 0c2678f1bf67..a29b151e1c64 100644 --- a/modules/openapi-generator/src/main/resources/kotlin-spring/headerParams.mustache +++ b/modules/openapi-generator/src/main/resources/kotlin-spring/headerParams.mustache @@ -1 +1 @@ -{{#isHeaderParam}}{{#useBeanValidation}}{{>beanValidationCore}}{{/useBeanValidation}}{{#swagger2AnnotationLibrary}}@Parameter(description = "{{{description}}}", `in` = ParameterIn.HEADER{{#required}}, required = true{{/required}}{{#allowableValues}}{{#defaultValue}}, schema = Schema(allowableValues = [{{#values}}"{{{.}}}"{{^-last}}, {{/-last}}{{/values}}]{{^isContainer}}, defaultValue = {{^isString}}"{{/isString}}{{#isString}}{{#isEnum}}"{{/isEnum}}{{/isString}}{{{defaultValue}}}{{^isString}}"{{/isString}}{{#isString}}{{#isEnum}}"{{/isEnum}}{{/isString}}{{/isContainer}}){{/defaultValue}}{{/allowableValues}}{{#allowableValues}}{{^defaultValue}}, schema = Schema(allowableValues = [{{#values}}"{{{.}}}"{{^-last}}, {{/-last}}{{/values}}]){{/defaultValue}}{{/allowableValues}}{{^allowableValues}}{{#defaultValue}}{{^isContainer}}, schema = Schema(defaultValue = {{^isString}}"{{/isString}}{{#isString}}{{#isEnum}}"{{/isEnum}}{{/isString}}{{{defaultValue}}}{{^isString}}"{{/isString}}{{#isString}}{{#isEnum}}"{{/isEnum}}{{/isString}}){{/isContainer}}{{/defaultValue}}{{/allowableValues}}) {{/swagger2AnnotationLibrary}}{{#swagger1AnnotationLibrary}}@ApiParam(value = "{{{description}}}"{{#required}}, required = true{{/required}}{{#allowableValues}}, allowableValues = "{{#values}}{{{.}}}{{^-last}}, {{/-last}}{{/values}}"{{/allowableValues}}{{#defaultValue}}, defaultValue = "{{{.}}}"{{/defaultValue}}) {{/swagger1AnnotationLibrary}}@RequestHeader(value = "{{baseName}}", required = {{#required}}true{{/required}}{{^required}}false{{/required}}{{#defaultValue}}, defaultValue = {{^isString}}"{{{.}}}"{{/isString}}{{#isString}}{{#isEnum}}"{{{.}}}"{{/isEnum}}{{^isEnum}}{{{.}}}{{/isEnum}}{{/isString}}{{/defaultValue}}) {{{paramName}}}: {{>optionalDataType}}{{/isHeaderParam}} \ No newline at end of file +{{#isHeaderParam}}{{#vendorExtensions.x-field-extra-annotation}}{{{.}}} {{/vendorExtensions.x-field-extra-annotation}}{{#useBeanValidation}}{{>beanValidationCore}}{{/useBeanValidation}}{{#swagger2AnnotationLibrary}}@Parameter(description = "{{{description}}}", `in` = ParameterIn.HEADER{{#required}}, required = true{{/required}}{{#allowableValues}}{{#defaultValue}}, schema = Schema(allowableValues = [{{#values}}"{{{.}}}"{{^-last}}, {{/-last}}{{/values}}]{{^isContainer}}, defaultValue = {{^isString}}"{{/isString}}{{#isString}}{{#isEnum}}"{{/isEnum}}{{/isString}}{{{defaultValue}}}{{^isString}}"{{/isString}}{{#isString}}{{#isEnum}}"{{/isEnum}}{{/isString}}{{/isContainer}}){{/defaultValue}}{{/allowableValues}}{{#allowableValues}}{{^defaultValue}}, schema = Schema(allowableValues = [{{#values}}"{{{.}}}"{{^-last}}, {{/-last}}{{/values}}]){{/defaultValue}}{{/allowableValues}}{{^allowableValues}}{{#defaultValue}}{{^isContainer}}, schema = Schema(defaultValue = {{^isString}}"{{/isString}}{{#isString}}{{#isEnum}}"{{/isEnum}}{{/isString}}{{{defaultValue}}}{{^isString}}"{{/isString}}{{#isString}}{{#isEnum}}"{{/isEnum}}{{/isString}}){{/isContainer}}{{/defaultValue}}{{/allowableValues}}) {{/swagger2AnnotationLibrary}}{{#swagger1AnnotationLibrary}}@ApiParam(value = "{{{description}}}"{{#required}}, required = true{{/required}}{{#allowableValues}}, allowableValues = "{{#values}}{{{.}}}{{^-last}}, {{/-last}}{{/values}}"{{/allowableValues}}{{#defaultValue}}, defaultValue = "{{{.}}}"{{/defaultValue}}) {{/swagger1AnnotationLibrary}}@RequestHeader(value = "{{baseName}}", required = {{#required}}true{{/required}}{{^required}}false{{/required}}{{#defaultValue}}, defaultValue = {{^isString}}"{{{.}}}"{{/isString}}{{#isString}}{{#isEnum}}"{{{.}}}"{{/isEnum}}{{^isEnum}}{{{.}}}{{/isEnum}}{{/isString}}{{/defaultValue}}) {{{paramName}}}: {{>optionalDataType}}{{/isHeaderParam}} \ No newline at end of file diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/java/spring/SpringCodegenTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/java/spring/SpringCodegenTest.java index f06ec550069a..6009416a7f96 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/java/spring/SpringCodegenTest.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/java/spring/SpringCodegenTest.java @@ -3959,7 +3959,12 @@ public void testHasOperationParameterExtraAnnotation_issue18224() throws IOExcep .toMethod() .assertParameter("clientId") .assertParameterAnnotations() - .containsWithName("com.test.MyAnnotationInHeader"); + .containsWithName("com.test.MyAnnotationInHeader") + .toParameter() + .toMethod() + .assertParameter("sessionId") + .assertParameterAnnotations() + .containsWithName("com.test.MyAnnotationInCookie"); } @Test diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/kotlin/spring/KotlinSpringServerCodegenTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/kotlin/spring/KotlinSpringServerCodegenTest.java index a0c048aeacd6..ac1cad8ed4f0 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/kotlin/spring/KotlinSpringServerCodegenTest.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/kotlin/spring/KotlinSpringServerCodegenTest.java @@ -4489,11 +4489,14 @@ public void testParameterFieldExtraAnnotation() throws IOException { Map.of(INTERFACE_ONLY, true)); Path locationApi = files.get("OrgsApi.kt").toPath(); - // path param (single value), path param (list value -> both render), query param + // path param (single value), path param (list value -> both render), query param, + // header param (single value), cookie param (list value -> both render) assertFileContains(locationApi, "@com.example.ValidOrgId ", "@com.example.ValidLocId @com.example.Trimmed ", - "@com.example.ValidFilter "); + "@com.example.ValidFilter ", + "@com.example.ValidTrace ", + "@com.example.ValidSession @com.example.Trimmed "); // form param assertFileContains(files.get("DevicesApi.kt").toPath(), "@com.example.ValidDeviceId "); diff --git a/modules/openapi-generator/src/test/resources/3_0/issue_18224.yaml b/modules/openapi-generator/src/test/resources/3_0/issue_18224.yaml index ea8a4db8adfd..8267a2b94a89 100644 --- a/modules/openapi-generator/src/test/resources/3_0/issue_18224.yaml +++ b/modules/openapi-generator/src/test/resources/3_0/issue_18224.yaml @@ -12,6 +12,7 @@ paths: - $ref: '#/components/parameters/groupObj' - $ref: '#/components/parameters/token' - $ref: '#/components/parameters/clientId' + - $ref: '#/components/parameters/sessionId' requestBody: content: application/json: @@ -49,6 +50,13 @@ components: pattern: "\\d" x-pattern-message: "Only numbers" x-field-extra-annotation: '@com.test.MyAnnotationInHeader' + sessionId: + in: cookie + name: sessionId + required: true + schema: + type: string + x-field-extra-annotation: '@com.test.MyAnnotationInCookie' schemas: ObjTest: description: A model to return diff --git a/modules/openapi-generator/src/test/resources/3_0/kotlin/kotlin-spring-param-extra-annotation.yaml b/modules/openapi-generator/src/test/resources/3_0/kotlin/kotlin-spring-param-extra-annotation.yaml index 100d7564ad74..b43b3a3f3f33 100644 --- a/modules/openapi-generator/src/test/resources/3_0/kotlin/kotlin-spring-param-extra-annotation.yaml +++ b/modules/openapi-generator/src/test/resources/3_0/kotlin/kotlin-spring-param-extra-annotation.yaml @@ -29,6 +29,20 @@ paths: x-field-extra-annotation: "@com.example.ValidFilter" schema: $ref: '#/components/schemas/Filter' + - name: X-Trace-Id + in: header + required: true + x-field-extra-annotation: "@com.example.ValidTrace" + schema: + $ref: '#/components/schemas/TraceId' + - name: session + in: cookie + required: true + x-field-extra-annotation: + - "@com.example.ValidSession" + - "@com.example.Trimmed" + schema: + $ref: '#/components/schemas/SessionId' responses: '200': description: ok @@ -63,3 +77,7 @@ components: type: string Filter: type: string + TraceId: + type: string + SessionId: + type: string From c977ee8cada142394835cc5db9e1929f568c1bbd Mon Sep 17 00:00:00 2001 From: Jachym Metlicka Date: Wed, 26 Aug 2026 11:18:26 +0200 Subject: [PATCH 04/20] update docs --- docs/generators/java-camel.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/generators/java-camel.md b/docs/generators/java-camel.md index 10213f64b802..7732d1dbe193 100644 --- a/docs/generators/java-camel.md +++ b/docs/generators/java-camel.md @@ -152,6 +152,7 @@ These options may be applied as additional-properties (cli) or configOptions (pl |x-class-extra-annotation|Custom annotation(s) to be added to model; accepts a string or list of strings|MODEL|null |x-field-extra-annotation|Custom annotation(s) to be added to property; accepts a string or list of strings|FIELD, OPERATION_PARAMETER|null |x-operation-extra-annotation|Custom annotation(s) to be added to operation; accepts a string or list of strings|OPERATION|null +|x-request-body-extra-annotation|Custom annotation(s) to be added to the request body parameter; accepts a string or list of strings. Declared on the operation because the request body typically `$ref`s a shared model (so the annotation cannot be placed next to the `$ref`); the value is rendered by being merged into the body parameter's `x-field-extra-annotation`|OPERATION|null |x-spring-paginated|Add `org.springframework.data.domain.Pageable` to controller method. Can be used to handle `page`, `size` and `sort` query parameters. If these query parameters are also specified in the operation spec, they will be removed from the controller method as their values can be obtained from the `Pageable` object. Applies when `library=spring-boot` or `library=spring-cloud`; ignored for other (client) libraries.|OPERATION|false |x-version-param|Marker property that tells that this parameter would be used for endpoint versioning. Applicable for headers & query params. true/false|OPERATION_PARAMETER|null |x-pattern-message|Add this property whenever you need to customize the invalidation error message for the regex pattern of a variable|FIELD, OPERATION_PARAMETER|null From eda2dd1afb36c9a7a6cb3ea56a4c810663fd52e2 Mon Sep 17 00:00:00 2001 From: Jachym Metlicka Date: Wed, 26 Aug 2026 11:47:54 +0200 Subject: [PATCH 05/20] feat: add --inject-operation-vendor-extensions to inject operation and parameter vendor extensions Injects vendor extensions onto operations and their parameters from the CLI or config without editing the spec, complementing --inject-model-vendor-extensions. Applied in DefaultCodegen.fromOperation so it works for all generators and flows through Spring's request-body annotation normalization. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../openapitools/codegen/cmd/Generate.java | 10 ++++ .../codegen/config/GeneratorSettings.java | 43 +++++++++++++++ .../openapitools/codegen/CodegenConfig.java | 2 + .../openapitools/codegen/DefaultCodegen.java | 55 ++++++++++++++++++- .../codegen/config/CodegenConfigurator.java | 17 ++++++ .../config/CodegenConfiguratorUtils.java | 13 +++++ .../codegen/DefaultCodegenTest.java | 26 +++++++++ .../java/spring/SpringCodegenTest.java | 49 +++++++++++++++++ .../spring/KotlinSpringServerCodegenTest.java | 27 +++++++++ .../inject-operation-vendor-extensions.yaml | 55 +++++++++++++++++++ 10 files changed, 296 insertions(+), 1 deletion(-) create mode 100644 modules/openapi-generator/src/test/resources/3_0/inject-operation-vendor-extensions.yaml diff --git a/modules/openapi-generator-cli/src/main/java/org/openapitools/codegen/cmd/Generate.java b/modules/openapi-generator-cli/src/main/java/org/openapitools/codegen/cmd/Generate.java index d0707f90388b..8acf57808516 100644 --- a/modules/openapi-generator-cli/src/main/java/org/openapitools/codegen/cmd/Generate.java +++ b/modules/openapi-generator-cli/src/main/java/org/openapitools/codegen/cmd/Generate.java @@ -268,6 +268,15 @@ public class Generate extends OpenApiGeneratorCommand { + " You can also have multiple occurrences of this option.") private List injectModelVendorExtensions = new ArrayList<>(); + @Option( + name = {"--inject-operation-vendor-extensions"}, + title = "inject operation vendor extensions", + description = "injects vendor extensions into operations or their parameters." + + " Operation-level format: operationId.x-extension-name=value." + + " Parameter-level format: operationId.paramName.x-extension-name=value." + + " You can also have multiple occurrences of this option.") + private List injectOperationVendorExtensions = new ArrayList<>(); + @Option( name = {"--openapi-normalizer"}, title = "OpenAPI normalizer rules", @@ -616,6 +625,7 @@ public void execute() { applyEnumNameMappingsKvpList(enumNameMappings, configurator); applyOperationIdNameMappingsKvpList(operationIdNameMappings, configurator); applyInjectModelVendorExtensionsKvpList(injectModelVendorExtensions, configurator); + applyInjectOperationVendorExtensionsKvpList(injectOperationVendorExtensions, configurator); applyOpenapiNormalizerKvpList(openapiNormalizer, configurator); applyTypeMappingsKvpList(typeMappings, configurator); applyAdditionalPropertiesKvpList(additionalProperties, configurator); diff --git a/modules/openapi-generator-core/src/main/java/org/openapitools/codegen/config/GeneratorSettings.java b/modules/openapi-generator-core/src/main/java/org/openapitools/codegen/config/GeneratorSettings.java index 9f18c8b85afb..71459f41d165 100644 --- a/modules/openapi-generator-core/src/main/java/org/openapitools/codegen/config/GeneratorSettings.java +++ b/modules/openapi-generator-core/src/main/java/org/openapitools/codegen/config/GeneratorSettings.java @@ -60,6 +60,7 @@ public final class GeneratorSettings implements Serializable { private final Map enumNameMappings; private final Map operationIdNameMappings; private final Map injectModelVendorExtensions; + private final Map injectOperationVendorExtensions; private final Map openapiNormalizer; private final Set languageSpecificPrimitives; private final Set openapiGeneratorIgnoreList; @@ -337,6 +338,15 @@ public Map getInjectModelVendorExtensions() { return injectModelVendorExtensions; } + /** + * Gets inject operation vendor extensions. + * + * @return a map of operationId.x-extension-name or operationId.paramName.x-extension-name to extension value + */ + public Map getInjectOperationVendorExtensions() { + return injectOperationVendorExtensions; + } + /** * Gets OpenAPI normalizer rules * @@ -480,6 +490,7 @@ private GeneratorSettings(Builder builder) { enumNameMappings = Collections.unmodifiableMap(builder.enumNameMappings); operationIdNameMappings = Collections.unmodifiableMap(builder.operationIdNameMappings); injectModelVendorExtensions = Collections.unmodifiableMap(builder.injectModelVendorExtensions); + injectOperationVendorExtensions = Collections.unmodifiableMap(builder.injectOperationVendorExtensions); openapiNormalizer = Collections.unmodifiableMap(builder.openapiNormalizer); languageSpecificPrimitives = Collections.unmodifiableSet(builder.languageSpecificPrimitives); openapiGeneratorIgnoreList = Collections.unmodifiableSet(builder.openapiGeneratorIgnoreList); @@ -562,6 +573,7 @@ public GeneratorSettings() { enumNameMappings = Collections.unmodifiableMap(new HashMap<>(0)); operationIdNameMappings = Collections.unmodifiableMap(new HashMap<>(0)); injectModelVendorExtensions = Collections.unmodifiableMap(new HashMap<>(0)); + injectOperationVendorExtensions = Collections.unmodifiableMap(new HashMap<>(0)); openapiNormalizer = Collections.unmodifiableMap(new HashMap<>(0)); languageSpecificPrimitives = Collections.unmodifiableSet(new HashSet<>(0)); openapiGeneratorIgnoreList = Collections.unmodifiableSet(new HashSet<>(0)); @@ -645,6 +657,9 @@ public static Builder newBuilder(GeneratorSettings copy) { if (copy.getInjectModelVendorExtensions() != null) { builder.injectModelVendorExtensions.putAll(copy.getInjectModelVendorExtensions()); } + if (copy.getInjectOperationVendorExtensions() != null) { + builder.injectOperationVendorExtensions.putAll(copy.getInjectOperationVendorExtensions()); + } if (copy.getOpenapiNormalizer() != null) { builder.openapiNormalizer.putAll(copy.getOpenapiNormalizer()); } @@ -700,6 +715,7 @@ public static final class Builder { private Map enumNameMappings; private Map operationIdNameMappings; private Map injectModelVendorExtensions; + private Map injectOperationVendorExtensions; private Map openapiNormalizer; private Set languageSpecificPrimitives; private Set openapiGeneratorIgnoreList; @@ -729,6 +745,7 @@ public Builder() { enumNameMappings = new HashMap<>(); operationIdNameMappings = new HashMap<>(); injectModelVendorExtensions = new HashMap<>(); + injectOperationVendorExtensions = new HashMap<>(); openapiNormalizer = new HashMap<>(); languageSpecificPrimitives = new HashSet<>(); openapiGeneratorIgnoreList = new HashSet<>(); @@ -1236,6 +1253,32 @@ public Builder withInjectModelVendorExtension(String key, String value) { return this; } + /** + * Sets the {@code injectOperationVendorExtensions} and returns a reference to this Builder so that the methods can be chained together. + * + * @param injectOperationVendorExtensions the {@code injectOperationVendorExtensions} to set + * @return a reference to this Builder + */ + public Builder withInjectOperationVendorExtensions(Map injectOperationVendorExtensions) { + this.injectOperationVendorExtensions = injectOperationVendorExtensions; + return this; + } + + /** + * Sets a single {@code injectOperationVendorExtension} and returns a reference to this Builder so that the methods can be chained together. + * + * @param key A key in the format operationId.x-extension-name or operationId.paramName.x-extension-name + * @param value The extension value + * @return a reference to this Builder + */ + public Builder withInjectOperationVendorExtension(String key, String value) { + if (this.injectOperationVendorExtensions == null) { + this.injectOperationVendorExtensions = new HashMap<>(); + } + this.injectOperationVendorExtensions.put(key, value); + return this; + } + /** * Sets the {@code openapiNormalizer} and returns a reference to this Builder so that the methods can be chained together. * 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..2e34ef5b5bac 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 @@ -190,6 +190,8 @@ default List divideOperationsByContentType(OpenAPI openAPI, String pa Map injectModelVendorExtensions(); + Map injectOperationVendorExtensions(); + Map openapiNormalizer(); Map apiTemplateFiles(); 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 162bae646b84..1e04aaf9c6c3 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 @@ -203,6 +203,8 @@ public class DefaultCodegen implements CodegenConfig { protected Map operationIdNameMapping = new HashMap<>(); // a map to inject vendor extensions into model classes or their properties: key=ModelName.x-extension-name or ModelName.propertyBaseName.x-extension-name, value=extensionValue protected Map injectModelVendorExtensions = new HashMap<>(); + // a map to inject vendor extensions into operations or their parameters: key=operationId.x-extension-name or operationId.paramName.x-extension-name, value=extensionValue + protected Map injectOperationVendorExtensions = new HashMap<>(); // a map to store the rules in OpenAPI Normalizer protected Map openapiNormalizer = new HashMap<>(); @Setter @@ -556,7 +558,7 @@ public Map postProcessAllModels(Map objs) } } - // Inject vendor extensions from --inject-property-extensions into matching schema properties + // Inject vendor extensions from --inject-model-vendor-extensions into matching schema properties if (!injectModelVendorExtensions.isEmpty()) { for (Map.Entry entry : objs.entrySet()) { CodegenModel model = ModelUtils.getModelByName(entry.getKey(), objs); @@ -1674,6 +1676,11 @@ public Map injectModelVendorExtensions() { return injectModelVendorExtensions; } + @Override + public Map injectOperationVendorExtensions() { + return injectOperationVendorExtensions; + } + @Override public Map openapiNormalizer() { return openapiNormalizer; @@ -5132,9 +5139,55 @@ public CodegenOperation fromOperation(String path, // legacy support op.nickname = op.operationId; + injectOperationVendorExtensions(op); + return op; } + /** + * Injects vendor extensions supplied via {@code injectOperationVendorExtensions} onto the given + * operation or its parameters. Keys are dotted: {@code operationId.x-extension-name} targets the + * operation, while {@code operationId.paramName.x-extension-name} targets a parameter matched by + * its spec name ({@code baseName}). This runs during operation construction, before + * {@code postProcessOperationsWithModels}, so an injected operation-level extension is available + * to any later normalization of that extension (for example, request-body annotation handling in + * the Spring generators). A parameter is represented by more than one object across the + * operation's collections, so the value is set on every matching instance. + * + * @param op the operation to update + */ + private void injectOperationVendorExtensions(CodegenOperation op) { + if (injectOperationVendorExtensions.isEmpty() || op.operationId == null) { + return; + } + for (Map.Entry extEntry : injectOperationVendorExtensions.entrySet()) { + String[] parts = extEntry.getKey().split("\\.", 3); + if (parts.length < 2) continue; + if (!parts[0].equals(op.operationId)) continue; + String extensionValue = extEntry.getValue(); + + if (parts.length == 2) { + // operation-level extension: operationId.x-extension-name + op.vendorExtensions.put(parts[1], extensionValue); + } else { + // parameter-level extension: operationId.paramName.x-extension-name + String paramBaseName = parts[1]; + String extensionName = parts[2]; + List> allParameterLists = Arrays.asList( + op.allParams, op.bodyParams, op.pathParams, op.queryParams, op.headerParams, + op.cookieParams, op.formParams, op.requiredParams, op.optionalParams, + op.requiredAndNotNullableParams, op.notNullableParams); + for (List parameters : allParameterLists) { + for (CodegenParameter parameter : parameters) { + if (paramBaseName.equals(parameter.baseName)) { + parameter.vendorExtensions.put(extensionName, extensionValue); + } + } + } + } + } + } + /** * Helper method to add an import for a data type if it exists in the importMapping. * diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/config/CodegenConfigurator.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/config/CodegenConfigurator.java index ab15c320b2cd..dac3ac307fd3 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/config/CodegenConfigurator.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/config/CodegenConfigurator.java @@ -75,6 +75,7 @@ public class CodegenConfigurator { private Map nameMappings = new HashMap<>(); private Map parameterNameMappings = new HashMap<>(); private Map modelNameMappings = new HashMap<>(); + private Map injectOperationVendorExtensions = new HashMap<>(); private Map enumNameMappings = new HashMap<>(); private Map operationIdNameMappings = new HashMap<>(); private Map injectModelVendorExtensions = new HashMap<>(); @@ -153,6 +154,9 @@ public static CodegenConfigurator fromFile(String configFile, Module... modules) if (generatorSettings.getInjectModelVendorExtensions() != null) { configurator.injectModelVendorExtensions.putAll(generatorSettings.getInjectModelVendorExtensions()); } + if (generatorSettings.getInjectOperationVendorExtensions() != null) { + configurator.injectOperationVendorExtensions.putAll(generatorSettings.getInjectOperationVendorExtensions()); + } if (generatorSettings.getOpenapiNormalizer() != null) { configurator.openapiNormalizer.putAll(generatorSettings.getOpenapiNormalizer()); } @@ -311,6 +315,18 @@ public CodegenConfigurator setInjectModelVendorExtensions(Map ex return this; } + public CodegenConfigurator addInjectOperationVendorExtension(String key, String value) { + this.injectOperationVendorExtensions.put(key, value); + generatorSettingsBuilder.withInjectOperationVendorExtension(key, value); + return this; + } + + public CodegenConfigurator setInjectOperationVendorExtensions(Map extensions) { + this.injectOperationVendorExtensions = extensions; + generatorSettingsBuilder.withInjectOperationVendorExtensions(extensions); + return this; + } + public CodegenConfigurator addOpenapiNormalizer(String key, String value) { this.openapiNormalizer.put(key, value); generatorSettingsBuilder.withOpenapiNormalizer(key, value); @@ -832,6 +848,7 @@ public ClientOptInput toClientOptInput() { config.enumNameMapping().putAll(generatorSettings.getEnumNameMappings()); config.operationIdNameMapping().putAll(generatorSettings.getOperationIdNameMappings()); config.injectModelVendorExtensions().putAll(generatorSettings.getInjectModelVendorExtensions()); + config.injectOperationVendorExtensions().putAll(generatorSettings.getInjectOperationVendorExtensions()); config.openapiNormalizer().putAll(generatorSettings.getOpenapiNormalizer()); config.languageSpecificPrimitives().addAll(generatorSettings.getLanguageSpecificPrimitives()); config.openapiGeneratorIgnoreList().addAll(generatorSettings.getOpenapiGeneratorIgnoreList()); diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/config/CodegenConfiguratorUtils.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/config/CodegenConfiguratorUtils.java index eabae28f67b6..1d8b6e5dff00 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/config/CodegenConfiguratorUtils.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/config/CodegenConfiguratorUtils.java @@ -217,6 +217,19 @@ public static void applyInjectModelVendorExtensionsKvp(String injectModelVendorE } } + public static void applyInjectOperationVendorExtensionsKvpList(List injectOperationVendorExtensions, CodegenConfigurator configurator) { + for (String propString : injectOperationVendorExtensions) { + applyInjectOperationVendorExtensionsKvp(propString, configurator); + } + } + + public static void applyInjectOperationVendorExtensionsKvp(String injectOperationVendorExtensions, CodegenConfigurator configurator) { + final Map map = createMapFromKeyValuePairs(injectOperationVendorExtensions); + for (Map.Entry entry : map.entrySet()) { + configurator.addInjectOperationVendorExtension(entry.getKey().trim(), entry.getValue().trim()); + } + } + public static void applyTypeMappingsKvpList(List typeMappings, CodegenConfigurator configurator) { for (String propString : typeMappings) { applyTypeMappingsKvp(propString, configurator); diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/DefaultCodegenTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/DefaultCodegenTest.java index 72624f257e5a..8181b496fdc7 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/DefaultCodegenTest.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/DefaultCodegenTest.java @@ -101,6 +101,32 @@ public void testEnumImports() { assertEquals(1, Sets.intersection(operation.imports, Sets.newHashSet("PetByType")).size()); } + @Test + public void testInjectOperationVendorExtensions() { + final DefaultCodegen codegen = new DefaultCodegen(); + final OpenAPI openApi = TestUtils.parseFlattenSpec("src/test/resources/3_0/inject-operation-vendor-extensions.yaml"); + codegen.setOpenAPI(openApi); + codegen.injectOperationVendorExtensions().put("createEmployee.x-request-body-extra-annotation", "@com.example.MyValidation"); + codegen.injectOperationVendorExtensions().put("createEmployee.orgId.x-field-extra-annotation", "@com.example.ValidOrgId"); + // non-matching operationId is a no-op + codegen.injectOperationVendorExtensions().put("noSuchOperation.x-foo", "bar"); + + PathItem path = openApi.getPaths().get("/orgs/{orgId}/employees"); + CodegenOperation operation = codegen.fromOperation("/orgs/{orgId}/employees", "post", path.getPost(), path.getServers()); + + // operation-level extension landed on the operation + assertEquals(operation.vendorExtensions.get("x-request-body-extra-annotation"), "@com.example.MyValidation"); + assertNull(operation.vendorExtensions.get("x-foo")); + + // parameter-level extension landed on the matching parameter across collections + CodegenParameter orgId = operation.allParams.stream() + .filter(p -> "orgId".equals(p.baseName)).findFirst().orElseThrow(); + assertEquals(orgId.vendorExtensions.get("x-field-extra-annotation"), "@com.example.ValidOrgId"); + CodegenParameter orgIdPath = operation.pathParams.stream() + .filter(p -> "orgId".equals(p.baseName)).findFirst().orElseThrow(); + assertEquals(orgIdPath.vendorExtensions.get("x-field-extra-annotation"), "@com.example.ValidOrgId"); + } + @Test public void testHasBodyParameter() { final Schema refSchema = new Schema<>().$ref("#/components/schemas/Pet"); diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/java/spring/SpringCodegenTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/java/spring/SpringCodegenTest.java index 6009416a7f96..2314d3a00f1d 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/java/spring/SpringCodegenTest.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/java/spring/SpringCodegenTest.java @@ -4012,6 +4012,55 @@ public void testRequestBodyExtraAnnotation() throws IOException { .doesNotContainWithName("com.example.AuditLogged"); } + @Test + public void testInjectOperationVendorExtensions() throws IOException { + File output = Files.createTempDirectory("test").toFile().getCanonicalFile(); + output.deleteOnExit(); + + final OpenAPI openAPI = TestUtils.parseFlattenSpec("src/test/resources/3_0/inject-operation-vendor-extensions.yaml"); + final SpringCodegen codegen = new SpringCodegen(); + codegen.setOpenAPI(openAPI); + codegen.setOutputDir(output.getAbsolutePath()); + codegen.additionalProperties().put(INTERFACE_ONLY, "true"); + + // operation-level injection drives the request-body annotation; parameter-level injection + // annotates the path param, both without editing the spec + codegen.injectOperationVendorExtensions().put("createEmployee.x-request-body-extra-annotation", "@com.example.MyValidation"); + codegen.injectOperationVendorExtensions().put("createEmployee.orgId.x-field-extra-annotation", "@com.example.ValidOrgId"); + + ClientOptInput input = new ClientOptInput(); + input.openAPI(openAPI); + input.config(codegen); + + DefaultGenerator generator = new DefaultGenerator(); + generator.setGenerateMetadata(false); + generator.setGeneratorPropertyDefault(CodegenConstants.APIS, "true"); + + Map files = generator.opts(input).generate().stream() + .collect(Collectors.toMap(File::getName, Function.identity())); + + JavaFileAssert.assertThat(files.get("OrgsApi.java")) + .assertMethod("createEmployee") + .assertParameter("employee") + .assertParameterAnnotations() + .containsWithName("com.example.MyValidation") + .containsWithName("RequestBody") + .toParameter().toMethod() + .assertParameter("orgId") + .assertParameterAnnotations() + .containsWithName("com.example.ValidOrgId") + .toParameter().toMethod().toFileAssert() + // selectivity: the second operation referencing the same model/param is unaffected + .assertMethod("createEmployeePlain") + .assertParameter("employee") + .assertParameterAnnotations() + .doesNotContainWithName("com.example.MyValidation") + .toParameter().toMethod() + .assertParameter("orgId") + .assertParameterAnnotations() + .doesNotContainWithName("com.example.ValidOrgId"); + } + @Test public void testModelHasParameterExtraAnnotations_issue19953() { Path output = TestUtils.newTempFolder(); diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/kotlin/spring/KotlinSpringServerCodegenTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/kotlin/spring/KotlinSpringServerCodegenTest.java index ac1cad8ed4f0..f93ce438e5d4 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/kotlin/spring/KotlinSpringServerCodegenTest.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/kotlin/spring/KotlinSpringServerCodegenTest.java @@ -4522,6 +4522,33 @@ public void testRequestBodyExtraAnnotation() throws IOException { assertThat(myValidationCount).isEqualTo(2); } + @Test + public void testInjectOperationVendorExtensions() throws IOException { + Map files = generateFromContract( + "src/test/resources/3_0/inject-operation-vendor-extensions.yaml", + Map.of(INTERFACE_ONLY, true), + new HashMap<>(), + configurator -> { + // operation-level injection drives the request-body annotation; parameter-level + // injection annotates the path param, both without editing the spec + configurator.addInjectOperationVendorExtension( + "createEmployee.x-request-body-extra-annotation", "@com.example.MyValidation"); + configurator.addInjectOperationVendorExtension( + "createEmployee.orgId.x-field-extra-annotation", "@com.example.ValidOrgId"); + }); + + Path employeesApi = files.get("OrgsApi.kt").toPath(); + assertFileContains(employeesApi, + "@com.example.MyValidation ", + "@com.example.ValidOrgId "); + + // selectivity: only createEmployee is annotated, not createEmployeePlain, even though + // both reference the same shared Employee model and OrgId parameter schema + String content = Files.readString(employeesApi); + assertThat(content.split("@com.example.MyValidation", -1).length - 1).isEqualTo(1); + assertThat(content.split("@com.example.ValidOrgId", -1).length - 1).isEqualTo(1); + } + private Map generateFromContract(String url) throws IOException { return generateFromContract(url, new HashMap<>(), new HashMap<>()); } diff --git a/modules/openapi-generator/src/test/resources/3_0/inject-operation-vendor-extensions.yaml b/modules/openapi-generator/src/test/resources/3_0/inject-operation-vendor-extensions.yaml new file mode 100644 index 000000000000..31132e865f03 --- /dev/null +++ b/modules/openapi-generator/src/test/resources/3_0/inject-operation-vendor-extensions.yaml @@ -0,0 +1,55 @@ +openapi: 3.0.3 +info: + title: inject operation vendor extensions + version: 1.0.0 +paths: + /orgs/{orgId}/employees: + post: + tags: + - employee + operationId: createEmployee + parameters: + - name: orgId + in: path + required: true + schema: + $ref: '#/components/schemas/OrgId' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Employee' + responses: + '201': + description: created + /orgs/{orgId}/employees/plain: + post: + tags: + - employee + operationId: createEmployeePlain + parameters: + - name: orgId + in: path + required: true + schema: + $ref: '#/components/schemas/OrgId' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Employee' + responses: + '201': + description: created +components: + schemas: + OrgId: + type: string + format: uuid + Employee: + type: object + properties: + name: + type: string From f8cc18c433b458fac888726b9f2f6adf63875fe3 Mon Sep 17 00:00:00 2001 From: Jachym Metlicka Date: Wed, 26 Aug 2026 12:41:50 +0200 Subject: [PATCH 06/20] test: cover requestBody-object and reusable requestBodies extra-annotation placements Adds regression coverage proving x-field-extra-annotation declared on the inline requestBody object and on a reusable components.requestBodies object renders on the generated body parameter in java-spring and kotlin-spring. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../java/spring/SpringCodegenTest.java | 22 ++++++++- .../spring/KotlinSpringServerCodegenTest.java | 8 ++++ .../3_0/request-body-extra-annotation.yaml | 47 +++++++++++++++++++ 3 files changed, 76 insertions(+), 1 deletion(-) diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/java/spring/SpringCodegenTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/java/spring/SpringCodegenTest.java index 2314d3a00f1d..2a657cb654db 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/java/spring/SpringCodegenTest.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/java/spring/SpringCodegenTest.java @@ -4009,7 +4009,27 @@ public void testRequestBodyExtraAnnotation() throws IOException { .assertParameter("employee") .assertParameterAnnotations() .doesNotContainWithName("com.example.MyValidation") - .doesNotContainWithName("com.example.AuditLogged"); + .doesNotContainWithName("com.example.AuditLogged") + .toParameter().toMethod().toFileAssert() + // annotation placed directly on the inline requestBody object renders before the body param + .assertMethod("createEmployeeInlineAnnotation") + .assertParameter("employee") + .assertParameterAnnotations() + .containsWithName("com.example.InlineBodyValidation") + .containsWithName("RequestBody") + .toParameter().toMethod().toFileAssert() + // reusable components.requestBodies annotation applies to every operation that refs it + .assertMethod("createEmployeeReusableA") + .assertParameter("employee") + .assertParameterAnnotations() + .containsWithName("com.example.ReusableBodyValidation") + .containsWithName("com.example.ReusableAuditLogged") + .toParameter().toMethod().toFileAssert() + .assertMethod("createEmployeeReusableB") + .assertParameter("employee") + .assertParameterAnnotations() + .containsWithName("com.example.ReusableBodyValidation") + .containsWithName("com.example.ReusableAuditLogged"); } @Test diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/kotlin/spring/KotlinSpringServerCodegenTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/kotlin/spring/KotlinSpringServerCodegenTest.java index f93ce438e5d4..9d61a6ef586a 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/kotlin/spring/KotlinSpringServerCodegenTest.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/kotlin/spring/KotlinSpringServerCodegenTest.java @@ -4520,6 +4520,14 @@ public void testRequestBodyExtraAnnotation() throws IOException { String content = Files.readString(employeeApi); int myValidationCount = content.split("@com.example.MyValidation", -1).length - 1; assertThat(myValidationCount).isEqualTo(2); + + // annotation placed directly on the inline requestBody object renders before the body binding + assertFileContains(employeeApi, "@com.example.InlineBodyValidation "); + // reusable components.requestBodies annotation applies to every operation that refs it (2 ops) + assertFileContains(employeeApi, + "@com.example.ReusableBodyValidation ", + "@com.example.ReusableAuditLogged "); + assertThat(content.split("@com.example.ReusableBodyValidation", -1).length - 1).isEqualTo(2); } @Test diff --git a/modules/openapi-generator/src/test/resources/3_0/request-body-extra-annotation.yaml b/modules/openapi-generator/src/test/resources/3_0/request-body-extra-annotation.yaml index 554624bcf416..f42f48bddccf 100644 --- a/modules/openapi-generator/src/test/resources/3_0/request-body-extra-annotation.yaml +++ b/modules/openapi-generator/src/test/resources/3_0/request-body-extra-annotation.yaml @@ -49,7 +49,54 @@ paths: responses: '201': description: created + /employees/inline-annotation: + post: + tags: + - employee + operationId: createEmployeeInlineAnnotation + # annotation placed directly on the requestBody object (per-operation, selective) + requestBody: + x-field-extra-annotation: "@com.example.InlineBodyValidation" + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Employee' + responses: + '201': + description: created + /employees/reusable-a: + post: + tags: + - employee + operationId: createEmployeeReusableA + requestBody: + $ref: '#/components/requestBodies/AnnotatedEmployeeBody' + responses: + '201': + description: created + /employees/reusable-b: + post: + tags: + - employee + operationId: createEmployeeReusableB + requestBody: + $ref: '#/components/requestBodies/AnnotatedEmployeeBody' + responses: + '201': + description: created components: + requestBodies: + # reusable request body: the annotation applies to every operation that $refs it + AnnotatedEmployeeBody: + x-field-extra-annotation: + - "@com.example.ReusableBodyValidation" + - "@com.example.ReusableAuditLogged" + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Employee' schemas: Employee: type: object From 22a9d98cfac0cd335a5629971beaaf036f18a990 Mon Sep 17 00:00:00 2001 From: Jachym Metlicka Date: Wed, 26 Aug 2026 14:10:53 +0200 Subject: [PATCH 07/20] Address review findings on vendor-extension work - GeneratorSettings: include injectModelVendorExtensions and injectOperationVendorExtensions in equals() and hashCode() so configs differing only in these maps are no longer treated as equal. - JavaCamelServerCodegen: stop advertising x-request-body-extra-annotation, which its Camel REST DSL templates never render; regenerate java-camel docs. - DefaultCodegen: move the shared parameter vendor-extension normalization (normalizeOperationParameterVendorExtensions) up from AbstractJavaCodegen and reuse it from KotlinSpringServerCodegen, removing the duplicate helpers. - DefaultCodegen.injectOperationVendorExtensions: match the spec-authored operationId (operationIdOriginal) when present, falling back to the generated operationId only when the spec omits one. - Correct docs/help to describe the parameter key segment as the spec name (paramBaseName / baseName) to match the actual matching logic. - Tests: add snake_case operationId injection test and a JavaCamel supported vendor-extension test. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/generators/java-camel.md | 1 - .../openapitools/codegen/cmd/Generate.java | 2 +- .../codegen/config/GeneratorSettings.java | 8 +++- .../openapitools/codegen/DefaultCodegen.java | 47 +++++++++++++++++-- .../languages/AbstractJavaCodegen.java | 30 ------------ .../languages/JavaCamelServerCodegen.java | 10 ++++ .../languages/KotlinSpringServerCodegen.java | 31 +----------- .../codegen/DefaultCodegenTest.java | 18 +++++++ .../languages/JavaCamelServerCodegenTest.java | 21 +++++++++ .../inject-operation-vendor-extensions.yaml | 20 ++++++++ 10 files changed, 119 insertions(+), 69 deletions(-) create mode 100644 modules/openapi-generator/src/test/java/org/openapitools/codegen/languages/JavaCamelServerCodegenTest.java diff --git a/docs/generators/java-camel.md b/docs/generators/java-camel.md index 7732d1dbe193..10213f64b802 100644 --- a/docs/generators/java-camel.md +++ b/docs/generators/java-camel.md @@ -152,7 +152,6 @@ These options may be applied as additional-properties (cli) or configOptions (pl |x-class-extra-annotation|Custom annotation(s) to be added to model; accepts a string or list of strings|MODEL|null |x-field-extra-annotation|Custom annotation(s) to be added to property; accepts a string or list of strings|FIELD, OPERATION_PARAMETER|null |x-operation-extra-annotation|Custom annotation(s) to be added to operation; accepts a string or list of strings|OPERATION|null -|x-request-body-extra-annotation|Custom annotation(s) to be added to the request body parameter; accepts a string or list of strings. Declared on the operation because the request body typically `$ref`s a shared model (so the annotation cannot be placed next to the `$ref`); the value is rendered by being merged into the body parameter's `x-field-extra-annotation`|OPERATION|null |x-spring-paginated|Add `org.springframework.data.domain.Pageable` to controller method. Can be used to handle `page`, `size` and `sort` query parameters. If these query parameters are also specified in the operation spec, they will be removed from the controller method as their values can be obtained from the `Pageable` object. Applies when `library=spring-boot` or `library=spring-cloud`; ignored for other (client) libraries.|OPERATION|false |x-version-param|Marker property that tells that this parameter would be used for endpoint versioning. Applicable for headers & query params. true/false|OPERATION_PARAMETER|null |x-pattern-message|Add this property whenever you need to customize the invalidation error message for the regex pattern of a variable|FIELD, OPERATION_PARAMETER|null diff --git a/modules/openapi-generator-cli/src/main/java/org/openapitools/codegen/cmd/Generate.java b/modules/openapi-generator-cli/src/main/java/org/openapitools/codegen/cmd/Generate.java index 8acf57808516..4fadcb966d7e 100644 --- a/modules/openapi-generator-cli/src/main/java/org/openapitools/codegen/cmd/Generate.java +++ b/modules/openapi-generator-cli/src/main/java/org/openapitools/codegen/cmd/Generate.java @@ -273,7 +273,7 @@ public class Generate extends OpenApiGeneratorCommand { title = "inject operation vendor extensions", description = "injects vendor extensions into operations or their parameters." + " Operation-level format: operationId.x-extension-name=value." - + " Parameter-level format: operationId.paramName.x-extension-name=value." + + " Parameter-level format: operationId.paramBaseName.x-extension-name=value." + " You can also have multiple occurrences of this option.") private List injectOperationVendorExtensions = new ArrayList<>(); diff --git a/modules/openapi-generator-core/src/main/java/org/openapitools/codegen/config/GeneratorSettings.java b/modules/openapi-generator-core/src/main/java/org/openapitools/codegen/config/GeneratorSettings.java index 71459f41d165..2697306e8866 100644 --- a/modules/openapi-generator-core/src/main/java/org/openapitools/codegen/config/GeneratorSettings.java +++ b/modules/openapi-generator-core/src/main/java/org/openapitools/codegen/config/GeneratorSettings.java @@ -341,7 +341,7 @@ public Map getInjectModelVendorExtensions() { /** * Gets inject operation vendor extensions. * - * @return a map of operationId.x-extension-name or operationId.paramName.x-extension-name to extension value + * @return a map of operationId.x-extension-name or operationId.paramBaseName.x-extension-name to extension value */ public Map getInjectOperationVendorExtensions() { return injectOperationVendorExtensions; @@ -1267,7 +1267,7 @@ public Builder withInjectOperationVendorExtensions(Map injectOpe /** * Sets a single {@code injectOperationVendorExtension} and returns a reference to this Builder so that the methods can be chained together. * - * @param key A key in the format operationId.x-extension-name or operationId.paramName.x-extension-name + * @param key A key in the format operationId.x-extension-name or operationId.paramBaseName.x-extension-name * @param value The extension value * @return a reference to this Builder */ @@ -1525,6 +1525,8 @@ public boolean equals(Object o) { Objects.equals(getModelNameMappings(), that.getModelNameMappings()) && Objects.equals(getEnumNameMappings(), that.getEnumNameMappings()) && Objects.equals(getOperationIdNameMappings(), that.getOperationIdNameMappings()) && + Objects.equals(getInjectModelVendorExtensions(), that.getInjectModelVendorExtensions()) && + Objects.equals(getInjectOperationVendorExtensions(), that.getInjectOperationVendorExtensions()) && Objects.equals(getOpenapiNormalizer(), that.getOpenapiNormalizer()) && Objects.equals(getLanguageSpecificPrimitives(), that.getLanguageSpecificPrimitives()) && Objects.equals(getOpenapiGeneratorIgnoreList(), that.getOpenapiGeneratorIgnoreList()) && @@ -1564,6 +1566,8 @@ public int hashCode() { getModelNameMappings(), getEnumNameMappings(), getOperationIdNameMappings(), + getInjectModelVendorExtensions(), + getInjectOperationVendorExtensions(), getOpenapiNormalizer(), getLanguageSpecificPrimitives(), getOpenapiGeneratorIgnoreList(), 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 1e04aaf9c6c3..81c526884b9d 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 @@ -5147,8 +5147,10 @@ public CodegenOperation fromOperation(String path, /** * Injects vendor extensions supplied via {@code injectOperationVendorExtensions} onto the given * operation or its parameters. Keys are dotted: {@code operationId.x-extension-name} targets the - * operation, while {@code operationId.paramName.x-extension-name} targets a parameter matched by - * its spec name ({@code baseName}). This runs during operation construction, before + * operation, while {@code operationId.paramBaseName.x-extension-name} targets a parameter matched + * by its spec name ({@code baseName}). The {@code operationId} segment is matched against the + * spec-authored operationId when present, falling back to the generated operationId only when the + * spec omits one. This runs during operation construction, before * {@code postProcessOperationsWithModels}, so an injected operation-level extension is available * to any later normalization of that extension (for example, request-body annotation handling in * the Spring generators). A parameter is represented by more than one object across the @@ -5157,20 +5159,26 @@ public CodegenOperation fromOperation(String path, * @param op the operation to update */ private void injectOperationVendorExtensions(CodegenOperation op) { - if (injectOperationVendorExtensions.isEmpty() || op.operationId == null) { + if (injectOperationVendorExtensions.isEmpty()) { + return; + } + // Prefer the spec-authored operationId; only fall back to the generated one when the spec + // omits operationId (in which case operationIdOriginal is null). + String matchOperationId = op.operationIdOriginal != null ? op.operationIdOriginal : op.operationId; + if (matchOperationId == null) { return; } for (Map.Entry extEntry : injectOperationVendorExtensions.entrySet()) { String[] parts = extEntry.getKey().split("\\.", 3); if (parts.length < 2) continue; - if (!parts[0].equals(op.operationId)) continue; + if (!parts[0].equals(matchOperationId)) continue; String extensionValue = extEntry.getValue(); if (parts.length == 2) { // operation-level extension: operationId.x-extension-name op.vendorExtensions.put(parts[1], extensionValue); } else { - // parameter-level extension: operationId.paramName.x-extension-name + // parameter-level extension: operationId.paramBaseName.x-extension-name String paramBaseName = parts[1]; String extensionName = parts[2]; List> allParameterLists = Arrays.asList( @@ -7452,6 +7460,35 @@ public static void normalizeVendorExtensionWithStringList(Map ve vendorExtensions.put(name, new ArrayList<>(getObjectAsStringList(vendorExtensions.get(name)))); } + /** + * Normalizes a vendor extension across all of an operation's parameter collections into a mutable + * {@code List}, so that a value authored as either a single string or a list is handled + * uniformly and further values can be appended. The same parameter can appear in several of the + * operation's parameter collections, so they are de-duplicated by identity before being updated. + * + * @param operation operation whose parameters should be updated + * @param name vendor extension name + */ + protected void normalizeOperationParameterVendorExtensions(CodegenOperation operation, String name) { + Set parameters = Collections.newSetFromMap(new IdentityHashMap<>()); + parameters.addAll(operation.allParams); + parameters.addAll(operation.bodyParams); + parameters.addAll(operation.pathParams); + parameters.addAll(operation.queryParams); + parameters.addAll(operation.headerParams); + parameters.addAll(operation.implicitHeadersParams); + parameters.addAll(operation.constantParams); + parameters.addAll(operation.formParams); + parameters.addAll(operation.cookieParams); + parameters.addAll(operation.requiredParams); + parameters.addAll(operation.optionalParams); + parameters.addAll(operation.requiredAndNotNullableParams); + parameters.addAll(operation.notNullableParams); + for (CodegenParameter parameter : parameters) { + normalizeVendorExtensionWithStringList(parameter.vendorExtensions, name); + } + } + /** * Merges the values of an operation-level vendor extension into a list-valued vendor extension on * each request body parameter. This lets an annotation authored on the operation be applied to the diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/AbstractJavaCodegen.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/AbstractJavaCodegen.java index 554f98183fc7..61cced77eb7b 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/AbstractJavaCodegen.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/AbstractJavaCodegen.java @@ -2303,36 +2303,6 @@ private void normalizeModelPropertyVendorExtensions(CodegenModel model, String n } } - /** - * Normalizes an operation parameter vendor extension across all parameter collections. - * In this context, normalization means converting a missing value, a single string, or a list value - * into a predictable mutable {@code List} on each parameter. The same parameter can appear in - * several operation collections, so the collections are de-duplicated before updating the extension map. - * - * @param operation operation whose parameters should be updated - * @param name vendor extension name - */ - protected void normalizeOperationParameterVendorExtensions(CodegenOperation operation, String name) { - Set parameters = Collections.newSetFromMap(new IdentityHashMap<>()); - parameters.addAll(operation.allParams); - parameters.addAll(operation.bodyParams); - parameters.addAll(operation.pathParams); - parameters.addAll(operation.queryParams); - parameters.addAll(operation.headerParams); - parameters.addAll(operation.implicitHeadersParams); - parameters.addAll(operation.constantParams); - parameters.addAll(operation.formParams); - parameters.addAll(operation.cookieParams); - parameters.addAll(operation.requiredParams); - parameters.addAll(operation.optionalParams); - parameters.addAll(operation.requiredAndNotNullableParams); - parameters.addAll(operation.notNullableParams); - - for (CodegenParameter parameter : parameters) { - normalizeVendorExtensionWithStringList(parameter.vendorExtensions, name); - } - } - @Override public void preprocessOpenAPI(OpenAPI openAPI) { super.preprocessOpenAPI(openAPI); diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/JavaCamelServerCodegen.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/JavaCamelServerCodegen.java index 831c083aa0b1..eb8a34859149 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/JavaCamelServerCodegen.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/JavaCamelServerCodegen.java @@ -20,6 +20,7 @@ import org.openapitools.codegen.CliOption; import org.openapitools.codegen.CodegenOperation; import org.openapitools.codegen.SupportingFile; +import org.openapitools.codegen.VendorExtension; import org.openapitools.codegen.languages.features.BeanValidationFeatures; import org.openapitools.codegen.languages.features.OptionalFeatures; import org.openapitools.codegen.languages.features.PerformBeanValidationFeatures; @@ -190,4 +191,13 @@ private T manageAdditionalProperty(String propertyName, T defaultValue) { private Boolean manageBooleanAdditionalProperty(String propertyValue) { return Boolean.parseBoolean(propertyValue); } + + @Override + public List getSupportedVendorExtensions() { + List extensions = super.getSupportedVendorExtensions(); + // The Camel REST DSL templates do not render a request-body parameter annotation, + // so this generator does not support the extension inherited from SpringCodegen. + extensions.remove(VendorExtension.X_REQUEST_BODY_EXTRA_ANNOTATION); + return extensions; + } } diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/KotlinSpringServerCodegen.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/KotlinSpringServerCodegen.java index 423ebf24339e..c0625a9f2215 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/KotlinSpringServerCodegen.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/KotlinSpringServerCodegen.java @@ -1700,35 +1700,6 @@ public ModelsMap postProcessModelsEnum(ModelsMap objs) { return objs; } - /** - * Normalizes a vendor extension on all of an operation's parameters into a mutable - * {@code List}, so that a value authored as either a single string or a list is - * handled uniformly and further values can be appended. The same parameter can appear in - * several of the operation's parameter collections, so they are de-duplicated by identity. - * - * @param operation operation whose parameters should be updated - * @param name vendor extension name - */ - private void normalizeParameterVendorExtensionWithStringList(CodegenOperation operation, String name) { - Set parameters = Collections.newSetFromMap(new IdentityHashMap<>()); - parameters.addAll(operation.allParams); - parameters.addAll(operation.bodyParams); - parameters.addAll(operation.pathParams); - parameters.addAll(operation.queryParams); - parameters.addAll(operation.headerParams); - parameters.addAll(operation.implicitHeadersParams); - parameters.addAll(operation.constantParams); - parameters.addAll(operation.formParams); - parameters.addAll(operation.cookieParams); - parameters.addAll(operation.requiredParams); - parameters.addAll(operation.optionalParams); - parameters.addAll(operation.requiredAndNotNullableParams); - parameters.addAll(operation.notNullableParams); - for (CodegenParameter parameter : parameters) { - normalizeVendorExtensionWithStringList(parameter.vendorExtensions, name); - } - } - @Override public OperationsMap postProcessOperationsWithModels(OperationsMap objs, List allModels) { OperationMap operations = objs.getOperations(); @@ -1737,7 +1708,7 @@ public OperationsMap postProcessOperationsWithModels(OperationsMap objs, List { // Normalize x-field-extra-annotation on each parameter (including the body) so a // string or list value is handled uniformly and further values can be appended. - normalizeParameterVendorExtensionWithStringList(operation, VendorExtension.X_FIELD_EXTRA_ANNOTATION.getName()); + normalizeOperationParameterVendorExtensions(operation, VendorExtension.X_FIELD_EXTRA_ANNOTATION.getName()); // x-request-body-extra-annotation is authored on the operation (the request body usually // $refs a shared model, so it cannot be placed next to the $ref). Merge its values into the // body parameter's x-field-extra-annotation so it renders through the same template path. diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/DefaultCodegenTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/DefaultCodegenTest.java index 8181b496fdc7..81f0ee5c8727 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/DefaultCodegenTest.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/DefaultCodegenTest.java @@ -127,6 +127,24 @@ public void testInjectOperationVendorExtensions() { assertEquals(orgIdPath.vendorExtensions.get("x-field-extra-annotation"), "@com.example.ValidOrgId"); } + @Test + public void testInjectOperationVendorExtensionsMatchesSpecOperationId() { + final DefaultCodegen codegen = new DefaultCodegen(); + final OpenAPI openApi = TestUtils.parseFlattenSpec("src/test/resources/3_0/inject-operation-vendor-extensions.yaml"); + codegen.setOpenAPI(openApi); + // key uses the spec-authored (snake_case) operationId, not the generated/camelized one + codegen.injectOperationVendorExtensions().put("create_employee_snake.x-request-body-extra-annotation", "@com.example.MyValidation"); + // the generated/camelized operationId must not match when the spec provides an operationId + codegen.injectOperationVendorExtensions().put("createEmployeeSnake.x-foo", "bar"); + + PathItem path = openApi.getPaths().get("/orgs/{orgId}/employees/snake"); + CodegenOperation operation = codegen.fromOperation("/orgs/{orgId}/employees/snake", "post", path.getPost(), path.getServers()); + + assertEquals(operation.operationIdOriginal, "create_employee_snake"); + assertEquals(operation.vendorExtensions.get("x-request-body-extra-annotation"), "@com.example.MyValidation"); + assertNull(operation.vendorExtensions.get("x-foo")); + } + @Test public void testHasBodyParameter() { final Schema refSchema = new Schema<>().$ref("#/components/schemas/Pet"); diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/languages/JavaCamelServerCodegenTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/languages/JavaCamelServerCodegenTest.java new file mode 100644 index 000000000000..6610e1863b7e --- /dev/null +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/languages/JavaCamelServerCodegenTest.java @@ -0,0 +1,21 @@ +package org.openapitools.codegen.languages; + +import org.openapitools.codegen.VendorExtension; +import org.testng.annotations.Test; + +import static org.testng.Assert.assertFalse; +import static org.testng.Assert.assertTrue; + +public class JavaCamelServerCodegenTest { + + @Test + public void doesNotAdvertiseRequestBodyExtraAnnotation() { + // The Camel REST DSL templates do not render a request-body parameter annotation, so this + // generator must not advertise the extension it inherits from SpringCodegen. + assertFalse(new JavaCamelServerCodegen().getSupportedVendorExtensions() + .contains(VendorExtension.X_REQUEST_BODY_EXTRA_ANNOTATION)); + // SpringCodegen itself still supports it. + assertTrue(new SpringCodegen().getSupportedVendorExtensions() + .contains(VendorExtension.X_REQUEST_BODY_EXTRA_ANNOTATION)); + } +} diff --git a/modules/openapi-generator/src/test/resources/3_0/inject-operation-vendor-extensions.yaml b/modules/openapi-generator/src/test/resources/3_0/inject-operation-vendor-extensions.yaml index 31132e865f03..32f1982d460d 100644 --- a/modules/openapi-generator/src/test/resources/3_0/inject-operation-vendor-extensions.yaml +++ b/modules/openapi-generator/src/test/resources/3_0/inject-operation-vendor-extensions.yaml @@ -43,6 +43,26 @@ paths: responses: '201': description: created + /orgs/{orgId}/employees/snake: + post: + tags: + - employee + operationId: create_employee_snake + parameters: + - name: orgId + in: path + required: true + schema: + $ref: '#/components/schemas/OrgId' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Employee' + responses: + '201': + description: created components: schemas: OrgId: From 9f45ec5a491ecbc3e6e57aa8de1788adbd44f990 Mon Sep 17 00:00:00 2001 From: Jachym Metlicka Date: Wed, 26 Aug 2026 14:37:58 +0200 Subject: [PATCH 08/20] Handle blank operationId and fix test assertion order - DefaultCodegen.injectOperationVendorExtensions: treat a blank operationIdOriginal like a missing one and fall back to the generated operationId, so injection is not silently skipped when the spec declares an empty operationId. - DefaultCodegenTest: use expected-first argument order for JUnit assertEquals and add a regression test covering the blank-operationId fallback. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../openapitools/codegen/DefaultCodegen.java | 6 ++-- .../codegen/DefaultCodegenTest.java | 29 +++++++++++++++---- .../inject-operation-vendor-extensions.yaml | 20 +++++++++++++ 3 files changed, 47 insertions(+), 8 deletions(-) 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 81c526884b9d..c0ef963b840a 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 @@ -5163,9 +5163,9 @@ private void injectOperationVendorExtensions(CodegenOperation op) { return; } // Prefer the spec-authored operationId; only fall back to the generated one when the spec - // omits operationId (in which case operationIdOriginal is null). - String matchOperationId = op.operationIdOriginal != null ? op.operationIdOriginal : op.operationId; - if (matchOperationId == null) { + // omits operationId (in which case operationIdOriginal is null or blank). + String matchOperationId = StringUtils.isNotBlank(op.operationIdOriginal) ? op.operationIdOriginal : op.operationId; + if (StringUtils.isBlank(matchOperationId)) { return; } for (Map.Entry extEntry : injectOperationVendorExtensions.entrySet()) { diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/DefaultCodegenTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/DefaultCodegenTest.java index 81f0ee5c8727..da713d79d333 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/DefaultCodegenTest.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/DefaultCodegenTest.java @@ -37,6 +37,7 @@ import io.swagger.v3.oas.models.responses.ApiResponses; import io.swagger.v3.oas.models.security.SecurityScheme; import io.swagger.v3.parser.core.models.ParseOptions; +import org.apache.commons.lang3.StringUtils; import org.junit.jupiter.api.Assertions; import org.openapitools.codegen.config.CodegenConfigurator; import org.openapitools.codegen.config.GlobalSettings; @@ -115,16 +116,16 @@ public void testInjectOperationVendorExtensions() { CodegenOperation operation = codegen.fromOperation("/orgs/{orgId}/employees", "post", path.getPost(), path.getServers()); // operation-level extension landed on the operation - assertEquals(operation.vendorExtensions.get("x-request-body-extra-annotation"), "@com.example.MyValidation"); + assertEquals("@com.example.MyValidation", operation.vendorExtensions.get("x-request-body-extra-annotation")); assertNull(operation.vendorExtensions.get("x-foo")); // parameter-level extension landed on the matching parameter across collections CodegenParameter orgId = operation.allParams.stream() .filter(p -> "orgId".equals(p.baseName)).findFirst().orElseThrow(); - assertEquals(orgId.vendorExtensions.get("x-field-extra-annotation"), "@com.example.ValidOrgId"); + assertEquals("@com.example.ValidOrgId", orgId.vendorExtensions.get("x-field-extra-annotation")); CodegenParameter orgIdPath = operation.pathParams.stream() .filter(p -> "orgId".equals(p.baseName)).findFirst().orElseThrow(); - assertEquals(orgIdPath.vendorExtensions.get("x-field-extra-annotation"), "@com.example.ValidOrgId"); + assertEquals("@com.example.ValidOrgId", orgIdPath.vendorExtensions.get("x-field-extra-annotation")); } @Test @@ -140,11 +141,29 @@ public void testInjectOperationVendorExtensionsMatchesSpecOperationId() { PathItem path = openApi.getPaths().get("/orgs/{orgId}/employees/snake"); CodegenOperation operation = codegen.fromOperation("/orgs/{orgId}/employees/snake", "post", path.getPost(), path.getServers()); - assertEquals(operation.operationIdOriginal, "create_employee_snake"); - assertEquals(operation.vendorExtensions.get("x-request-body-extra-annotation"), "@com.example.MyValidation"); + assertEquals("create_employee_snake", operation.operationIdOriginal); + assertEquals("@com.example.MyValidation", operation.vendorExtensions.get("x-request-body-extra-annotation")); assertNull(operation.vendorExtensions.get("x-foo")); } + @Test + public void testInjectOperationVendorExtensionsFallsBackToGeneratedIdWhenBlank() { + final DefaultCodegen codegen = new DefaultCodegen(); + final OpenAPI openApi = TestUtils.parseFlattenSpec("src/test/resources/3_0/inject-operation-vendor-extensions.yaml"); + codegen.setOpenAPI(openApi); + + PathItem path = openApi.getPaths().get("/orgs/{orgId}/employees/blank"); + // first pass discovers the generated operationId (the spec leaves operationId blank) + CodegenOperation discovered = codegen.fromOperation("/orgs/{orgId}/employees/blank", "post", path.getPost(), path.getServers()); + assertTrue(discovered.operationIdOriginal == null || discovered.operationIdOriginal.isEmpty()); + assertTrue(StringUtils.isNotBlank(discovered.operationId)); + + // injecting via the generated operationId must apply, since the blank original cannot be matched + codegen.injectOperationVendorExtensions().put(discovered.operationId + ".x-request-body-extra-annotation", "@com.example.MyValidation"); + CodegenOperation operation = codegen.fromOperation("/orgs/{orgId}/employees/blank", "post", path.getPost(), path.getServers()); + assertEquals("@com.example.MyValidation", operation.vendorExtensions.get("x-request-body-extra-annotation")); + } + @Test public void testHasBodyParameter() { final Schema refSchema = new Schema<>().$ref("#/components/schemas/Pet"); diff --git a/modules/openapi-generator/src/test/resources/3_0/inject-operation-vendor-extensions.yaml b/modules/openapi-generator/src/test/resources/3_0/inject-operation-vendor-extensions.yaml index 32f1982d460d..b59f3c24ad23 100644 --- a/modules/openapi-generator/src/test/resources/3_0/inject-operation-vendor-extensions.yaml +++ b/modules/openapi-generator/src/test/resources/3_0/inject-operation-vendor-extensions.yaml @@ -63,6 +63,26 @@ paths: responses: '201': description: created + /orgs/{orgId}/employees/blank: + post: + tags: + - employee + operationId: "" + parameters: + - name: orgId + in: path + required: true + schema: + $ref: '#/components/schemas/OrgId' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Employee' + responses: + '201': + description: created components: schemas: OrgId: From d0aafd2cb2c84cae015ec045796928f2948e00de Mon Sep 17 00:00:00 2001 From: Jachym Metlicka Date: Wed, 26 Aug 2026 14:51:50 +0200 Subject: [PATCH 09/20] Assert operationId invariant instead of silent blank guard op.operationId is always non-blank at injection time because getOrGenerateOperationId synthesizes one from the path and HTTP method when the spec omits or blanks it. Replace the unreachable isBlank(matchOperationId) early-return with an explicit Objects.requireNonNull on op.operationId so the invariant is documented and a future regression fails loudly instead of silently dropping injected extensions. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../main/java/org/openapitools/codegen/DefaultCodegen.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 c0ef963b840a..7fcfc04c3d9f 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 @@ -5162,12 +5162,12 @@ private void injectOperationVendorExtensions(CodegenOperation op) { if (injectOperationVendorExtensions.isEmpty()) { return; } + // The operation always has a non-blank operationId at this point: getOrGenerateOperationId + // synthesizes one from the path and HTTP method when the spec omits or blanks it. + Objects.requireNonNull(op.operationId, "operationId must be set before injecting operation vendor extensions"); // Prefer the spec-authored operationId; only fall back to the generated one when the spec // omits operationId (in which case operationIdOriginal is null or blank). String matchOperationId = StringUtils.isNotBlank(op.operationIdOriginal) ? op.operationIdOriginal : op.operationId; - if (StringUtils.isBlank(matchOperationId)) { - return; - } for (Map.Entry extEntry : injectOperationVendorExtensions.entrySet()) { String[] parts = extEntry.getKey().split("\\.", 3); if (parts.length < 2) continue; From 57e96c9519116cb90181be621cf65555e0980d39 Mon Sep 17 00:00:00 2001 From: Jachym Metlicka Date: Wed, 26 Aug 2026 14:53:56 +0200 Subject: [PATCH 10/20] Document getOrGenerateOperationId never returns null or blank Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/main/java/org/openapitools/codegen/DefaultCodegen.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 7fcfc04c3d9f..6dd59dd02e39 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 @@ -6032,7 +6032,7 @@ protected boolean isReservedWord(String word) { * @param operation the operation object * @param path the path of the operation * @param httpMethod the HTTP method of the operation - * @return the (generated) operationId + * @return the (generated) operationId; never null or blank */ protected String getOrGenerateOperationId(Operation operation, String path, String httpMethod) { String operationId = operation.getOperationId(); From 87fec9e3757b0587471e3d9c211fbbb3eb8af84a Mon Sep 17 00:00:00 2001 From: Jachym Metlicka Date: Wed, 26 Aug 2026 15:39:18 +0200 Subject: [PATCH 11/20] test(spring): compile-verify extra-annotation features in existing samples Add compile coverage for the Spring extra-annotation features by copying the shared petstore specs, adding the extension annotations to the copies, and repointing four existing (already-compiled) samples to them. The originals are left untouched, so there is no ripple to the 140+ other configs and no new build target. Copied specs (originals unchanged): - 3_0/spring/petstore-with-fake-endpoints-models-for-testing-extra-annotation.yaml - 3_0/kotlin/petstore-with-extra-annotation.yaml Repointed samples (cover java/kotlin x reactive/non-reactive): - springboot-useoptional (java, useOptional body branch) - springboot-reactive (java, Mono/Flux body branch) - kotlin-springboot-delegate (kotlin, non-reactive) - kotlin-springboot-reactive (kotlin, Flow/suspend body branch) Exercised, all verified to compile locally (mvn + gradle): - operation-level x-request-body-extra-annotation on addPet (body is a $ref), with updatePet left un-annotated to prove per-operation selectivity - param x-field-extra-annotation on path (getPetById), list-valued query (findPetsByStatus, two annotations), and form (uploadFile) params - kotlin references short names imported via x-extra-imports; java uses fully-qualified Spring @NonNull (java-spring has no x-extra-imports support) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- bin/configs/kotlin-spring-boot-delegate.yaml | 2 +- bin/configs/kotlin-spring-boot-reactive.yaml | 2 +- bin/configs/spring-boot-reactive.yaml | 2 +- bin/configs/spring-boot-useoptional.yaml | 2 +- .../petstore-with-extra-annotation.yaml | 753 ++++++ ...s-models-for-testing-extra-annotation.yaml | 2101 +++++++++++++++++ .../kotlin/org/openapitools/api/PetApi.kt | 10 +- .../src/main/resources/openapi.yaml | 12 + .../org/openapitools/api/PetApiController.kt | 10 +- .../src/main/resources/openapi.yaml | 12 + .../java/org/openapitools/api/PetApi.java | 6 +- .../src/main/resources/openapi.yaml | 3 + .../java/org/openapitools/api/PetApi.java | 6 +- .../src/main/resources/openapi.yaml | 3 + 14 files changed, 2906 insertions(+), 18 deletions(-) create mode 100644 modules/openapi-generator/src/test/resources/3_0/kotlin/petstore-with-extra-annotation.yaml create mode 100644 modules/openapi-generator/src/test/resources/3_0/spring/petstore-with-fake-endpoints-models-for-testing-extra-annotation.yaml diff --git a/bin/configs/kotlin-spring-boot-delegate.yaml b/bin/configs/kotlin-spring-boot-delegate.yaml index c09fcf345fdb..6253940f8d24 100644 --- a/bin/configs/kotlin-spring-boot-delegate.yaml +++ b/bin/configs/kotlin-spring-boot-delegate.yaml @@ -1,7 +1,7 @@ generatorName: kotlin-spring outputDir: samples/server/petstore/kotlin-springboot-delegate library: spring-boot -inputSpec: modules/openapi-generator/src/test/resources/3_0/petstore.yaml +inputSpec: modules/openapi-generator/src/test/resources/3_0/kotlin/petstore-with-extra-annotation.yaml templateDir: modules/openapi-generator/src/main/resources/kotlin-spring additionalProperties: generateJsonIncludeAnnotations: "true" diff --git a/bin/configs/kotlin-spring-boot-reactive.yaml b/bin/configs/kotlin-spring-boot-reactive.yaml index a8272859ce07..1be36b4ef027 100644 --- a/bin/configs/kotlin-spring-boot-reactive.yaml +++ b/bin/configs/kotlin-spring-boot-reactive.yaml @@ -1,7 +1,7 @@ generatorName: kotlin-spring outputDir: samples/server/petstore/kotlin-springboot-reactive library: spring-boot -inputSpec: modules/openapi-generator/src/test/resources/3_0/petstore.yaml +inputSpec: modules/openapi-generator/src/test/resources/3_0/kotlin/petstore-with-extra-annotation.yaml templateDir: modules/openapi-generator/src/main/resources/kotlin-spring additionalProperties: generateJsonIncludeAnnotations: "true" diff --git a/bin/configs/spring-boot-reactive.yaml b/bin/configs/spring-boot-reactive.yaml index 338a4ebaeedf..71ad6c034831 100644 --- a/bin/configs/spring-boot-reactive.yaml +++ b/bin/configs/spring-boot-reactive.yaml @@ -1,6 +1,6 @@ generatorName: spring outputDir: samples/server/petstore/springboot-reactive -inputSpec: modules/openapi-generator/src/test/resources/3_0/spring/petstore-with-fake-endpoints-models-for-testing.yaml +inputSpec: modules/openapi-generator/src/test/resources/3_0/spring/petstore-with-fake-endpoints-models-for-testing-extra-annotation.yaml templateDir: modules/openapi-generator/src/main/resources/JavaSpring additionalProperties: generateJsonIncludeAnnotations: "true" diff --git a/bin/configs/spring-boot-useoptional.yaml b/bin/configs/spring-boot-useoptional.yaml index 428935c9bdd7..0342f84e0f46 100644 --- a/bin/configs/spring-boot-useoptional.yaml +++ b/bin/configs/spring-boot-useoptional.yaml @@ -1,6 +1,6 @@ generatorName: spring outputDir: samples/server/petstore/springboot-useoptional -inputSpec: modules/openapi-generator/src/test/resources/3_0/spring/petstore-with-fake-endpoints-models-for-testing.yaml +inputSpec: modules/openapi-generator/src/test/resources/3_0/spring/petstore-with-fake-endpoints-models-for-testing-extra-annotation.yaml templateDir: modules/openapi-generator/src/main/resources/JavaSpring additionalProperties: generateJsonIncludeAnnotations: "true" diff --git a/modules/openapi-generator/src/test/resources/3_0/kotlin/petstore-with-extra-annotation.yaml b/modules/openapi-generator/src/test/resources/3_0/kotlin/petstore-with-extra-annotation.yaml new file mode 100644 index 000000000000..a3c6c88fbd5f --- /dev/null +++ b/modules/openapi-generator/src/test/resources/3_0/kotlin/petstore-with-extra-annotation.yaml @@ -0,0 +1,753 @@ +openapi: 3.0.0 +servers: + - url: 'http://petstore.swagger.io/v2' +info: + description: >- + This is a sample server Petstore server. For this sample, you can use the api key + `special-key` to test the authorization filters. + version: 1.0.0 + title: OpenAPI Petstore + license: + name: Apache-2.0 + url: 'https://www.apache.org/licenses/LICENSE-2.0.html' +tags: + - name: pet + description: Everything about your Pets + - name: store + description: Access to Petstore orders + - name: user + description: Operations about user +paths: + /pet: + post: + tags: + - pet + summary: Add a new pet to the store + description: '' + operationId: addPet + x-extra-imports: org.springframework.lang.NonNull + x-request-body-extra-annotation: "@NonNull" + responses: + '200': + description: successful operation + content: + application/xml: + schema: + $ref: '#/components/schemas/Pet' + application/json: + schema: + $ref: '#/components/schemas/Pet' + '405': + description: Invalid input + security: + - petstore_auth: + - 'write:pets' + - 'read:pets' + requestBody: + $ref: '#/components/requestBodies/Pet' + put: + tags: + - pet + summary: Update an existing pet + description: '' + operationId: updatePet + externalDocs: + url: "http://petstore.swagger.io/v2/doc/updatePet" + description: "API documentation for the updatePet operation" + responses: + '200': + description: successful operation + content: + application/xml: + schema: + $ref: '#/components/schemas/Pet' + application/json: + schema: + $ref: '#/components/schemas/Pet' + '400': + description: Invalid ID supplied + '404': + description: Pet not found + '405': + description: Validation exception + security: + - petstore_auth: + - 'write:pets' + - 'read:pets' + requestBody: + $ref: '#/components/requestBodies/Pet' + /pet/findByStatus: + get: + tags: + - pet + summary: Finds Pets by status + description: Multiple status values can be provided with comma separated strings + operationId: findPetsByStatus + parameters: + - name: status + in: query + description: Status values that need to be considered for filter + required: true + style: form + explode: false + deprecated: true + x-extra-imports: + - org.springframework.lang.NonNull + - org.springframework.lang.Nullable + x-field-extra-annotation: + - "@NonNull" + - "@Nullable" + schema: + type: array + items: + type: string + enum: + - available + - pending + - sold + default: available + responses: + '200': + description: successful operation + content: + application/xml: + schema: + type: array + items: + $ref: '#/components/schemas/Pet' + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Pet' + '400': + description: Invalid status value + security: + - petstore_auth: + - 'read:pets' + /pet/findByTags: + get: + tags: + - pet + summary: Finds Pets by tags + description: >- + Multiple tags can be provided with comma separated strings. Use tag1, + tag2, tag3 for testing. + operationId: findPetsByTags + parameters: + - name: tags + in: query + description: Tags to filter by + required: true + style: form + explode: false + schema: + type: array + items: + type: string + responses: + '200': + description: successful operation + content: + application/xml: + schema: + type: array + items: + $ref: '#/components/schemas/Pet' + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Pet' + '400': + description: Invalid tag value + security: + - petstore_auth: + - 'read:pets' + deprecated: true + '/pet/{petId}': + get: + tags: + - pet + summary: Find pet by ID + description: Returns a single pet + operationId: getPetById + parameters: + - name: petId + in: path + description: ID of pet to return + required: true + x-extra-imports: org.springframework.lang.NonNull + x-field-extra-annotation: "@NonNull" + schema: + type: integer + format: int64 + responses: + '200': + description: successful operation + content: + application/xml: + schema: + $ref: '#/components/schemas/Pet' + application/json: + schema: + $ref: '#/components/schemas/Pet' + '400': + description: Invalid ID supplied + '404': + description: Pet not found + security: + - api_key: [] + post: + tags: + - pet + summary: Updates a pet in the store with form data + description: '' + operationId: updatePetWithForm + parameters: + - name: petId + in: path + description: ID of pet that needs to be updated + required: true + schema: + type: integer + format: int64 + responses: + '405': + description: Invalid input + security: + - petstore_auth: + - 'write:pets' + - 'read:pets' + requestBody: + content: + application/x-www-form-urlencoded: + schema: + type: object + properties: + name: + description: Updated name of the pet + type: string + status: + description: Updated status of the pet + type: string + delete: + tags: + - pet + summary: Deletes a pet + description: '' + operationId: deletePet + parameters: + - name: api_key + in: header + required: false + schema: + type: string + - name: petId + in: path + description: Pet id to delete + required: true + schema: + type: integer + format: int64 + responses: + '400': + description: Invalid pet value + security: + - petstore_auth: + - 'write:pets' + - 'read:pets' + '/pet/{petId}/uploadImage': + post: + tags: + - pet + summary: uploads an image + description: '' + operationId: uploadFile + parameters: + - name: petId + in: path + description: ID of pet to update + required: true + schema: + type: integer + format: int64 + responses: + '200': + description: successful operation + content: + application/json: + schema: + $ref: '#/components/schemas/ApiResponse' + security: + - petstore_auth: + - 'write:pets' + - 'read:pets' + requestBody: + content: + multipart/form-data: + schema: + type: object + properties: + additionalMetadata: + description: Additional data to pass to server + type: string + file: + description: file to upload + type: string + format: binary + x-extra-imports: org.springframework.lang.NonNull + x-field-extra-annotation: "@NonNull" + /store/inventory: + get: + tags: + - store + summary: Returns pet inventories by status + description: Returns a map of status codes to quantities + operationId: getInventory + responses: + '200': + description: successful operation + content: + application/json: + schema: + type: object + additionalProperties: + type: integer + format: int32 + security: + - api_key: [] + /store/order: + post: + tags: + - store + summary: Place an order for a pet + description: '' + operationId: placeOrder + responses: + '200': + description: successful operation + content: + application/xml: + schema: + $ref: '#/components/schemas/Order' + application/json: + schema: + $ref: '#/components/schemas/Order' + '400': + description: Invalid Order + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/Order' + description: order placed for purchasing the pet + required: true + '/store/order/{orderId}': + get: + tags: + - store + summary: Find purchase order by ID + description: >- + For valid response try integer IDs with value <= 5 or > 10. Other values + will generate exceptions + operationId: getOrderById + parameters: + - name: orderId + in: path + description: ID of pet that needs to be fetched + required: true + schema: + type: integer + format: int64 + minimum: 1 + maximum: 5 + responses: + '200': + description: successful operation + content: + application/xml: + schema: + $ref: '#/components/schemas/Order' + application/json: + schema: + $ref: '#/components/schemas/Order' + '400': + description: Invalid ID supplied + '404': + description: Order not found + delete: + tags: + - store + summary: Delete purchase order by ID + description: >- + For valid response try integer IDs with value < 1000. Anything above + 1000 or nonintegers will generate API errors + operationId: deleteOrder + parameters: + - name: orderId + in: path + description: ID of the order that needs to be deleted + required: true + schema: + type: string + responses: + '400': + description: Invalid ID supplied + '404': + description: Order not found + /user: + post: + tags: + - user + summary: Create user + description: This can only be done by the logged in user. + operationId: createUser + responses: + default: + description: successful operation + security: + - api_key: [] + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/User' + description: Created user object + required: true + /user/createWithArray: + post: + tags: + - user + summary: Creates list of users with given input array + description: '' + operationId: createUsersWithArrayInput + responses: + default: + description: successful operation + security: + - api_key: [] + requestBody: + $ref: '#/components/requestBodies/UserArray' + /user/createWithList: + post: + tags: + - user + summary: Creates list of users with given input array + description: '' + operationId: createUsersWithListInput + responses: + default: + description: successful operation + security: + - api_key: [] + requestBody: + $ref: '#/components/requestBodies/UserArray' + /user/login: + get: + tags: + - user + summary: Logs user into the system + description: '' + operationId: loginUser + parameters: + - name: username + in: query + description: The user name for login + required: true + schema: + type: string + pattern: '^[a-zA-Z0-9]+[a-zA-Z0-9\.\-_]*[a-zA-Z0-9]+$' + - name: password + in: query + description: The password for login in clear text + required: true + schema: + type: string + responses: + '200': + description: successful operation + headers: + Set-Cookie: + description: >- + Cookie authentication key for use with the `api_key` + apiKey authentication. + schema: + type: string + example: AUTH_KEY=abcde12345; Path=/; HttpOnly + X-Rate-Limit: + description: calls per hour allowed by the user + schema: + type: integer + format: int32 + X-Expires-After: + description: date in UTC when token expires + schema: + type: string + format: date-time + content: + application/xml: + schema: + type: string + application/json: + schema: + type: string + '400': + description: Invalid username/password supplied + /user/logout: + get: + tags: + - user + summary: Logs out current logged in user session + description: '' + operationId: logoutUser + responses: + default: + description: successful operation + security: + - api_key: [] + '/user/{username}': + get: + tags: + - user + summary: Get user by user name + description: '' + operationId: getUserByName + parameters: + - name: username + in: path + description: The name that needs to be fetched. Use user1 for testing. + required: true + schema: + type: string + responses: + '200': + description: successful operation + content: + application/xml: + schema: + $ref: '#/components/schemas/User' + application/json: + schema: + $ref: '#/components/schemas/User' + '400': + description: Invalid username supplied + '404': + description: User not found + put: + tags: + - user + summary: Updated user + description: This can only be done by the logged in user. + operationId: updateUser + parameters: + - name: username + in: path + description: name that need to be deleted + required: true + schema: + type: string + responses: + '400': + description: Invalid user supplied + '404': + description: User not found + security: + - api_key: [] + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/User' + description: Updated user object + required: true + delete: + tags: + - user + summary: Delete user + description: This can only be done by the logged in user. + operationId: deleteUser + parameters: + - name: username + in: path + description: The name that needs to be deleted + required: true + schema: + type: string + responses: + '400': + description: Invalid username supplied + '404': + description: User not found + security: + - api_key: [] +externalDocs: + description: Find out more about Swagger + url: 'http://swagger.io' +components: + requestBodies: + UserArray: + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/User' + description: List of user object + required: true + Pet: + content: + application/json: + schema: + $ref: '#/components/schemas/Pet' + application/xml: + schema: + $ref: '#/components/schemas/Pet' + description: Pet object that needs to be added to the store + required: true + securitySchemes: + petstore_auth: + type: oauth2 + flows: + implicit: + authorizationUrl: 'http://petstore.swagger.io/api/oauth/dialog' + scopes: + 'write:pets': modify pets in your account + 'read:pets': read your pets + api_key: + type: apiKey + name: api_key + in: header + schemas: + Order: + title: Pet Order + description: An order for a pets from the pet store + type: object + properties: + id: + type: integer + format: int64 + petId: + type: integer + format: int64 + quantity: + type: integer + format: int32 + shipDate: + type: string + format: date-time + status: + type: string + description: Order Status + enum: + - placed + - approved + - delivered + complete: + type: boolean + default: false + xml: + name: Order + Category: + title: Pet category + description: A category for a pet + type: object + properties: + id: + type: integer + format: int64 + name: + type: string + pattern: '^[a-zA-Z0-9]+[a-zA-Z0-9\.\-_]*[a-zA-Z0-9]+$' + xml: + name: Category + User: + title: a User + description: A User who is purchasing from the pet store + type: object + properties: + id: + type: integer + format: int64 + username: + type: string + firstName: + type: string + lastName: + type: string + email: + type: string + password: + type: string + phone: + type: string + userStatus: + type: integer + format: int32 + description: User Status + xml: + name: User + Tag: + title: Pet Tag + description: A tag for a pet + type: object + properties: + id: + type: integer + format: int64 + name: + type: string + xml: + name: Tag + Pet: + title: a Pet + description: A pet for sale in the pet store + type: object + required: + - name + - photoUrls + properties: + id: + type: integer + format: int64 + category: + $ref: '#/components/schemas/Category' + name: + type: string + example: doggie + photoUrls: + type: array + xml: + name: photoUrl + wrapped: true + items: + type: string + tags: + type: array + xml: + name: tag + wrapped: true + items: + $ref: '#/components/schemas/Tag' + status: + type: string + description: pet status in the store + deprecated: true + enum: + - available + - pending + - sold + xml: + name: Pet + ApiResponse: + title: An uploaded response + description: Describes the result of uploading an image resource + type: object + properties: + code: + type: integer + format: int32 + type: + type: string + message: + type: string \ No newline at end of file diff --git a/modules/openapi-generator/src/test/resources/3_0/spring/petstore-with-fake-endpoints-models-for-testing-extra-annotation.yaml b/modules/openapi-generator/src/test/resources/3_0/spring/petstore-with-fake-endpoints-models-for-testing-extra-annotation.yaml new file mode 100644 index 000000000000..82f149cd9c4e --- /dev/null +++ b/modules/openapi-generator/src/test/resources/3_0/spring/petstore-with-fake-endpoints-models-for-testing-extra-annotation.yaml @@ -0,0 +1,2101 @@ +openapi: 3.0.0 +info: + description: 'This spec is mainly for testing Petstore server and contains fake + endpoints, models. Please do not use this for any other purpose. Special + characters: " \' + version: 1.0.0 + title: OpenAPI Petstore + license: + name: Apache-2.0 + url: https://www.apache.org/licenses/LICENSE-2.0.html +tags: + - name: pet + description: Everything about your Pets + - name: store + description: Access to Petstore orders + - name: user + description: Operations about user +paths: + /pet: + post: + tags: + - pet + summary: Add a new pet to the store + description: "" + operationId: addPet + x-request-body-extra-annotation: "@org.springframework.lang.NonNull" + requestBody: + $ref: "#/components/requestBodies/Pet" + responses: + "200": + description: successful operation + "405": + description: Invalid input + security: + - petstore_auth: + - write:pets + - read:pets + put: + tags: + - pet + summary: Update an existing pet + description: "" + operationId: updatePet + requestBody: + $ref: "#/components/requestBodies/Pet" + responses: + "200": + description: successful operation + "400": + description: Invalid ID supplied + "404": + description: Pet not found + "405": + description: Validation exception + security: + - petstore_auth: + - write:pets + - read:pets + /pet/findByStatus: + get: + tags: + - pet + summary: Finds Pets by status + description: Multiple status values can be provided with comma separated strings + operationId: findPetsByStatus + parameters: + - name: status + in: query + description: Status values that need to be considered for filter + required: true + style: form + explode: false + x-field-extra-annotation: "@org.springframework.lang.NonNull" + schema: + type: array + items: + type: string + enum: + - available + - pending + - sold + default: available + responses: + "200": + description: successful operation + content: + application/xml: + schema: + type: array + items: + $ref: "#/components/schemas/Pet" + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Pet" + "400": + description: Invalid status value + security: + - petstore_auth: + - write:pets + - read:pets + /pet/findByTags: + get: + tags: + - pet + summary: Finds Pets by tags + description: Multiple tags can be provided with comma separated strings. Use tag1, + tag2, tag3 for testing. + operationId: findPetsByTags + parameters: + - name: tags + in: query + description: Tags to filter by + required: true + style: form + explode: false + schema: + type: array + items: + type: string + uniqueItems: true + responses: + "200": + description: successful operation + content: + application/xml: + schema: + type: array + uniqueItems: true + items: + $ref: "#/components/schemas/Pet" + application/json: + schema: + type: array + uniqueItems: true + items: + $ref: "#/components/schemas/Pet" + "400": + description: Invalid tag value + security: + - petstore_auth: + - write:pets + - read:pets + deprecated: true + "/pet/{petId}": + get: + tags: + - pet + summary: Find pet by ID + description: Returns a single pet + operationId: getPetById + parameters: + - name: petId + in: path + description: ID of pet to return + required: true + x-field-extra-annotation: "@org.springframework.lang.NonNull" + schema: + type: integer + format: int64 + responses: + "200": + description: successful operation + content: + application/xml: + schema: + $ref: "#/components/schemas/Pet" + application/json: + schema: + $ref: "#/components/schemas/Pet" + "400": + description: Invalid ID supplied + "404": + description: Pet not found + security: + - api_key: [] + post: + tags: + - pet + summary: Updates a pet in the store with form data + description: "" + operationId: updatePetWithForm + parameters: + - name: petId + in: path + description: ID of pet that needs to be updated + required: true + schema: + type: integer + format: int64 + requestBody: + content: + application/x-www-form-urlencoded: + schema: + type: object + properties: + name: + description: Updated name of the pet + type: string + status: + description: Updated status of the pet + type: string + responses: + "405": + description: Invalid input + security: + - petstore_auth: + - write:pets + - read:pets + delete: + tags: + - pet + summary: Deletes a pet + description: "" + operationId: deletePet + parameters: + - name: api_key + in: header + required: false + schema: + type: string + - name: petId + in: path + description: Pet id to delete + required: true + schema: + type: integer + format: int64 + responses: + "200": + description: successful operation + "400": + description: Invalid pet value + security: + - petstore_auth: + - write:pets + - read:pets + "/pet/{petId}/uploadImage": + post: + tags: + - pet + summary: uploads an image + description: "" + operationId: uploadFile + parameters: + - name: petId + in: path + description: ID of pet to update + required: true + schema: + type: integer + format: int64 + requestBody: + content: + multipart/form-data: + schema: + type: object + properties: + additionalMetadata: + description: Additional data to pass to server + type: string + file: + description: file to upload + type: string + format: binary + responses: + "200": + description: successful operation + content: + application/json: + schema: + $ref: "#/components/schemas/ApiResponse" + security: + - petstore_auth: + - write:pets + - read:pets + /store/inventory: + get: + tags: + - store + summary: Returns pet inventories by status + description: Returns a map of status codes to quantities + operationId: getInventory + responses: + "200": + description: successful operation + content: + application/json: + schema: + type: object + additionalProperties: + type: integer + format: int32 + security: + - api_key: [] + /store/order: + post: + tags: + - store + summary: Place an order for a pet + description: "" + operationId: placeOrder + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/Order" + description: order placed for purchasing the pet + required: true + responses: + "200": + description: successful operation + content: + application/xml: + schema: + $ref: "#/components/schemas/Order" + application/json: + schema: + $ref: "#/components/schemas/Order" + "400": + description: Invalid Order + "/store/order/{order_id}": + get: + tags: + - store + summary: Find purchase order by ID + description: For valid response try integer IDs with value <= 5 or > 10. Other values + will generate exceptions + operationId: getOrderById + parameters: + - name: order_id + in: path + description: ID of pet that needs to be fetched + required: true + schema: + type: integer + format: int64 + minimum: 1 + maximum: 5 + responses: + "200": + description: successful operation + content: + application/xml: + schema: + $ref: "#/components/schemas/Order" + application/json: + schema: + $ref: "#/components/schemas/Order" + "400": + description: Invalid ID supplied + "404": + description: Order not found + delete: + tags: + - store + summary: Delete purchase order by ID + description: For valid response try integer IDs with value < 1000. Anything above + 1000 or nonintegers will generate API errors + operationId: deleteOrder + parameters: + - name: order_id + in: path + description: ID of the order that needs to be deleted + required: true + schema: + type: string + responses: + "400": + description: Invalid ID supplied + "404": + description: Order not found + /user: + post: + tags: + - user + summary: Create user + description: This can only be done by the logged in user. + operationId: createUser + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/User" + description: Created user object + required: true + responses: + default: + description: successful operation + /user/createWithArray: + post: + tags: + - user + summary: Creates list of users with given input array + description: "" + operationId: createUsersWithArrayInput + requestBody: + $ref: "#/components/requestBodies/UserArray" + responses: + default: + description: successful operation + /user/createWithList: + post: + tags: + - user + summary: Creates list of users with given input array + description: "" + operationId: createUsersWithListInput + requestBody: + $ref: "#/components/requestBodies/UserArray" + responses: + default: + description: successful operation + /user/login: + get: + tags: + - user + summary: Logs user into the system + description: "" + operationId: loginUser + parameters: + - name: username + in: query + description: The user name for login + required: true + schema: + type: string + - name: password + in: query + description: The password for login in clear text + required: true + schema: + type: string + responses: + "200": + description: successful operation + headers: + X-Rate-Limit: + description: calls per hour allowed by the user + schema: + type: integer + format: int32 + X-Expires-After: + description: date in UTC when token expires + schema: + type: string + format: date-time + content: + application/xml: + schema: + type: string + application/json: + schema: + type: string + "400": + description: Invalid username/password supplied + /user/logout: + get: + tags: + - user + summary: Logs out current logged in user session + description: "" + operationId: logoutUser + responses: + default: + description: successful operation + "/user/{username}": + get: + tags: + - user + summary: Get user by user name + description: "" + operationId: getUserByName + parameters: + - name: username + in: path + description: The name that needs to be fetched. Use user1 for testing. + required: true + schema: + type: string + responses: + "200": + description: successful operation + content: + application/xml: + schema: + $ref: "#/components/schemas/User" + application/json: + schema: + $ref: "#/components/schemas/User" + "400": + description: Invalid username supplied + "404": + description: User not found + put: + tags: + - user + summary: Updated user + description: This can only be done by the logged in user. + operationId: updateUser + parameters: + - name: username + in: path + description: name that need to be deleted + required: true + schema: + type: string + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/User" + description: Updated user object + required: true + responses: + "400": + description: Invalid user supplied + "404": + description: User not found + delete: + tags: + - user + summary: Delete user + description: This can only be done by the logged in user. + operationId: deleteUser + parameters: + - name: username + in: path + description: The name that needs to be deleted + required: true + schema: + type: string + responses: + "400": + description: Invalid username supplied + "404": + description: User not found + /fake_classname_test: + patch: + tags: + - fake_classname_tags 123#$%^ + summary: To test class name in snake case + description: To test class name in snake case + operationId: testClassname + requestBody: + $ref: "#/components/requestBodies/Client" + responses: + "200": + description: successful operation + content: + application/json: + schema: + $ref: "#/components/schemas/Client" + security: + - api_key_query: [] + /fake: + patch: + tags: + - fake + summary: To test "client" model + description: To test "client" model + operationId: testClientModel + requestBody: + $ref: "#/components/requestBodies/Client" + responses: + "200": + description: successful operation + content: + application/json: + schema: + $ref: "#/components/schemas/Client" + get: + tags: + - fake + summary: To test enum parameters + description: To test enum parameters + operationId: testEnumParameters + parameters: + - name: enum_header_string_array + in: header + description: Header parameter enum test (string array) + schema: + type: array + items: + type: string + default: $ + enum: + - ">" + - $ + - name: enum_header_string + in: header + description: Header parameter enum test (string) + schema: + type: string + enum: + - _abc + - -efg + - (xyz) + default: -efg + - name: enum_query_string_array + in: query + description: Query parameter enum test (string array) + schema: + type: array + items: + type: string + default: $ + enum: + - ">" + - $ + - name: enum_query_string + in: query + description: Query parameter enum test (string) + schema: + type: string + enum: + - _abc + - -efg + - (xyz) + default: -efg + - name: enum_query_integer + in: query + description: Query parameter enum test (double) + schema: + type: integer + format: int32 + enum: + - 1 + - -2 + - name: enum_query_double + in: query + description: Query parameter enum test (double) + schema: + type: number + format: double + enum: + - 1.1 + - -1.2 + requestBody: + content: + application/x-www-form-urlencoded: + schema: + type: object + properties: + enum_form_string_array: + description: Form parameter enum test (string array) + type: array + items: + type: string + default: $ + enum: + - ">" + - $ + enum_form_string: + description: Form parameter enum test (string) + type: string + enum: + - _abc + - -efg + - (xyz) + default: -efg + responses: + "400": + description: Invalid request + "404": + description: Not found + post: + tags: + - fake + summary: |- + Fake endpoint for testing various parameters + 假端點 + 偽のエンドポイント + 가짜 엔드 포인트 + description: |- + Fake endpoint for testing various parameters + 假端點 + 偽のエンドポイント + 가짜 엔드 포인트 + operationId: testEndpointParameters + requestBody: + content: + application/x-www-form-urlencoded: + schema: + type: object + properties: + integer: + description: None + type: integer + minimum: 10 + maximum: 100 + int32: + description: None + type: integer + format: int32 + minimum: 20 + maximum: 200 + int64: + description: None + type: integer + format: int64 + number: + description: None + type: number + minimum: 32.1 + maximum: 543.2 + float: + description: None + type: number + format: float + maximum: 987.6 + double: + description: None + type: number + format: double + minimum: 67.8 + maximum: 123.4 + string: + description: None + type: string + pattern: "[a-zA-Z]" + pattern_without_delimiter: + description: None + type: string + pattern: ^[A-Z].* + byte: + description: None + type: string + format: byte + binary: + description: None + type: string + format: binary + date: + description: None + type: string + format: date + dateTime: + description: None + type: string + format: date-time + password: + description: None + type: string + format: password + minLength: 10 + maxLength: 64 + callback: + description: None + type: string + required: + - number + - double + - pattern_without_delimiter + - byte + responses: + "400": + description: Invalid username supplied + "404": + description: User not found + security: + - http_basic_test: [] + delete: + tags: + - fake + summary: Fake endpoint to test group parameters (optional) + description: Fake endpoint to test group parameters (optional) + operationId: testGroupParameters + x-group-parameters: true + parameters: + - name: required_string_group + in: query + description: Required String in group parameters + required: true + schema: + type: integer + - name: required_boolean_group + in: header + description: Required Boolean in group parameters + required: true + schema: + type: boolean + - name: required_int64_group + in: query + description: Required Integer in group parameters + required: true + schema: + type: integer + format: int64 + - name: string_group + in: query + description: String in group parameters + schema: + type: integer + - name: boolean_group + in: header + description: Boolean in group parameters + schema: + type: boolean + - name: int64_group + in: query + description: Integer in group parameters + schema: + type: integer + format: int64 + responses: + "400": + description: Something wrong + /fake/outer/number: + post: + tags: + - fake + description: Test serialization of outer number types + operationId: fakeOuterNumberSerialize + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/OuterNumber" + description: Input number as post body + responses: + "200": + description: Output number + content: + "*/*": + schema: + $ref: "#/components/schemas/OuterNumber" + /fake/outer/string: + post: + tags: + - fake + description: Test serialization of outer string types + operationId: fakeOuterStringSerialize + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/OuterString" + description: Input string as post body + responses: + "200": + description: Output string + content: + "*/*": + schema: + $ref: "#/components/schemas/OuterString" + /fake/outer/boolean: + post: + tags: + - fake + description: Test serialization of outer boolean types + operationId: fakeOuterBooleanSerialize + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/OuterBoolean" + description: Input boolean as post body + responses: + "200": + description: Output boolean + content: + "*/*": + schema: + $ref: "#/components/schemas/OuterBoolean" + /fake/outer/composite: + post: + tags: + - fake + description: Test serialization of object with outer number type + operationId: fakeOuterCompositeSerialize + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/OuterComposite" + description: Input composite as post body + responses: + "200": + description: Output composite + content: + "*/*": + schema: + $ref: "#/components/schemas/OuterComposite" + /fake/jsonFormData: + get: + tags: + - fake + summary: test json serialization of form data + description: "" + operationId: testJsonFormData + requestBody: + content: + application/x-www-form-urlencoded: + schema: + type: object + properties: + param: + description: field1 + type: string + param2: + description: field2 + type: string + required: + - param + - param2 + responses: + "200": + description: successful operation + /fake/inline-additionalProperties: + post: + tags: + - fake + summary: test inline additionalProperties + description: "" + operationId: testInlineAdditionalProperties + requestBody: + content: + application/json: + schema: + type: object + additionalProperties: + type: string + description: request body + required: true + responses: + "200": + description: successful operation + /fake/nullable: + post: + tags: + - fake + summary: test nullable parent property + description: "" + operationId: testNullable + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/ChildWithNullable' + description: request body + required: true + responses: + "200": + description: successful operation + /fake/body-with-query-params: + put: + tags: + - fake + operationId: testBodyWithQueryParams + parameters: + - name: query + in: query + required: true + schema: + type: string + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/User" + required: true + responses: + "200": + description: Success + /fake/create_xml_item: + post: + tags: + - fake + operationId: createXmlItem + summary: creates an XmlItem + description: this route creates an XmlItem + requestBody: + content: + application/xml: + schema: + $ref: "#/components/schemas/XmlItem" + application/xml; charset=utf-8: + schema: + $ref: "#/components/schemas/XmlItem" + application/xml; charset=utf-16: + schema: + $ref: "#/components/schemas/XmlItem" + text/xml: + schema: + $ref: "#/components/schemas/XmlItem" + text/xml; charset=utf-8: + schema: + $ref: "#/components/schemas/XmlItem" + text/xml; charset=utf-16: + schema: + $ref: "#/components/schemas/XmlItem" + description: XmlItem Body + required: true + responses: + "200": + description: successful operation + /another-fake/dummy: + patch: + tags: + - $another-fake? + summary: To test special tags + description: To test special tags and operation ID starting with number + operationId: 123_test_@#$%_special_tags + requestBody: + $ref: "#/components/requestBodies/Client" + responses: + "200": + description: successful operation + content: + application/json: + schema: + $ref: "#/components/schemas/Client" + /fake/body-with-file-schema: + put: + tags: + - fake + description: For this test, the body for this request much reference a schema named + `File`. + operationId: testBodyWithFileSchema + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/FileSchemaTestClass" + required: true + responses: + "200": + description: Success + /fake/response-with-example: + get: + tags: + - fake + description: This endpoint defines an example value for its response schema. + operationId: testWithResultExample + responses: + "200": + content: + application/json: + schema: + example: 42 + type: integer + description: Success + /fake/test-query-parameters: + put: + tags: + - fake + description: To test the collection format in query parameters + operationId: testQueryParameterCollectionFormat + parameters: + - name: pipe + in: query + required: true + schema: + type: array + items: + type: string + - name: http + in: query + required: true + style: spaceDelimited + schema: + type: array + items: + type: string + - name: url + in: query + required: true + style: form + explode: false + schema: + type: array + items: + type: string + - name: context + in: query + required: true + explode: true + schema: + type: array + items: + type: string + responses: + "200": + description: Success + "/fake/{petId}/uploadImageWithRequiredFile": + post: + tags: + - pet + summary: uploads an image (required) + description: "" + operationId: uploadFileWithRequiredFile + parameters: + - name: petId + in: path + description: ID of pet to update + required: true + schema: + type: integer + format: int64 + requestBody: + content: + multipart/form-data: + schema: + type: object + properties: + additionalMetadata: + description: Additional data to pass to server + type: string + requiredFile: + description: file to upload + type: string + format: binary + required: + - requiredFile + responses: + "200": + description: successful operation + content: + application/json: + schema: + $ref: "#/components/schemas/ApiResponse" + security: + - petstore_auth: + - write:pets + - read:pets + /fake/{petId}/response-object-different-names: + get: + tags: + - pet + operationId: responseObjectDifferentNames + parameters: + - name: petId + in: path + description: ID of pet to update + required: true + schema: + type: integer + format: int64 + responses: + 200: + description: successful operation + content: + application/json: + schema: + $ref: "#/components/schemas/ResponseObjectWithDifferentFieldNames" +servers: + - url: http://petstore.swagger.io:80/v2 +components: + requestBodies: + UserArray: + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/User" + description: List of user object + required: true + Client: + content: + application/json: + schema: + $ref: "#/components/schemas/Client" + description: client model + required: true + Pet: + content: + application/json: + schema: + $ref: "#/components/schemas/Pet" + application/xml: + schema: + $ref: "#/components/schemas/Pet" + description: Pet object that needs to be added to the store + required: true + securitySchemes: + petstore_auth: + type: oauth2 + flows: + implicit: + authorizationUrl: http://petstore.swagger.io/api/oauth/dialog + scopes: + write:pets: modify pets in your account + read:pets: read your pets + api_key: + type: apiKey + name: api_key + in: header + api_key_query: + type: apiKey + name: api_key_query + in: query + http_basic_test: + type: http + scheme: basic + schemas: + Order: + type: object + properties: + id: + type: integer + format: int64 + petId: + type: integer + format: int64 + quantity: + type: integer + format: int32 + shipDate: + type: string + format: date-time + status: + type: string + description: Order Status + enum: + - placed + - approved + - delivered + complete: + type: boolean + default: false + xml: + name: Order + Category: + type: object + required: + - name + properties: + id: + type: integer + format: int64 + name: + type: string + default: default-name + xml: + name: Category + User: + type: object + properties: + id: + type: integer + format: int64 + x-is-unique: true + username: + type: string + firstName: + type: string + lastName: + type: string + email: + type: string + password: + type: string + phone: + type: string + userStatus: + type: integer + format: int32 + description: User Status + xml: + name: User + Tag: + type: object + properties: + id: + type: integer + format: int64 + name: + type: string + xml: + name: Tag + Pet: + type: object + required: + - name + - photoUrls + properties: + id: + type: integer + format: int64 + x-is-unique: true + category: + $ref: "#/components/schemas/Category" + name: + type: string + example: doggie + photoUrls: + type: array + uniqueItems: true + xml: + name: photoUrl + wrapped: true + items: + type: string + tags: + type: array + xml: + name: tag + wrapped: true + items: + $ref: "#/components/schemas/Tag" + status: + type: string + description: pet status in the store + deprecated: true + enum: + - available + - pending + - sold + xml: + name: Pet + ApiResponse: + type: object + properties: + code: + type: integer + format: int32 + type: + type: string + message: + type: string + Return: + description: Model for testing reserved words + properties: + return: + type: integer + format: int32 + xml: + name: Return + Name: + description: Model for testing model name same as property name + required: + - name + properties: + name: + type: integer + format: int32 + snake_case: + readOnly: true + type: integer + format: int32 + property: + type: string + 123Number: + type: integer + readOnly: true + xml: + name: Name + 200_response: + description: Model for testing model name starting with number + properties: + name: + type: integer + format: int32 + class: + type: string + xml: + name: Name + ClassModel: + description: Model for testing model with "_class" property + properties: + _class: + type: string + Dog: + allOf: + - $ref: "#/components/schemas/Animal" + - type: object + properties: + breed: + type: string + Cat: + allOf: + - $ref: "#/components/schemas/Animal" + - type: object + properties: + declawed: + type: boolean + BigCat: + allOf: + - $ref: "#/components/schemas/Cat" + - type: object + properties: + kind: + type: string + enum: + - lions + - tigers + - leopards + - jaguars + Animal: + type: object + discriminator: + propertyName: className + required: + - className + properties: + className: + type: string + color: + type: string + default: red + AnimalFarm: + type: array + items: + $ref: "#/components/schemas/Animal" + format_test: + type: object + required: + - number + - byte + - date + - password + properties: + integer: + type: integer + maximum: 100 + minimum: 10 + int32: + type: integer + format: int32 + maximum: 200 + minimum: 20 + int64: + type: integer + format: int64 + number: + maximum: 543.2 + minimum: 32.1 + type: number + float: + type: number + format: float + maximum: 987.6 + minimum: 54.3 + double: + type: number + format: double + maximum: 123.4 + minimum: 67.8 + string: + type: string + pattern: "[a-zA-Z]" + byte: + type: string + format: byte + binary: + type: string + format: binary + date: + type: string + format: date + dateTime: + type: string + format: date-time + uuid: + type: string + format: uuid + maxLength: 36 + example: 72f98069-206d-4f12-9f12-3d1e525a8e84 + password: + type: string + format: password + maxLength: 64 + minLength: 10 + BigDecimal: + type: string + format: number + EnumClass: + type: string + default: -efg + enum: + - _abc + - -efg + - (xyz) + Enum_Test: + type: object + required: + - enum_string_required + properties: + enum_string: + type: string + enum: + - UPPER + - lower + - "" + enum_string_required: + type: string + enum: + - UPPER + - lower + - "" + enum_integer: + type: integer + format: int32 + enum: + - 1 + - -1 + enum_number: + type: number + format: double + enum: + - 1.1 + - -1.2 + outerEnum: + $ref: "#/components/schemas/OuterEnum" + AdditionalPropertiesClass: + type: object + properties: + map_string: + type: object + additionalProperties: + type: string + map_number: + type: object + additionalProperties: + type: number + map_integer: + type: object + additionalProperties: + type: integer + map_boolean: + type: object + additionalProperties: + type: boolean + map_array_integer: + type: object + additionalProperties: + type: array + items: + type: integer + map_array_anytype: + type: object + additionalProperties: + type: array + items: + type: object + map_map_string: + type: object + additionalProperties: + type: object + additionalProperties: + type: string + map_map_anytype: + type: object + additionalProperties: + type: object + additionalProperties: + type: object + anytype_1: + type: object + anytype_2: {} + anytype_3: + type: object + properties: {} + AdditionalPropertiesString: + type: object + properties: + name: + type: string + additionalProperties: + type: string + AdditionalPropertiesInteger: + type: object + properties: + name: + type: string + additionalProperties: + type: integer + AdditionalPropertiesNumber: + type: object + properties: + name: + type: string + additionalProperties: + type: number + AdditionalPropertiesBoolean: + type: object + properties: + name: + type: string + additionalProperties: + type: boolean + AdditionalPropertiesArray: + type: object + properties: + name: + type: string + additionalProperties: + type: array + items: + type: object + AdditionalPropertiesObject: + type: object + properties: + name: + type: string + additionalProperties: + type: object + additionalProperties: + type: object + AdditionalPropertiesAnyType: + type: object + properties: + name: + type: string + additionalProperties: + type: object + MixedPropertiesAndAdditionalPropertiesClass: + type: object + properties: + uuid: + type: string + format: uuid + dateTime: + type: string + format: date-time + map: + type: object + additionalProperties: + $ref: "#/components/schemas/Animal" + List: + type: object + properties: + 123-list: + type: string + Client: + type: object + properties: + client: + type: string + ReadOnlyFirst: + type: object + properties: + bar: + type: string + readOnly: true + baz: + type: string + hasOnlyReadOnly: + type: object + properties: + bar: + type: string + readOnly: true + foo: + type: string + readOnly: true + Capitalization: + type: object + properties: + smallCamel: + type: string + CapitalCamel: + type: string + small_Snake: + type: string + Capital_Snake: + type: string + SCA_ETH_Flow_Points: + type: string + ATT_NAME: + description: | + Name of the pet + type: string + MapTest: + type: object + properties: + map_map_of_string: + type: object + additionalProperties: + type: object + additionalProperties: + type: string + map_of_enum_string: + type: object + additionalProperties: + type: string + enum: + - UPPER + - lower + direct_map: + type: object + additionalProperties: + type: boolean + indirect_map: + $ref: "#/components/schemas/StringBooleanMap" + ArrayTest: + type: object + properties: + array_of_string: + type: array + items: + type: string + array_array_of_integer: + type: array + items: + type: array + items: + type: integer + format: int64 + array_array_of_model: + type: array + items: + type: array + items: + $ref: "#/components/schemas/ReadOnlyFirst" + NumberOnly: + type: object + properties: + JustNumber: + type: number + ArrayOfNumberOnly: + type: object + properties: + ArrayNumber: + type: array + items: + type: number + ArrayOfArrayOfNumberOnly: + type: object + properties: + ArrayArrayNumber: + type: array + items: + type: array + items: + type: number + EnumArrays: + type: object + properties: + just_symbol: + type: string + enum: + - ">=" + - $ + array_enum: + type: array + items: + type: string + enum: + - fish + - crab + OuterEnum: + type: string + enum: + - placed + - approved + - delivered + OuterComposite: + type: object + properties: + my_number: + $ref: "#/components/schemas/OuterNumber" + my_string: + $ref: "#/components/schemas/OuterString" + my_boolean: + $ref: "#/components/schemas/OuterBoolean" + OuterNumber: + type: number + OuterString: + type: string + OuterBoolean: + type: boolean + x-codegen-body-parameter-name: boolean_post_body + ParentWithNullable: + type: object + discriminator: + propertyName: type + properties: + type: + type: string + enum: + - ChildWithNullable + nullableProperty: + type: string + nullable: true + ChildWithNullable: + allOf: + - $ref: '#/components/schemas/ParentWithNullable' + - type: object + properties: + otherProperty: + type: string + StringBooleanMap: + additionalProperties: + type: boolean + FileSchemaTestClass: + type: object + properties: + file: + $ref: "#/components/schemas/File" + files: + type: array + items: + $ref: "#/components/schemas/File" + File: + type: object + description: Must be named `File` for test. + properties: + sourceURI: + description: Test capitalization + type: string + TypeHolderDefault: + type: object + required: + - string_item + - number_item + - integer_item + - bool_item + - array_item + properties: + string_item: + type: string + default: what + number_item: + type: number + default: 1.234 + integer_item: + type: integer + default: -2 + bool_item: + type: boolean + default: true + array_item: + type: array + items: + type: integer + default: + - 0 + - 1 + - 2 + - 3 + TypeHolderExample: + type: object + required: + - string_item + - number_item + - float_item + - integer_item + - bool_item + - array_item + properties: + string_item: + type: string + example: what + number_item: + type: number + example: 1.234 + float_item: + type: number + example: 1.234 + format: float + integer_item: + type: integer + example: -2 + bool_item: + type: boolean + example: true + array_item: + type: array + items: + type: integer + example: + - 0 + - 1 + - 2 + - 3 + XmlItem: + type: object + xml: + namespace: http://a.com/schema + prefix: pre + properties: + attribute_string: + type: string + example: string + xml: + attribute: true + attribute_number: + type: number + example: 1.234 + xml: + attribute: true + attribute_integer: + type: integer + example: -2 + xml: + attribute: true + attribute_boolean: + type: boolean + example: true + xml: + attribute: true + wrapped_array: + type: array + xml: + wrapped: true + items: + type: integer + name_string: + type: string + example: string + xml: + name: xml_name_string + name_number: + type: number + example: 1.234 + xml: + name: xml_name_number + name_integer: + type: integer + example: -2 + xml: + name: xml_name_integer + name_boolean: + type: boolean + example: true + xml: + name: xml_name_boolean + name_array: + type: array + items: + type: integer + xml: + name: xml_name_array_item + name_wrapped_array: + type: array + xml: + wrapped: true + name: xml_name_wrapped_array + items: + type: integer + xml: + name: xml_name_wrapped_array_item + prefix_string: + type: string + example: string + xml: + prefix: ab + prefix_number: + type: number + example: 1.234 + xml: + prefix: cd + prefix_integer: + type: integer + example: -2 + xml: + prefix: ef + prefix_boolean: + type: boolean + example: true + xml: + prefix: gh + prefix_array: + type: array + items: + type: integer + xml: + prefix: ij + prefix_wrapped_array: + type: array + xml: + wrapped: true + prefix: kl + items: + type: integer + xml: + prefix: mn + namespace_string: + type: string + example: string + xml: + namespace: http://a.com/schema + namespace_number: + type: number + example: 1.234 + xml: + namespace: http://b.com/schema + namespace_integer: + type: integer + example: -2 + xml: + namespace: http://c.com/schema + namespace_boolean: + type: boolean + example: true + xml: + namespace: http://d.com/schema + namespace_array: + type: array + items: + type: integer + xml: + namespace: http://e.com/schema + namespace_wrapped_array: + type: array + xml: + wrapped: true + namespace: http://f.com/schema + items: + type: integer + xml: + namespace: http://g.com/schema + prefix_ns_string: + type: string + example: string + xml: + namespace: http://a.com/schema + prefix: a + prefix_ns_number: + type: number + example: 1.234 + xml: + namespace: http://b.com/schema + prefix: b + prefix_ns_integer: + type: integer + example: -2 + xml: + namespace: http://c.com/schema + prefix: c + prefix_ns_boolean: + type: boolean + example: true + xml: + namespace: http://d.com/schema + prefix: d + prefix_ns_array: + type: array + items: + type: integer + xml: + namespace: http://e.com/schema + prefix: e + prefix_ns_wrapped_array: + type: array + xml: + wrapped: true + namespace: http://f.com/schema + prefix: f + items: + type: integer + xml: + namespace: http://g.com/schema + prefix: g + _special_model.name_: + properties: + "$special[property.name]": + type: integer + format: int64 + xml: + name: $special[model.name] + ContainerDefaultValue: + type: object + required: + - required_array + - nullable_required_array + properties: + nullable_array: + type: array + nullable: true + items: + type: string + nullable_required_array: + type: array + nullable: true + items: + type: string + required_array: + type: array + nullable: false + items: + type: string + nullable_array_with_default: + type: array + nullable: true + items: + type: string + default: ["foo", "bar"] + ResponseObjectWithDifferentFieldNames: + type: object + properties: + normalPropertyName: + type: string + UPPER_CASE_PROPERTY_SNAKE: + type: string + lower-case-property-dashes: + type: string + property name with spaces: + type: string + NullableMapProperty: + type: object + properties: + languageValues: + nullable: true + type: object + additionalProperties: + type: string diff --git a/samples/server/petstore/kotlin-springboot-delegate/src/main/kotlin/org/openapitools/api/PetApi.kt b/samples/server/petstore/kotlin-springboot-delegate/src/main/kotlin/org/openapitools/api/PetApi.kt index 8acb5303e965..e94244bcd9da 100644 --- a/samples/server/petstore/kotlin-springboot-delegate/src/main/kotlin/org/openapitools/api/PetApi.kt +++ b/samples/server/petstore/kotlin-springboot-delegate/src/main/kotlin/org/openapitools/api/PetApi.kt @@ -7,6 +7,8 @@ package org.openapitools.api import org.openapitools.model.ModelApiResponse import org.openapitools.model.Pet +import org.springframework.lang.NonNull +import org.springframework.lang.Nullable import io.swagger.v3.oas.annotations.* import io.swagger.v3.oas.annotations.enums.* import io.swagger.v3.oas.annotations.media.* @@ -59,7 +61,7 @@ interface PetApi { consumes = ["application/json", "application/xml"] ) fun addPet( - @Parameter(description = "Pet object that needs to be added to the store", required = true) @Valid @RequestBody pet: Pet + @Parameter(description = "Pet object that needs to be added to the store", required = true) @NonNull @Valid @RequestBody pet: Pet ): ResponseEntity { return getDelegate().addPet(pet) } @@ -104,7 +106,7 @@ interface PetApi { produces = ["application/xml", "application/json"] ) fun findPetsByStatus( - @NotNull @Parameter(description = "Status values that need to be considered for filter", required = true, schema = Schema(allowableValues = ["available", "pending", "sold"])) @Valid @RequestParam(value = "status", required = true) status: kotlin.collections.List + @NonNull @Nullable @NotNull @Parameter(description = "Status values that need to be considered for filter", required = true, schema = Schema(allowableValues = ["available", "pending", "sold"])) @Valid @RequestParam(value = "status", required = true) status: kotlin.collections.List ): ResponseEntity> { return getDelegate().findPetsByStatus(status) } @@ -152,7 +154,7 @@ interface PetApi { produces = ["application/xml", "application/json"] ) fun getPetById( - @Parameter(description = "ID of pet to return", required = true) @PathVariable("petId") petId: kotlin.Long + @NonNull @Parameter(description = "ID of pet to return", required = true) @PathVariable("petId") petId: kotlin.Long ): ResponseEntity { return getDelegate().getPetById(petId) } @@ -227,7 +229,7 @@ interface PetApi { fun uploadFile( @Parameter(description = "ID of pet to update", required = true) @PathVariable("petId") petId: kotlin.Long, @Parameter(description = "Additional data to pass to server") @Valid @RequestParam(value = "additionalMetadata", required = false) additionalMetadata: kotlin.String?, - @Parameter(description = "file to upload") @Valid @RequestPart("file", required = false) file: org.springframework.web.multipart.MultipartFile + @NonNull @Parameter(description = "file to upload") @Valid @RequestPart("file", required = false) file: org.springframework.web.multipart.MultipartFile ): ResponseEntity { return getDelegate().uploadFile(petId, additionalMetadata, file) } diff --git a/samples/server/petstore/kotlin-springboot-delegate/src/main/resources/openapi.yaml b/samples/server/petstore/kotlin-springboot-delegate/src/main/resources/openapi.yaml index 885b64671054..218e008f9bad 100644 --- a/samples/server/petstore/kotlin-springboot-delegate/src/main/resources/openapi.yaml +++ b/samples/server/petstore/kotlin-springboot-delegate/src/main/resources/openapi.yaml @@ -45,6 +45,8 @@ paths: summary: Add a new pet to the store tags: - pet + x-extra-imports: org.springframework.lang.NonNull + x-request-body-extra-annotation: '@NonNull' put: description: "" externalDocs: @@ -97,6 +99,12 @@ paths: type: string type: array style: form + x-extra-imports: + - org.springframework.lang.NonNull + - org.springframework.lang.Nullable + x-field-extra-annotation: + - '@NonNull' + - '@Nullable' responses: "200": content: @@ -202,6 +210,8 @@ paths: format: int64 type: integer style: simple + x-extra-imports: org.springframework.lang.NonNull + x-field-extra-annotation: '@NonNull' responses: "200": content: @@ -795,6 +805,8 @@ components: description: file to upload format: binary type: string + x-extra-imports: org.springframework.lang.NonNull + x-field-extra-annotation: '@NonNull' type: object securitySchemes: petstore_auth: diff --git a/samples/server/petstore/kotlin-springboot-reactive/src/main/kotlin/org/openapitools/api/PetApiController.kt b/samples/server/petstore/kotlin-springboot-reactive/src/main/kotlin/org/openapitools/api/PetApiController.kt index 9e04cc7c297a..aa54d9ceecb3 100644 --- a/samples/server/petstore/kotlin-springboot-reactive/src/main/kotlin/org/openapitools/api/PetApiController.kt +++ b/samples/server/petstore/kotlin-springboot-reactive/src/main/kotlin/org/openapitools/api/PetApiController.kt @@ -2,6 +2,8 @@ package org.openapitools.api import org.openapitools.model.ModelApiResponse import org.openapitools.model.Pet +import org.springframework.lang.NonNull +import org.springframework.lang.Nullable import io.swagger.v3.oas.annotations.* import io.swagger.v3.oas.annotations.enums.* import io.swagger.v3.oas.annotations.media.* @@ -52,7 +54,7 @@ class PetApiController(@Autowired(required = true) val service: PetApiService) { consumes = ["application/json", "application/xml"] ) suspend fun addPet( - @Parameter(description = "Pet object that needs to be added to the store", required = true) @Valid @RequestBody pet: Pet + @Parameter(description = "Pet object that needs to be added to the store", required = true) @NonNull @Valid @RequestBody pet: Pet ): ResponseEntity { return ResponseEntity(service.addPet(pet), HttpStatus.valueOf(200)) } @@ -93,7 +95,7 @@ class PetApiController(@Autowired(required = true) val service: PetApiService) { produces = ["application/xml", "application/json"] ) fun findPetsByStatus( - @NotNull @Parameter(description = "Status values that need to be considered for filter", required = true, schema = Schema(allowableValues = ["available", "pending", "sold"])) @Valid @RequestParam(value = "status", required = true) status: kotlin.collections.List + @NonNull @Nullable @NotNull @Parameter(description = "Status values that need to be considered for filter", required = true, schema = Schema(allowableValues = ["available", "pending", "sold"])) @Valid @RequestParam(value = "status", required = true) status: kotlin.collections.List ): ResponseEntity> { return ResponseEntity(service.findPetsByStatus(status), HttpStatus.valueOf(200)) } @@ -137,7 +139,7 @@ class PetApiController(@Autowired(required = true) val service: PetApiService) { produces = ["application/xml", "application/json"] ) suspend fun getPetById( - @Parameter(description = "ID of pet to return", required = true) @PathVariable("petId") petId: kotlin.Long + @NonNull @Parameter(description = "ID of pet to return", required = true) @PathVariable("petId") petId: kotlin.Long ): ResponseEntity { return ResponseEntity(service.getPetById(petId), HttpStatus.valueOf(200)) } @@ -206,7 +208,7 @@ class PetApiController(@Autowired(required = true) val service: PetApiService) { suspend fun uploadFile( @Parameter(description = "ID of pet to update", required = true) @PathVariable("petId") petId: kotlin.Long, @Parameter(description = "Additional data to pass to server") @Valid @RequestParam(value = "additionalMetadata", required = false) additionalMetadata: kotlin.String?, - @Parameter(description = "file to upload") @Valid @RequestPart("file", required = false) file: org.springframework.http.codec.multipart.Part? + @NonNull @Parameter(description = "file to upload") @Valid @RequestPart("file", required = false) file: org.springframework.http.codec.multipart.Part? ): ResponseEntity { return ResponseEntity(service.uploadFile(petId, additionalMetadata, file), HttpStatus.valueOf(200)) } diff --git a/samples/server/petstore/kotlin-springboot-reactive/src/main/resources/openapi.yaml b/samples/server/petstore/kotlin-springboot-reactive/src/main/resources/openapi.yaml index 885b64671054..218e008f9bad 100644 --- a/samples/server/petstore/kotlin-springboot-reactive/src/main/resources/openapi.yaml +++ b/samples/server/petstore/kotlin-springboot-reactive/src/main/resources/openapi.yaml @@ -45,6 +45,8 @@ paths: summary: Add a new pet to the store tags: - pet + x-extra-imports: org.springframework.lang.NonNull + x-request-body-extra-annotation: '@NonNull' put: description: "" externalDocs: @@ -97,6 +99,12 @@ paths: type: string type: array style: form + x-extra-imports: + - org.springframework.lang.NonNull + - org.springframework.lang.Nullable + x-field-extra-annotation: + - '@NonNull' + - '@Nullable' responses: "200": content: @@ -202,6 +210,8 @@ paths: format: int64 type: integer style: simple + x-extra-imports: org.springframework.lang.NonNull + x-field-extra-annotation: '@NonNull' responses: "200": content: @@ -795,6 +805,8 @@ components: description: file to upload format: binary type: string + x-extra-imports: org.springframework.lang.NonNull + x-field-extra-annotation: '@NonNull' type: object securitySchemes: petstore_auth: diff --git a/samples/server/petstore/springboot-reactive/src/main/java/org/openapitools/api/PetApi.java b/samples/server/petstore/springboot-reactive/src/main/java/org/openapitools/api/PetApi.java index fb35140489a7..6fe0984cde43 100644 --- a/samples/server/petstore/springboot-reactive/src/main/java/org/openapitools/api/PetApi.java +++ b/samples/server/petstore/springboot-reactive/src/main/java/org/openapitools/api/PetApi.java @@ -73,7 +73,7 @@ default PetApiDelegate getDelegate() { consumes = { "application/json", "application/xml" } ) default Mono> addPet( - @Parameter(name = "Pet", description = "Pet object that needs to be added to the store", required = true) @Valid @RequestBody Mono pet, + @Parameter(name = "Pet", description = "Pet object that needs to be added to the store", required = true)@org.springframework.lang.NonNull @Valid @RequestBody Mono pet, @Parameter(hidden = true) final ServerWebExchange exchange ) { return getDelegate().addPet(pet, exchange); @@ -147,7 +147,7 @@ default Mono> deletePet( produces = { "application/xml", "application/json" } ) default Mono>> findPetsByStatus( - @NotNull @Parameter(name = "status", description = "Status values that need to be considered for filter", required = true, in = ParameterIn.QUERY) @Valid @RequestParam(value = "status", required = true) List status, + @org.springframework.lang.NonNull @NotNull @Parameter(name = "status", description = "Status values that need to be considered for filter", required = true, in = ParameterIn.QUERY) @Valid @RequestParam(value = "status", required = true) List status, @Parameter(hidden = true) final ServerWebExchange exchange ) { return getDelegate().findPetsByStatus(status, exchange); @@ -228,7 +228,7 @@ default Mono>> findPetsByTags( produces = { "application/xml", "application/json" } ) default Mono> getPetById( - @Parameter(name = "petId", description = "ID of pet to return", required = true, in = ParameterIn.PATH) @PathVariable("petId") Long petId, + @org.springframework.lang.NonNull @Parameter(name = "petId", description = "ID of pet to return", required = true, in = ParameterIn.PATH) @PathVariable("petId") Long petId, @Parameter(hidden = true) final ServerWebExchange exchange ) { return getDelegate().getPetById(petId, exchange); diff --git a/samples/server/petstore/springboot-reactive/src/main/resources/openapi.yaml b/samples/server/petstore/springboot-reactive/src/main/resources/openapi.yaml index 53ec8083c77d..28da4d940572 100644 --- a/samples/server/petstore/springboot-reactive/src/main/resources/openapi.yaml +++ b/samples/server/petstore/springboot-reactive/src/main/resources/openapi.yaml @@ -36,6 +36,7 @@ paths: summary: Add a new pet to the store tags: - pet + x-request-body-extra-annotation: '@org.springframework.lang.NonNull' x-content-type: application/json x-accepts: - application/json @@ -87,6 +88,7 @@ paths: type: string type: array style: form + x-field-extra-annotation: '@org.springframework.lang.NonNull' responses: "200": content: @@ -213,6 +215,7 @@ paths: format: int64 type: integer style: simple + x-field-extra-annotation: '@org.springframework.lang.NonNull' responses: "200": content: diff --git a/samples/server/petstore/springboot-useoptional/src/main/java/org/openapitools/api/PetApi.java b/samples/server/petstore/springboot-useoptional/src/main/java/org/openapitools/api/PetApi.java index 433ff63e9a9f..f78e181bb0ed 100644 --- a/samples/server/petstore/springboot-useoptional/src/main/java/org/openapitools/api/PetApi.java +++ b/samples/server/petstore/springboot-useoptional/src/main/java/org/openapitools/api/PetApi.java @@ -73,7 +73,7 @@ default Optional getRequest() { consumes = { "application/json", "application/xml" } ) default ResponseEntity addPet( - @Parameter(name = "Pet", description = "Pet object that needs to be added to the store", required = true) @Valid @RequestBody Pet pet + @Parameter(name = "Pet", description = "Pet object that needs to be added to the store", required = true)@org.springframework.lang.NonNull @Valid @RequestBody Pet pet ) { return new ResponseEntity<>(HttpStatus.NOT_IMPLEMENTED); @@ -147,7 +147,7 @@ default ResponseEntity deletePet( produces = { "application/xml", "application/json" } ) default ResponseEntity> findPetsByStatus( - @NotNull @Parameter(name = "status", description = "Status values that need to be considered for filter", required = true, in = ParameterIn.QUERY) @Valid @RequestParam(value = "status", required = true) List status + @org.springframework.lang.NonNull @NotNull @Parameter(name = "status", description = "Status values that need to be considered for filter", required = true, in = ParameterIn.QUERY) @Valid @RequestParam(value = "status", required = true) List status ) { getRequest().ifPresent(request -> { for (MediaType mediaType: MediaType.parseMediaTypes(request.getHeader("Accept"))) { @@ -256,7 +256,7 @@ default ResponseEntity> findPetsByTags( produces = { "application/xml", "application/json" } ) default ResponseEntity getPetById( - @Parameter(name = "petId", description = "ID of pet to return", required = true, in = ParameterIn.PATH) @PathVariable("petId") Long petId + @org.springframework.lang.NonNull @Parameter(name = "petId", description = "ID of pet to return", required = true, in = ParameterIn.PATH) @PathVariable("petId") Long petId ) { getRequest().ifPresent(request -> { for (MediaType mediaType: MediaType.parseMediaTypes(request.getHeader("Accept"))) { diff --git a/samples/server/petstore/springboot-useoptional/src/main/resources/openapi.yaml b/samples/server/petstore/springboot-useoptional/src/main/resources/openapi.yaml index 53ec8083c77d..28da4d940572 100644 --- a/samples/server/petstore/springboot-useoptional/src/main/resources/openapi.yaml +++ b/samples/server/petstore/springboot-useoptional/src/main/resources/openapi.yaml @@ -36,6 +36,7 @@ paths: summary: Add a new pet to the store tags: - pet + x-request-body-extra-annotation: '@org.springframework.lang.NonNull' x-content-type: application/json x-accepts: - application/json @@ -87,6 +88,7 @@ paths: type: string type: array style: form + x-field-extra-annotation: '@org.springframework.lang.NonNull' responses: "200": content: @@ -213,6 +215,7 @@ paths: format: int64 type: integer style: simple + x-field-extra-annotation: '@org.springframework.lang.NonNull' responses: "200": content: From 704d4eb86b3537b94705494f9571721ef522a76a Mon Sep 17 00:00:00 2001 From: Jachym Metlicka Date: Wed, 26 Aug 2026 15:45:20 +0200 Subject: [PATCH 12/20] fix(java-spring): correct spacing around request-body extra annotation The x-field-extra-annotation section in JavaSpring/bodyParams.mustache emitted the annotation with a trailing space and no leading space. Because it sits directly after {{>paramDoc}} (which ends in ")" with no trailing space), the result glued the annotation to the @Parameter(...) close paren and produced a double space before @Valid, e.g. ...required = true)@org.springframework.lang.NonNull @Valid @RequestBody Switch to a leading-space style (matching the surrounding binding annotations) so the output is now: ...required = true) @org.springframework.lang.NonNull @Valid @RequestBody Only java-spring was affected; the kotlin-spring @Parameter block already ends with a trailing space, so its output was already correct. The section renders nothing when the extension is absent, so no other samples change. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/main/resources/JavaSpring/bodyParams.mustache | 2 +- .../src/main/java/org/openapitools/api/PetApi.java | 2 +- .../src/main/java/org/openapitools/api/PetApi.java | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/modules/openapi-generator/src/main/resources/JavaSpring/bodyParams.mustache b/modules/openapi-generator/src/main/resources/JavaSpring/bodyParams.mustache index 2ffb02d98100..258d3425292e 100644 --- a/modules/openapi-generator/src/main/resources/JavaSpring/bodyParams.mustache +++ b/modules/openapi-generator/src/main/resources/JavaSpring/bodyParams.mustache @@ -1 +1 @@ -{{#isBodyParam}}{{>paramDoc}}{{#vendorExtensions.x-field-extra-annotation}}{{{.}}} {{/vendorExtensions.x-field-extra-annotation}}{{#useBeanValidation}} {{>beanValidationBodyParams}}@Valid{{/useBeanValidation}} @RequestBody{{^required}}(required = false){{/required}} {{^reactive}}{{>nullableAnnotation}}{{>optionalDataType}}{{/reactive}}{{#reactive}}{{^isArray}}Mono<{{{dataType}}}>{{/isArray}}{{#isArray}}Flux<{{{baseType}}}>{{/isArray}}{{/reactive}} {{paramName}}{{/isBodyParam}} \ No newline at end of file +{{#isBodyParam}}{{>paramDoc}}{{#vendorExtensions.x-field-extra-annotation}} {{{.}}}{{/vendorExtensions.x-field-extra-annotation}}{{#useBeanValidation}} {{>beanValidationBodyParams}}@Valid{{/useBeanValidation}} @RequestBody{{^required}}(required = false){{/required}} {{^reactive}}{{>nullableAnnotation}}{{>optionalDataType}}{{/reactive}}{{#reactive}}{{^isArray}}Mono<{{{dataType}}}>{{/isArray}}{{#isArray}}Flux<{{{baseType}}}>{{/isArray}}{{/reactive}} {{paramName}}{{/isBodyParam}} \ No newline at end of file diff --git a/samples/server/petstore/springboot-reactive/src/main/java/org/openapitools/api/PetApi.java b/samples/server/petstore/springboot-reactive/src/main/java/org/openapitools/api/PetApi.java index 6fe0984cde43..e78bdca9637d 100644 --- a/samples/server/petstore/springboot-reactive/src/main/java/org/openapitools/api/PetApi.java +++ b/samples/server/petstore/springboot-reactive/src/main/java/org/openapitools/api/PetApi.java @@ -73,7 +73,7 @@ default PetApiDelegate getDelegate() { consumes = { "application/json", "application/xml" } ) default Mono> addPet( - @Parameter(name = "Pet", description = "Pet object that needs to be added to the store", required = true)@org.springframework.lang.NonNull @Valid @RequestBody Mono pet, + @Parameter(name = "Pet", description = "Pet object that needs to be added to the store", required = true) @org.springframework.lang.NonNull @Valid @RequestBody Mono pet, @Parameter(hidden = true) final ServerWebExchange exchange ) { return getDelegate().addPet(pet, exchange); diff --git a/samples/server/petstore/springboot-useoptional/src/main/java/org/openapitools/api/PetApi.java b/samples/server/petstore/springboot-useoptional/src/main/java/org/openapitools/api/PetApi.java index f78e181bb0ed..cf708161f613 100644 --- a/samples/server/petstore/springboot-useoptional/src/main/java/org/openapitools/api/PetApi.java +++ b/samples/server/petstore/springboot-useoptional/src/main/java/org/openapitools/api/PetApi.java @@ -73,7 +73,7 @@ default Optional getRequest() { consumes = { "application/json", "application/xml" } ) default ResponseEntity addPet( - @Parameter(name = "Pet", description = "Pet object that needs to be added to the store", required = true)@org.springframework.lang.NonNull @Valid @RequestBody Pet pet + @Parameter(name = "Pet", description = "Pet object that needs to be added to the store", required = true) @org.springframework.lang.NonNull @Valid @RequestBody Pet pet ) { return new ResponseEntity<>(HttpStatus.NOT_IMPLEMENTED); From 5f9764e6a9fe3b673dd358a97e0dd38317bb6285 Mon Sep 17 00:00:00 2001 From: Jachym Metlicka Date: Wed, 26 Aug 2026 15:52:48 +0200 Subject: [PATCH 13/20] test(spring): exercise --inject-operation-vendor-extensions in the samples Extend the four repointed samples so they also cover the side-loading path: an injectOperationVendorExtensions: block in each sample config (the config-file equivalent of the --inject-operation-vendor-extensions CLI flag) injects the extensions without editing the spec. Injected onto store operations (kept separate from the pet operations used for the spec-declared demo): - placeOrder: operation-level x-request-body-extra-annotation - getOrderById: parameter-level x-field-extra-annotation on the path param The java base spec names that path param order_id while the kotlin base spec names it orderId, so the two configs use different keys. This validates that the parameter segment is matched against the raw spec paramBaseName. Values use the fully-qualified @org.springframework.lang.NonNull, so the injected demo needs no imports and compiles on its own. Regenerated StoreApi for all four samples (java + kotlin, reactive + non-reactive); the injected annotations render before the placeOrder body binding (incl. Mono in the java reactive sample) and before the getOrderById path param. All four samples compile. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- bin/configs/kotlin-spring-boot-delegate.yaml | 3 +++ bin/configs/kotlin-spring-boot-reactive.yaml | 3 +++ bin/configs/spring-boot-reactive.yaml | 3 +++ bin/configs/spring-boot-useoptional.yaml | 3 +++ .../src/main/kotlin/org/openapitools/api/StoreApi.kt | 4 ++-- .../main/kotlin/org/openapitools/api/StoreApiController.kt | 4 ++-- .../src/main/java/org/openapitools/api/StoreApi.java | 4 ++-- .../src/main/java/org/openapitools/api/StoreApi.java | 4 ++-- 8 files changed, 20 insertions(+), 8 deletions(-) diff --git a/bin/configs/kotlin-spring-boot-delegate.yaml b/bin/configs/kotlin-spring-boot-delegate.yaml index 6253940f8d24..8f9767dd3d68 100644 --- a/bin/configs/kotlin-spring-boot-delegate.yaml +++ b/bin/configs/kotlin-spring-boot-delegate.yaml @@ -3,6 +3,9 @@ outputDir: samples/server/petstore/kotlin-springboot-delegate library: spring-boot inputSpec: modules/openapi-generator/src/test/resources/3_0/kotlin/petstore-with-extra-annotation.yaml templateDir: modules/openapi-generator/src/main/resources/kotlin-spring +injectOperationVendorExtensions: + placeOrder.x-request-body-extra-annotation: "@org.springframework.lang.NonNull" + getOrderById.orderId.x-field-extra-annotation: "@org.springframework.lang.NonNull" additionalProperties: generateJsonIncludeAnnotations: "true" generateJsonSetterNullsAnnotations: "true" diff --git a/bin/configs/kotlin-spring-boot-reactive.yaml b/bin/configs/kotlin-spring-boot-reactive.yaml index 1be36b4ef027..b2854ad14cd0 100644 --- a/bin/configs/kotlin-spring-boot-reactive.yaml +++ b/bin/configs/kotlin-spring-boot-reactive.yaml @@ -3,6 +3,9 @@ outputDir: samples/server/petstore/kotlin-springboot-reactive library: spring-boot inputSpec: modules/openapi-generator/src/test/resources/3_0/kotlin/petstore-with-extra-annotation.yaml templateDir: modules/openapi-generator/src/main/resources/kotlin-spring +injectOperationVendorExtensions: + placeOrder.x-request-body-extra-annotation: "@org.springframework.lang.NonNull" + getOrderById.orderId.x-field-extra-annotation: "@org.springframework.lang.NonNull" additionalProperties: generateJsonIncludeAnnotations: "true" generateJsonSetterNullsAnnotations: "true" diff --git a/bin/configs/spring-boot-reactive.yaml b/bin/configs/spring-boot-reactive.yaml index 71ad6c034831..164719b6d418 100644 --- a/bin/configs/spring-boot-reactive.yaml +++ b/bin/configs/spring-boot-reactive.yaml @@ -2,6 +2,9 @@ generatorName: spring outputDir: samples/server/petstore/springboot-reactive inputSpec: modules/openapi-generator/src/test/resources/3_0/spring/petstore-with-fake-endpoints-models-for-testing-extra-annotation.yaml templateDir: modules/openapi-generator/src/main/resources/JavaSpring +injectOperationVendorExtensions: + placeOrder.x-request-body-extra-annotation: "@org.springframework.lang.NonNull" + getOrderById.order_id.x-field-extra-annotation: "@org.springframework.lang.NonNull" additionalProperties: generateJsonIncludeAnnotations: "true" generateJsonSetterNullsAnnotations: "true" diff --git a/bin/configs/spring-boot-useoptional.yaml b/bin/configs/spring-boot-useoptional.yaml index 0342f84e0f46..dba0e460064e 100644 --- a/bin/configs/spring-boot-useoptional.yaml +++ b/bin/configs/spring-boot-useoptional.yaml @@ -2,6 +2,9 @@ generatorName: spring outputDir: samples/server/petstore/springboot-useoptional inputSpec: modules/openapi-generator/src/test/resources/3_0/spring/petstore-with-fake-endpoints-models-for-testing-extra-annotation.yaml templateDir: modules/openapi-generator/src/main/resources/JavaSpring +injectOperationVendorExtensions: + placeOrder.x-request-body-extra-annotation: "@org.springframework.lang.NonNull" + getOrderById.order_id.x-field-extra-annotation: "@org.springframework.lang.NonNull" additionalProperties: generateJsonIncludeAnnotations: "true" generateJsonSetterNullsAnnotations: "true" diff --git a/samples/server/petstore/kotlin-springboot-delegate/src/main/kotlin/org/openapitools/api/StoreApi.kt b/samples/server/petstore/kotlin-springboot-delegate/src/main/kotlin/org/openapitools/api/StoreApi.kt index 485509730aff..c54a211f7ad9 100644 --- a/samples/server/petstore/kotlin-springboot-delegate/src/main/kotlin/org/openapitools/api/StoreApi.kt +++ b/samples/server/petstore/kotlin-springboot-delegate/src/main/kotlin/org/openapitools/api/StoreApi.kt @@ -98,7 +98,7 @@ interface StoreApi { produces = ["application/xml", "application/json"] ) fun getOrderById( - @Min(value=1L) @Max(value=5L) @Parameter(description = "ID of pet that needs to be fetched", required = true) @PathVariable("orderId") orderId: kotlin.Long + @org.springframework.lang.NonNull @Min(value=1L) @Max(value=5L) @Parameter(description = "ID of pet that needs to be fetched", required = true) @PathVariable("orderId") orderId: kotlin.Long ): ResponseEntity { return getDelegate().getOrderById(orderId) } @@ -121,7 +121,7 @@ interface StoreApi { consumes = ["application/json"] ) fun placeOrder( - @Parameter(description = "order placed for purchasing the pet", required = true) @Valid @RequestBody order: Order + @Parameter(description = "order placed for purchasing the pet", required = true) @org.springframework.lang.NonNull @Valid @RequestBody order: Order ): ResponseEntity { return getDelegate().placeOrder(order) } diff --git a/samples/server/petstore/kotlin-springboot-reactive/src/main/kotlin/org/openapitools/api/StoreApiController.kt b/samples/server/petstore/kotlin-springboot-reactive/src/main/kotlin/org/openapitools/api/StoreApiController.kt index 03750f1b7577..e07127f95d15 100644 --- a/samples/server/petstore/kotlin-springboot-reactive/src/main/kotlin/org/openapitools/api/StoreApiController.kt +++ b/samples/server/petstore/kotlin-springboot-reactive/src/main/kotlin/org/openapitools/api/StoreApiController.kt @@ -87,7 +87,7 @@ class StoreApiController(@Autowired(required = true) val service: StoreApiServic produces = ["application/xml", "application/json"] ) suspend fun getOrderById( - @Min(value=1L) @Max(value=5L) @Parameter(description = "ID of pet that needs to be fetched", required = true) @PathVariable("orderId") orderId: kotlin.Long + @org.springframework.lang.NonNull @Min(value=1L) @Max(value=5L) @Parameter(description = "ID of pet that needs to be fetched", required = true) @PathVariable("orderId") orderId: kotlin.Long ): ResponseEntity { return ResponseEntity(service.getOrderById(orderId), HttpStatus.valueOf(200)) } @@ -108,7 +108,7 @@ class StoreApiController(@Autowired(required = true) val service: StoreApiServic consumes = ["application/json"] ) suspend fun placeOrder( - @Parameter(description = "order placed for purchasing the pet", required = true) @Valid @RequestBody order: Order + @Parameter(description = "order placed for purchasing the pet", required = true) @org.springframework.lang.NonNull @Valid @RequestBody order: Order ): ResponseEntity { return ResponseEntity(service.placeOrder(order), HttpStatus.valueOf(200)) } diff --git a/samples/server/petstore/springboot-reactive/src/main/java/org/openapitools/api/StoreApi.java b/samples/server/petstore/springboot-reactive/src/main/java/org/openapitools/api/StoreApi.java index 2c52ce3dddf3..08ef69a3df87 100644 --- a/samples/server/petstore/springboot-reactive/src/main/java/org/openapitools/api/StoreApi.java +++ b/samples/server/petstore/springboot-reactive/src/main/java/org/openapitools/api/StoreApi.java @@ -137,7 +137,7 @@ default Mono>> getInventory( produces = { "application/xml", "application/json" } ) default Mono> getOrderById( - @Min(value = 1L) @Max(value = 5L) @Parameter(name = "order_id", description = "ID of pet that needs to be fetched", required = true, in = ParameterIn.PATH) @PathVariable("order_id") Long orderId, + @org.springframework.lang.NonNull @Min(value = 1L) @Max(value = 5L) @Parameter(name = "order_id", description = "ID of pet that needs to be fetched", required = true, in = ParameterIn.PATH) @PathVariable("order_id") Long orderId, @Parameter(hidden = true) final ServerWebExchange exchange ) { return getDelegate().getOrderById(orderId, exchange); @@ -173,7 +173,7 @@ default Mono> getOrderById( consumes = { "application/json" } ) default Mono> placeOrder( - @Parameter(name = "Order", description = "order placed for purchasing the pet", required = true) @Valid @RequestBody Mono order, + @Parameter(name = "Order", description = "order placed for purchasing the pet", required = true) @org.springframework.lang.NonNull @Valid @RequestBody Mono order, @Parameter(hidden = true) final ServerWebExchange exchange ) { return getDelegate().placeOrder(order, exchange); diff --git a/samples/server/petstore/springboot-useoptional/src/main/java/org/openapitools/api/StoreApi.java b/samples/server/petstore/springboot-useoptional/src/main/java/org/openapitools/api/StoreApi.java index 0813a3064040..f394ffcaf309 100644 --- a/samples/server/petstore/springboot-useoptional/src/main/java/org/openapitools/api/StoreApi.java +++ b/samples/server/petstore/springboot-useoptional/src/main/java/org/openapitools/api/StoreApi.java @@ -138,7 +138,7 @@ default ResponseEntity> getInventory( produces = { "application/xml", "application/json" } ) default ResponseEntity getOrderById( - @Min(value = 1L) @Max(value = 5L) @Parameter(name = "order_id", description = "ID of pet that needs to be fetched", required = true, in = ParameterIn.PATH) @PathVariable("order_id") Long orderId + @org.springframework.lang.NonNull @Min(value = 1L) @Max(value = 5L) @Parameter(name = "order_id", description = "ID of pet that needs to be fetched", required = true, in = ParameterIn.PATH) @PathVariable("order_id") Long orderId ) { getRequest().ifPresent(request -> { for (MediaType mediaType: MediaType.parseMediaTypes(request.getHeader("Accept"))) { @@ -188,7 +188,7 @@ default ResponseEntity getOrderById( consumes = { "application/json" } ) default ResponseEntity placeOrder( - @Parameter(name = "Order", description = "order placed for purchasing the pet", required = true) @Valid @RequestBody Order order + @Parameter(name = "Order", description = "order placed for purchasing the pet", required = true) @org.springframework.lang.NonNull @Valid @RequestBody Order order ) { getRequest().ifPresent(request -> { for (MediaType mediaType: MediaType.parseMediaTypes(request.getHeader("Accept"))) { From 78406bc319bbaa0cfebacd6a0a1466cd4caafcc7 Mon Sep 17 00:00:00 2001 From: Jachym Metlicka Date: Wed, 26 Aug 2026 16:04:49 +0200 Subject: [PATCH 14/20] test(kotlin-spring): side-load x-extra-imports alongside injected annotations The kotlin generator collects x-extra-imports from operation and parameter vendor extensions, and those extensions can themselves be side-loaded. Inject x-extra-imports next to the injected annotations on the two kotlin samples so the injected annotation can use the short name instead of a fully-qualified one: placeOrder.x-request-body-extra-annotation: "@NonNull" placeOrder.x-extra-imports: org.springframework.lang.NonNull getOrderById.orderId.x-field-extra-annotation: "@NonNull" getOrderById.orderId.x-extra-imports: org.springframework.lang.NonNull Regenerated StoreApi for both kotlin samples: the injected import is added to the file and the short @NonNull renders on both the placeOrder body and the getOrderById path param. Both samples compile. The java samples keep the fully-qualified form, since java-spring has no x-extra-imports support. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- bin/configs/kotlin-spring-boot-delegate.yaml | 6 ++++-- bin/configs/kotlin-spring-boot-reactive.yaml | 6 ++++-- .../src/main/kotlin/org/openapitools/api/StoreApi.kt | 5 +++-- .../main/kotlin/org/openapitools/api/StoreApiController.kt | 5 +++-- 4 files changed, 14 insertions(+), 8 deletions(-) diff --git a/bin/configs/kotlin-spring-boot-delegate.yaml b/bin/configs/kotlin-spring-boot-delegate.yaml index 8f9767dd3d68..b887a2eb1721 100644 --- a/bin/configs/kotlin-spring-boot-delegate.yaml +++ b/bin/configs/kotlin-spring-boot-delegate.yaml @@ -4,8 +4,10 @@ library: spring-boot inputSpec: modules/openapi-generator/src/test/resources/3_0/kotlin/petstore-with-extra-annotation.yaml templateDir: modules/openapi-generator/src/main/resources/kotlin-spring injectOperationVendorExtensions: - placeOrder.x-request-body-extra-annotation: "@org.springframework.lang.NonNull" - getOrderById.orderId.x-field-extra-annotation: "@org.springframework.lang.NonNull" + placeOrder.x-request-body-extra-annotation: "@NonNull" + placeOrder.x-extra-imports: org.springframework.lang.NonNull + getOrderById.orderId.x-field-extra-annotation: "@NonNull" + getOrderById.orderId.x-extra-imports: org.springframework.lang.NonNull additionalProperties: generateJsonIncludeAnnotations: "true" generateJsonSetterNullsAnnotations: "true" diff --git a/bin/configs/kotlin-spring-boot-reactive.yaml b/bin/configs/kotlin-spring-boot-reactive.yaml index b2854ad14cd0..dd7c9d6ccc30 100644 --- a/bin/configs/kotlin-spring-boot-reactive.yaml +++ b/bin/configs/kotlin-spring-boot-reactive.yaml @@ -4,8 +4,10 @@ library: spring-boot inputSpec: modules/openapi-generator/src/test/resources/3_0/kotlin/petstore-with-extra-annotation.yaml templateDir: modules/openapi-generator/src/main/resources/kotlin-spring injectOperationVendorExtensions: - placeOrder.x-request-body-extra-annotation: "@org.springframework.lang.NonNull" - getOrderById.orderId.x-field-extra-annotation: "@org.springframework.lang.NonNull" + placeOrder.x-request-body-extra-annotation: "@NonNull" + placeOrder.x-extra-imports: org.springframework.lang.NonNull + getOrderById.orderId.x-field-extra-annotation: "@NonNull" + getOrderById.orderId.x-extra-imports: org.springframework.lang.NonNull additionalProperties: generateJsonIncludeAnnotations: "true" generateJsonSetterNullsAnnotations: "true" diff --git a/samples/server/petstore/kotlin-springboot-delegate/src/main/kotlin/org/openapitools/api/StoreApi.kt b/samples/server/petstore/kotlin-springboot-delegate/src/main/kotlin/org/openapitools/api/StoreApi.kt index c54a211f7ad9..9d821b638239 100644 --- a/samples/server/petstore/kotlin-springboot-delegate/src/main/kotlin/org/openapitools/api/StoreApi.kt +++ b/samples/server/petstore/kotlin-springboot-delegate/src/main/kotlin/org/openapitools/api/StoreApi.kt @@ -6,6 +6,7 @@ package org.openapitools.api import org.openapitools.model.Order +import org.springframework.lang.NonNull import io.swagger.v3.oas.annotations.* import io.swagger.v3.oas.annotations.enums.* import io.swagger.v3.oas.annotations.media.* @@ -98,7 +99,7 @@ interface StoreApi { produces = ["application/xml", "application/json"] ) fun getOrderById( - @org.springframework.lang.NonNull @Min(value=1L) @Max(value=5L) @Parameter(description = "ID of pet that needs to be fetched", required = true) @PathVariable("orderId") orderId: kotlin.Long + @NonNull @Min(value=1L) @Max(value=5L) @Parameter(description = "ID of pet that needs to be fetched", required = true) @PathVariable("orderId") orderId: kotlin.Long ): ResponseEntity { return getDelegate().getOrderById(orderId) } @@ -121,7 +122,7 @@ interface StoreApi { consumes = ["application/json"] ) fun placeOrder( - @Parameter(description = "order placed for purchasing the pet", required = true) @org.springframework.lang.NonNull @Valid @RequestBody order: Order + @Parameter(description = "order placed for purchasing the pet", required = true) @NonNull @Valid @RequestBody order: Order ): ResponseEntity { return getDelegate().placeOrder(order) } diff --git a/samples/server/petstore/kotlin-springboot-reactive/src/main/kotlin/org/openapitools/api/StoreApiController.kt b/samples/server/petstore/kotlin-springboot-reactive/src/main/kotlin/org/openapitools/api/StoreApiController.kt index e07127f95d15..f156825366fe 100644 --- a/samples/server/petstore/kotlin-springboot-reactive/src/main/kotlin/org/openapitools/api/StoreApiController.kt +++ b/samples/server/petstore/kotlin-springboot-reactive/src/main/kotlin/org/openapitools/api/StoreApiController.kt @@ -1,6 +1,7 @@ package org.openapitools.api import org.openapitools.model.Order +import org.springframework.lang.NonNull import io.swagger.v3.oas.annotations.* import io.swagger.v3.oas.annotations.enums.* import io.swagger.v3.oas.annotations.media.* @@ -87,7 +88,7 @@ class StoreApiController(@Autowired(required = true) val service: StoreApiServic produces = ["application/xml", "application/json"] ) suspend fun getOrderById( - @org.springframework.lang.NonNull @Min(value=1L) @Max(value=5L) @Parameter(description = "ID of pet that needs to be fetched", required = true) @PathVariable("orderId") orderId: kotlin.Long + @NonNull @Min(value=1L) @Max(value=5L) @Parameter(description = "ID of pet that needs to be fetched", required = true) @PathVariable("orderId") orderId: kotlin.Long ): ResponseEntity { return ResponseEntity(service.getOrderById(orderId), HttpStatus.valueOf(200)) } @@ -108,7 +109,7 @@ class StoreApiController(@Autowired(required = true) val service: StoreApiServic consumes = ["application/json"] ) suspend fun placeOrder( - @Parameter(description = "order placed for purchasing the pet", required = true) @org.springframework.lang.NonNull @Valid @RequestBody order: Order + @Parameter(description = "order placed for purchasing the pet", required = true) @NonNull @Valid @RequestBody order: Order ): ResponseEntity { return ResponseEntity(service.placeOrder(order), HttpStatus.valueOf(200)) } From 795320723a57393312f92f30e014b2b849a562b5 Mon Sep 17 00:00:00 2001 From: Jachym Metlicka Date: Wed, 26 Aug 2026 16:37:29 +0200 Subject: [PATCH 15/20] Add inject-vendor-extensions properties to Gradle and Maven plugins Bring CLI/plugin parity for the vendor-extension side-loading feature by exposing injectModelVendorExtensions and injectOperationVendorExtensions on both the Gradle and Maven plugins (previously only reachable via a configFile). - Gradle plugin: new mapProperty extension fields, plugin wiring, and the four GenerateTask mirror points (WorkParameters, execute, task inputs, parameters). - Maven plugin: two List KVP @Parameter fields with guarded applyInject*KvpList calls. - Docs: Gradle README.adoc and Maven README.md config tables; CLI help now clarifies that multiple annotations in a single value are space-separated, since an unquoted comma separates different injection targets. - Tests: Gradle ParameterWiringRegressionTest wiring test and Maven CodeGenMojoTest inject-vendor-extensions resource project asserting the injected request-body annotation renders on the generated Spring API. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../openapitools/codegen/cmd/Generate.java | 6 ++ .../README.adoc | 10 ++++ .../gradle/plugin/OpenApiGeneratorPlugin.kt | 2 + .../OpenApiGeneratorGenerateExtension.kt | 18 ++++++ .../gradle/plugin/tasks/GenerateTask.kt | 28 ++++++++++ .../kotlin/ParameterWiringRegressionTest.kt | 37 +++++++++++++ .../openapi-generator-maven-plugin/README.md | 2 + .../codegen/plugin/CodeGenMojo.java | 27 +++++++++ .../codegen/plugin/CodeGenMojoTest.java | 39 +++++++++++++ .../inject-vendor-extensions/pom.xml | 55 +++++++++++++++++++ 10 files changed, 224 insertions(+) create mode 100644 modules/openapi-generator-maven-plugin/src/test/resources/inject-vendor-extensions/pom.xml diff --git a/modules/openapi-generator-cli/src/main/java/org/openapitools/codegen/cmd/Generate.java b/modules/openapi-generator-cli/src/main/java/org/openapitools/codegen/cmd/Generate.java index 4fadcb966d7e..77f093a78fba 100644 --- a/modules/openapi-generator-cli/src/main/java/org/openapitools/codegen/cmd/Generate.java +++ b/modules/openapi-generator-cli/src/main/java/org/openapitools/codegen/cmd/Generate.java @@ -265,6 +265,9 @@ public class Generate extends OpenApiGeneratorCommand { description = "injects vendor extensions into model classes or their properties." + " Class-level format: ModelName.x-extension-name=value." + " Property-level format: ModelName.propertyBaseName.x-extension-name=value." + + " To supply multiple annotations in a single value, separate them with spaces" + + " (e.g. ModelName.x-class-extra-annotation=@Foo @Bar), not commas, since an" + + " unquoted comma is treated as a separator between different injection targets." + " You can also have multiple occurrences of this option.") private List injectModelVendorExtensions = new ArrayList<>(); @@ -274,6 +277,9 @@ public class Generate extends OpenApiGeneratorCommand { description = "injects vendor extensions into operations or their parameters." + " Operation-level format: operationId.x-extension-name=value." + " Parameter-level format: operationId.paramBaseName.x-extension-name=value." + + " To supply multiple annotations in a single value, separate them with spaces" + + " (e.g. operationId.x-operation-extra-annotation=@Foo @Bar), not commas, since an" + + " unquoted comma is treated as a separator between different injection targets." + " You can also have multiple occurrences of this option.") private List injectOperationVendorExtensions = new ArrayList<>(); diff --git a/modules/openapi-generator-gradle-plugin/README.adoc b/modules/openapi-generator-gradle-plugin/README.adoc index 66ee6acb357c..384981bbd64e 100644 --- a/modules/openapi-generator-gradle-plugin/README.adoc +++ b/modules/openapi-generator-gradle-plugin/README.adoc @@ -200,6 +200,16 @@ apply plugin: 'org.openapi.generator' |None |Sets specified global properties. +|injectModelVendorExtensions +|Map / Provider +|None +|Injects vendor extensions into models or their properties without editing the input spec. Keys use the form `modelName.x-extension-name` (model) or `modelName.propertyBaseName.x-extension-name` (model property). To supply multiple annotations in a single value, separate them with spaces (e.g. `@Foo @Bar`). + +|injectOperationVendorExtensions +|Map / Provider +|None +|Injects vendor extensions into operations or their parameters without editing the input spec. Keys use the form `operationId.x-extension-name` (operation) or `operationId.paramBaseName.x-extension-name` (parameter, matched by its raw spec name). To supply multiple annotations in a single value, separate them with spaces (e.g. `@Foo @Bar`). + |configFile |String / Provider |None diff --git a/modules/openapi-generator-gradle-plugin/src/main/kotlin/org/openapitools/generator/gradle/plugin/OpenApiGeneratorPlugin.kt b/modules/openapi-generator-gradle-plugin/src/main/kotlin/org/openapitools/generator/gradle/plugin/OpenApiGeneratorPlugin.kt index 62e2a439c481..bea647b27bd3 100644 --- a/modules/openapi-generator-gradle-plugin/src/main/kotlin/org/openapitools/generator/gradle/plugin/OpenApiGeneratorPlugin.kt +++ b/modules/openapi-generator-gradle-plugin/src/main/kotlin/org/openapitools/generator/gradle/plugin/OpenApiGeneratorPlugin.kt @@ -127,6 +127,8 @@ class OpenApiGeneratorPlugin : Plugin { templateResourcePath.set(generate.templateResourcePath) auth.set(generate.auth) globalProperties.set(generate.globalProperties) + injectModelVendorExtensions.set(generate.injectModelVendorExtensions) + injectOperationVendorExtensions.set(generate.injectOperationVendorExtensions) configFile.set(generate.configFile) skipOverwrite.set(generate.skipOverwrite) packageName.set(generate.packageName) diff --git a/modules/openapi-generator-gradle-plugin/src/main/kotlin/org/openapitools/generator/gradle/plugin/extensions/OpenApiGeneratorGenerateExtension.kt b/modules/openapi-generator-gradle-plugin/src/main/kotlin/org/openapitools/generator/gradle/plugin/extensions/OpenApiGeneratorGenerateExtension.kt index c2b2e9a23a17..cbd87c26436f 100644 --- a/modules/openapi-generator-gradle-plugin/src/main/kotlin/org/openapitools/generator/gradle/plugin/extensions/OpenApiGeneratorGenerateExtension.kt +++ b/modules/openapi-generator-gradle-plugin/src/main/kotlin/org/openapitools/generator/gradle/plugin/extensions/OpenApiGeneratorGenerateExtension.kt @@ -163,6 +163,24 @@ open class OpenApiGeneratorGenerateExtension(private val project: Project) { */ val globalProperties = project.objects.mapProperty() + /** + * Injects vendor extensions into models or their properties without editing the input spec. + * + * Keys use the form {@code modelName.x-extension-name} for a model or + * {@code modelName.propertyBaseName.x-extension-name} for a model property; the value is the + * extension value. + */ + val injectModelVendorExtensions = project.objects.mapProperty() + + /** + * Injects vendor extensions into operations or their parameters without editing the input spec. + * + * Keys use the form {@code operationId.x-extension-name} for an operation or + * {@code operationId.paramBaseName.x-extension-name} for a parameter (matched by its raw spec + * name); the value is the extension value. + */ + val injectOperationVendorExtensions = project.objects.mapProperty() + /** * Path to json configuration file. * File content should be in a json format { "optionKey":"optionValue", "optionKey1":"optionValue1"...} diff --git a/modules/openapi-generator-gradle-plugin/src/main/kotlin/org/openapitools/generator/gradle/plugin/tasks/GenerateTask.kt b/modules/openapi-generator-gradle-plugin/src/main/kotlin/org/openapitools/generator/gradle/plugin/tasks/GenerateTask.kt index 913ab08c96ed..efa9196b30d2 100644 --- a/modules/openapi-generator-gradle-plugin/src/main/kotlin/org/openapitools/generator/gradle/plugin/tasks/GenerateTask.kt +++ b/modules/openapi-generator-gradle-plugin/src/main/kotlin/org/openapitools/generator/gradle/plugin/tasks/GenerateTask.kt @@ -89,6 +89,8 @@ interface OpenApiWorkParameters : WorkParameters { val dryRun: Property val globalProperties: MapProperty + val injectModelVendorExtensions: MapProperty + val injectOperationVendorExtensions: MapProperty val instantiationTypes: MapProperty val importMappings: MapProperty val schemaMappings: MapProperty @@ -212,6 +214,8 @@ abstract class OpenApiWorkAction : WorkAction { // Maps and Lists params.globalProperties.orNull?.forEach { (k, v) -> configurator.addGlobalProperty(k, v) } + params.injectModelVendorExtensions.orNull?.forEach { (k, v) -> configurator.addInjectModelVendorExtension(k, v) } + params.injectOperationVendorExtensions.orNull?.forEach { (k, v) -> configurator.addInjectOperationVendorExtension(k, v) } params.instantiationTypes.orNull?.forEach { (k, v) -> configurator.addInstantiationType(k, v) } params.importMappings.orNull?.forEach { (k, v) -> configurator.addImportMapping(k, v) } params.schemaMappings.orNull?.forEach { (k, v) -> configurator.addSchemaMapping(k, v) } @@ -523,6 +527,28 @@ abstract class GenerateTask : DefaultTask() { @get:Input abstract val globalProperties: MapProperty + /** + * Injects vendor extensions into models or their properties without editing the input spec. + * + * Keys use the form {@code modelName.x-extension-name} for a model or + * {@code modelName.propertyBaseName.x-extension-name} for a model property; the value is the + * extension value. + */ + @get:Optional + @get:Input + abstract val injectModelVendorExtensions: MapProperty + + /** + * Injects vendor extensions into operations or their parameters without editing the input spec. + * + * Keys use the form {@code operationId.x-extension-name} for an operation or + * {@code operationId.paramBaseName.x-extension-name} for a parameter (matched by its raw spec + * name); the value is the extension value. + */ + @get:Optional + @get:Input + abstract val injectOperationVendorExtensions: MapProperty + /** * Path to json configuration file. * File content should be in a json format { "optionKey":"optionValue", "optionKey1":"optionValue1"...} @@ -1234,6 +1260,8 @@ abstract class GenerateTask : DefaultTask() { parameters.dryRun.set(dryRun) parameters.globalProperties.set(globalProperties) + parameters.injectModelVendorExtensions.set(injectModelVendorExtensions) + parameters.injectOperationVendorExtensions.set(injectOperationVendorExtensions) parameters.instantiationTypes.set(instantiationTypes) parameters.importMappings.set(importMappings) parameters.schemaMappings.set(schemaMappings) diff --git a/modules/openapi-generator-gradle-plugin/src/test/kotlin/ParameterWiringRegressionTest.kt b/modules/openapi-generator-gradle-plugin/src/test/kotlin/ParameterWiringRegressionTest.kt index d24892023f78..21ae5a32c3af 100644 --- a/modules/openapi-generator-gradle-plugin/src/test/kotlin/ParameterWiringRegressionTest.kt +++ b/modules/openapi-generator-gradle-plugin/src/test/kotlin/ParameterWiringRegressionTest.kt @@ -226,4 +226,41 @@ class ParameterWiringRegressionTest : TestBase() { assertEquals(TaskOutcome.SUCCESS, result.task(":openApiGenerate")?.outcome) } + + // ------------------------------------------------------------------------- + // injectOperationVendorExtensions (side-loading, was not exposed on the plugin) + // ------------------------------------------------------------------------- + + @Test + fun `injectOperationVendorExtensions is wired from extension to task`() { + // Before the fix injectOperationVendorExtensions was not exposed on the Gradle plugin, + // so vendor extensions could only be side-loaded via a configFile. We inject + // x-operation-extra-annotation onto the listPets operation and verify the Spring + // generator renders the injected annotation into the generated API interface. + val result = runOpenApiGenerate(""" + plugins { id 'org.openapi.generator' } + openApiGenerate { + generatorName = "spring" + inputSpec = file("spec.yaml").absolutePath + outputDir = file("build/spring").absolutePath + configOptions = ["interfaceOnly": "true"] + injectOperationVendorExtensions = ["listPets.x-operation-extra-annotation": "@Deprecated"] + } + """.trimIndent(), "spec.yaml" to "specs/petstore-v3.0.yaml") + + assertEquals( + TaskOutcome.SUCCESS, result.task(":openApiGenerate")?.outcome, + "Generation failed after wiring injectOperationVendorExtensions — check plugin wiring" + ) + + val generatedSrcRoot = File(temp, "build/spring/src/main/java") + val allJavaSources = generatedSrcRoot.walkTopDown().filter { it.extension == "java" }.toList() + assertTrue(allJavaSources.isNotEmpty(), "No Java source files were generated") + + val allSourceText = allJavaSources.joinToString("\n") { it.readText() } + assertTrue( + allSourceText.contains("@Deprecated"), + "Expected injected operation annotation '@Deprecated' not found in generated sources — injectOperationVendorExtensions may not be wired" + ) + } } diff --git a/modules/openapi-generator-maven-plugin/README.md b/modules/openapi-generator-maven-plugin/README.md index 4c5637e16a74..8d7376fcc2d7 100644 --- a/modules/openapi-generator-maven-plugin/README.md +++ b/modules/openapi-generator-maven-plugin/README.md @@ -94,6 +94,8 @@ mvn clean compile | `parameterNameMappings` | `openapi.generator.maven.plugin.parameterNameMappings` | specifies mappings between the parameter name and the new name in the format of param_a=first_parameter,param_b=second_parameter. https://openapi-generator.tech/docs/customization/#name-mapping | | `inlineSchemaNameMappings` | `openapi.generator.maven.plugin.inlineSchemaNameMappings` | specifies mappings between the inline schema name and the new name in the format of inline_object_2=Cat,inline_object_5=Bird. | | `inlineSchemaOptions` | `openapi.generator.maven.plugin.inlineSchemaOptions` | specifies the options used when naming inline schema in inline model resolver | +| `injectModelVendorExtensions` | `openapi.generator.maven.plugin.injectModelVendorExtensions` | injects vendor extensions into models or their properties without editing the input spec, in the format of `modelName.x-extension-name=value` (model) or `modelName.propertyBaseName.x-extension-name=value` (model property). To supply multiple annotations in a single value, separate them with spaces (e.g. `@Foo @Bar`), not commas, since an unquoted comma separates different injection targets. You can also have multiple occurrences of this option | +| `injectOperationVendorExtensions` | `openapi.generator.maven.plugin.injectOperationVendorExtensions` | injects vendor extensions into operations or their parameters without editing the input spec, in the format of `operationId.x-extension-name=value` (operation) or `operationId.paramBaseName.x-extension-name=value` (parameter, matched by its raw spec name). To supply multiple annotations in a single value, separate them with spaces (e.g. `@Foo @Bar`), not commas, since an unquoted comma separates different injection targets. You can also have multiple occurrences of this option | | `languageSpecificPrimitives` | `openapi.generator.maven.plugin.languageSpecificPrimitives` | specifies additional language specific primitive types in the format of type1,type2,type3,type3. For example: `String,boolean,Boolean,Double`. You can also have multiple occurrences of this option | | `additionalProperties` | `openapi.generator.maven.plugin.additionalProperties` | sets additional properties that can be referenced by the mustache templates in the format of name=value,name=value. You can also have multiple occurrences of this option | | `serverVariableOverrides` | `openapi.generator.maven.plugin.serverVariableOverrides` | A map of server variable overrides for specs that support server URL templating | diff --git a/modules/openapi-generator-maven-plugin/src/main/java/org/openapitools/codegen/plugin/CodeGenMojo.java b/modules/openapi-generator-maven-plugin/src/main/java/org/openapitools/codegen/plugin/CodeGenMojo.java index 084fab18b5c4..358db2f86700 100644 --- a/modules/openapi-generator-maven-plugin/src/main/java/org/openapitools/codegen/plugin/CodeGenMojo.java +++ b/modules/openapi-generator-maven-plugin/src/main/java/org/openapitools/codegen/plugin/CodeGenMojo.java @@ -446,6 +446,23 @@ public class CodeGenMojo extends AbstractMojo { @Parameter(name = "operationIdNameMappings", property = "openapi.generator.maven.plugin.operationIdNameMappings") private List operationIdNameMappings; + /** + * A map of vendor extensions to inject into models or their properties, without editing the input + * spec. Each entry is of the form {@code modelName.x-extension-name=value} for a model or + * {@code modelName.propertyBaseName.x-extension-name=value} for a model property. + */ + @Parameter(name = "injectModelVendorExtensions", property = "openapi.generator.maven.plugin.injectModelVendorExtensions") + private List injectModelVendorExtensions; + + /** + * A map of vendor extensions to inject into operations or their parameters, without editing the + * input spec. Each entry is of the form {@code operationId.x-extension-name=value} for an operation + * or {@code operationId.paramBaseName.x-extension-name=value} for a parameter (matched by its raw + * spec name). + */ + @Parameter(name = "injectOperationVendorExtensions", property = "openapi.generator.maven.plugin.injectOperationVendorExtensions") + private List injectOperationVendorExtensions; + /** * A set of rules for OpenAPI normalizer */ @@ -1040,6 +1057,16 @@ public void execute() throws MojoExecutionException { applyOperationIdNameMappingsKvpList(operationIdNameMappings, configurator); } + // Apply Inject Model Vendor Extensions + if (injectModelVendorExtensions != null && (configOptions == null || !configOptions.containsKey("inject-model-vendor-extensions"))) { + applyInjectModelVendorExtensionsKvpList(injectModelVendorExtensions, configurator); + } + + // Apply Inject Operation Vendor Extensions + if (injectOperationVendorExtensions != null && (configOptions == null || !configOptions.containsKey("inject-operation-vendor-extensions"))) { + applyInjectOperationVendorExtensionsKvpList(injectOperationVendorExtensions, configurator); + } + // Apply OpenAPI normalizer rules if (openapiNormalizer != null && (configOptions == null || !configOptions.containsKey("openapi-normalizer"))) { applyOpenapiNormalizerKvpList(openapiNormalizer, configurator); diff --git a/modules/openapi-generator-maven-plugin/src/test/java/org/openapitools/codegen/plugin/CodeGenMojoTest.java b/modules/openapi-generator-maven-plugin/src/test/java/org/openapitools/codegen/plugin/CodeGenMojoTest.java index 18b6df86f1e8..814065cdb4b0 100644 --- a/modules/openapi-generator-maven-plugin/src/test/java/org/openapitools/codegen/plugin/CodeGenMojoTest.java +++ b/modules/openapi-generator-maven-plugin/src/test/java/org/openapitools/codegen/plugin/CodeGenMojoTest.java @@ -101,6 +101,45 @@ public void testMinimalUpdateConfiguration() throws Exception { assertEquals(Boolean.TRUE, getVariableValueFromObject(mojo, "minimalUpdate")); } + @SuppressWarnings("unchecked") + public void testInjectOperationVendorExtensions() throws Exception { + // GIVEN + final Path tempDir = newTempFolder(); + CodeGenMojo mojo = loadMojo(tempDir, "src/test/resources/inject-vendor-extensions", null, "executionId"); + + // WHEN + mojo.execute(); + + // THEN + // The configured parameter is bound onto the mojo. + List injected = (List) getVariableValueFromObject(mojo, "injectOperationVendorExtensions"); + assertNotNull(injected); + assertEquals(1, injected.size()); + assertEquals("addPet.x-request-body-extra-annotation=@com.example.MyValidation", injected.get(0)); + + // The injected request-body annotation is merged into the body parameter and rendered + // on the generated Spring API interface (verifies the full inject -> merge -> render path). + final Path generatedDir = tempDir.resolve("target/generated-sources/inject-vendor-extensions"); + String allSources; + try (Stream files = Files.walk(generatedDir)) { + allSources = files + .filter(Files::isRegularFile) + .filter(path -> path.getFileName().toString().endsWith(".java")) + .map(path -> { + try { + return Files.readString(path); + } catch (IOException e) { + throw new RuntimeException(e); + } + }) + .collect(Collectors.joining("\n")); + } + assertTrue( + "Injected request-body annotation '@com.example.MyValidation' should appear in the generated sources", + allSources.contains("@com.example.MyValidation") + ); + } + public void testHashGenerationFileContainsExecutionId() throws Exception { // GIVEN final Path tempDir = newTempFolder(); diff --git a/modules/openapi-generator-maven-plugin/src/test/resources/inject-vendor-extensions/pom.xml b/modules/openapi-generator-maven-plugin/src/test/resources/inject-vendor-extensions/pom.xml new file mode 100644 index 000000000000..48c9954bf4fa --- /dev/null +++ b/modules/openapi-generator-maven-plugin/src/test/resources/inject-vendor-extensions/pom.xml @@ -0,0 +1,55 @@ + + + + 4.0.0 + inject.vendor.extensions.test + inject-vendor-extensions-test + jar + 1.0.0-SNAPSHOT + OpenAPI Generator Inject Vendor Extensions Test + https://openapi-generator.tech/ + + inject-vendor-extensions-test + + + org.openapitools + openapi-generator-maven-plugin + + petstore-on-classpath.yaml + spring + ${basedir}/target/generated-sources/inject-vendor-extensions + + true + true + + + addPet.x-request-body-extra-annotation=@com.example.MyValidation + + + + + executionId + generate-sources + + generate + + + + + + + From 8902338e2f454a01277af9066ad1c4e8d72c933b Mon Sep 17 00:00:00 2001 From: Jachym Metlicka Date: Wed, 26 Aug 2026 16:39:34 +0200 Subject: [PATCH 16/20] docs(gradle): expand inject-vendor-extensions note with space example Clarify in the Gradle plugin README that multiple annotations in a single injected value are space-separated (emitted verbatim, safe inside parentheses), with a groovy example. Note that commas inside a value need no escaping in the Gradle map form, unlike the comma-separated CLI/Maven KVP form. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../README.adoc | 31 +++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/modules/openapi-generator-gradle-plugin/README.adoc b/modules/openapi-generator-gradle-plugin/README.adoc index 384981bbd64e..76b3e5e853ef 100644 --- a/modules/openapi-generator-gradle-plugin/README.adoc +++ b/modules/openapi-generator-gradle-plugin/README.adoc @@ -203,12 +203,12 @@ apply plugin: 'org.openapi.generator' |injectModelVendorExtensions |Map / Provider |None -|Injects vendor extensions into models or their properties without editing the input spec. Keys use the form `modelName.x-extension-name` (model) or `modelName.propertyBaseName.x-extension-name` (model property). To supply multiple annotations in a single value, separate them with spaces (e.g. `@Foo @Bar`). +|Injects vendor extensions into models or their properties without editing the input spec. Keys use the form `modelName.x-extension-name` (model) or `modelName.propertyBaseName.x-extension-name` (model property). See the note below on supplying multiple annotations in a single value. |injectOperationVendorExtensions |Map / Provider |None -|Injects vendor extensions into operations or their parameters without editing the input spec. Keys use the form `operationId.x-extension-name` (operation) or `operationId.paramBaseName.x-extension-name` (parameter, matched by its raw spec name). To supply multiple annotations in a single value, separate them with spaces (e.g. `@Foo @Bar`). +|Injects vendor extensions into operations or their parameters without editing the input spec. Keys use the form `operationId.x-extension-name` (operation) or `operationId.paramBaseName.x-extension-name` (parameter, matched by its raw spec name). See the note below on supplying multiple annotations in a single value. |configFile |String / Provider @@ -574,6 +574,33 @@ models: "User:Pet" ---- ==== +[NOTE] +==== +`injectModelVendorExtensions` and `injectOperationVendorExtensions` let you attach vendor extensions +(for example the Spring extra-annotation extensions) to models, properties, operations, or parameters +without editing the input spec. The map value is emitted verbatim, so when the extension renders +annotations you write them exactly as they appear in source. + +To attach *multiple* annotations to the same target, put them in a single value separated by *spaces* +(that is how annotations are written in Java/Kotlin), not by commas. Spaces are safe anywhere in the +value, including inside parentheses, so annotations with arguments work as-is: +[source,groovy] +---- +openApiGenerate { +// other settings omitted +injectOperationVendorExtensions.set([ + // two annotations on one request body: space-separated, emitted verbatim + "addPet.x-request-body-extra-annotation": "@com.example.MyValidation @io.swagger.v3.oas.annotations.media.Schema(description = \"a pet\")", + // annotation on a single path/query parameter (matched by its raw spec name) + "getPetById.petId.x-field-extra-annotation": "@com.example.ValidPetId" +]) +} +---- +Because each map entry is a separate `key: value` pair, there is no need to escape commas that appear +*inside* a single value here (unlike the comma-separated CLI/Maven form). A value such as +`@Size(min = 0, max = 10)` therefore works directly from Gradle. +==== + === openApiValidate .Options From 8b93f4136deed4ce7e49553351e599f2cb5fd61c Mon Sep 17 00:00:00 2001 From: Jachym Metlicka Date: Wed, 26 Aug 2026 17:07:20 +0200 Subject: [PATCH 17/20] docs: clarify inject-vendor-extensions as a generic string-typed mechanism Reword the Gradle/Maven/CLI docs to describe injectModelVendorExtensions and injectOperationVendorExtensions as a generic vendor-extension mechanism: values are strings, applied at render time, and overwrite existing values; missing targets are a silent no-op. The space-vs-comma guidance is scoped to the extra-annotation extensions. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../openapitools/codegen/cmd/Generate.java | 14 ++++---- .../README.adoc | 34 ++++++------------- .../openapi-generator-maven-plugin/README.md | 4 +-- 3 files changed, 21 insertions(+), 31 deletions(-) diff --git a/modules/openapi-generator-cli/src/main/java/org/openapitools/codegen/cmd/Generate.java b/modules/openapi-generator-cli/src/main/java/org/openapitools/codegen/cmd/Generate.java index 77f093a78fba..d72176238c08 100644 --- a/modules/openapi-generator-cli/src/main/java/org/openapitools/codegen/cmd/Generate.java +++ b/modules/openapi-generator-cli/src/main/java/org/openapitools/codegen/cmd/Generate.java @@ -265,9 +265,10 @@ public class Generate extends OpenApiGeneratorCommand { description = "injects vendor extensions into model classes or their properties." + " Class-level format: ModelName.x-extension-name=value." + " Property-level format: ModelName.propertyBaseName.x-extension-name=value." - + " To supply multiple annotations in a single value, separate them with spaces" - + " (e.g. ModelName.x-class-extra-annotation=@Foo @Bar), not commas, since an" - + " unquoted comma is treated as a separator between different injection targets." + + " Values are strings, applied at render time, and overwrite existing values." + + " For the extra-annotation extensions, separate multiple annotations in a single" + + " value with spaces (e.g. ModelName.x-class-extra-annotation=@Foo @Bar), not" + + " commas, since an unquoted comma separates different injection targets." + " You can also have multiple occurrences of this option.") private List injectModelVendorExtensions = new ArrayList<>(); @@ -277,9 +278,10 @@ public class Generate extends OpenApiGeneratorCommand { description = "injects vendor extensions into operations or their parameters." + " Operation-level format: operationId.x-extension-name=value." + " Parameter-level format: operationId.paramBaseName.x-extension-name=value." - + " To supply multiple annotations in a single value, separate them with spaces" - + " (e.g. operationId.x-operation-extra-annotation=@Foo @Bar), not commas, since an" - + " unquoted comma is treated as a separator between different injection targets." + + " Values are strings, applied at render time, and overwrite existing values." + + " For the extra-annotation extensions, separate multiple annotations in a single" + + " value with spaces (e.g. operationId.x-operation-extra-annotation=@Foo @Bar), not" + + " commas, since an unquoted comma separates different injection targets." + " You can also have multiple occurrences of this option.") private List injectOperationVendorExtensions = new ArrayList<>(); diff --git a/modules/openapi-generator-gradle-plugin/README.adoc b/modules/openapi-generator-gradle-plugin/README.adoc index 76b3e5e853ef..dc3dd0cf39f7 100644 --- a/modules/openapi-generator-gradle-plugin/README.adoc +++ b/modules/openapi-generator-gradle-plugin/README.adoc @@ -576,29 +576,17 @@ models: "User:Pet" [NOTE] ==== -`injectModelVendorExtensions` and `injectOperationVendorExtensions` let you attach vendor extensions -(for example the Spring extra-annotation extensions) to models, properties, operations, or parameters -without editing the input spec. The map value is emitted verbatim, so when the extension renders -annotations you write them exactly as they appear in source. - -To attach *multiple* annotations to the same target, put them in a single value separated by *spaces* -(that is how annotations are written in Java/Kotlin), not by commas. Spaces are safe anywhere in the -value, including inside parentheses, so annotations with arguments work as-is: -[source,groovy] ----- -openApiGenerate { -// other settings omitted -injectOperationVendorExtensions.set([ - // two annotations on one request body: space-separated, emitted verbatim - "addPet.x-request-body-extra-annotation": "@com.example.MyValidation @io.swagger.v3.oas.annotations.media.Schema(description = \"a pet\")", - // annotation on a single path/query parameter (matched by its raw spec name) - "getPetById.petId.x-field-extra-annotation": "@com.example.ValidPetId" -]) -} ----- -Because each map entry is a separate `key: value` pair, there is no need to escape commas that appear -*inside* a single value here (unlike the comma-separated CLI/Maven form). A value such as -`@Size(min = 0, max = 10)` therefore works directly from Gradle. +`injectModelVendorExtensions` and `injectOperationVendorExtensions` are a generic mechanism for +setting *any* vendor extension on a model, property, operation, or parameter without editing the spec. +Keep in mind: + +* Values are always strings, applied *late* (at codegen/render time) and **overwrite** any existing + value; missing targets are a silent no-op. It is best suited to extensions consumed by templates, + such as the Spring extra-annotation extensions. +* For the extra-annotation extensions, put multiple annotations in one value separated by *spaces* + (as in source), e.g. `"addPet.x-request-body-extra-annotation": "@com.example.MyValidation @Valid"`. + In the Gradle map form commas inside a value need no escaping, so `@Size(min = 0, max = 10)` works + directly (unlike the comma-separated CLI/Maven form). ==== === openApiValidate diff --git a/modules/openapi-generator-maven-plugin/README.md b/modules/openapi-generator-maven-plugin/README.md index 8d7376fcc2d7..64ab488a4d2b 100644 --- a/modules/openapi-generator-maven-plugin/README.md +++ b/modules/openapi-generator-maven-plugin/README.md @@ -94,8 +94,8 @@ mvn clean compile | `parameterNameMappings` | `openapi.generator.maven.plugin.parameterNameMappings` | specifies mappings between the parameter name and the new name in the format of param_a=first_parameter,param_b=second_parameter. https://openapi-generator.tech/docs/customization/#name-mapping | | `inlineSchemaNameMappings` | `openapi.generator.maven.plugin.inlineSchemaNameMappings` | specifies mappings between the inline schema name and the new name in the format of inline_object_2=Cat,inline_object_5=Bird. | | `inlineSchemaOptions` | `openapi.generator.maven.plugin.inlineSchemaOptions` | specifies the options used when naming inline schema in inline model resolver | -| `injectModelVendorExtensions` | `openapi.generator.maven.plugin.injectModelVendorExtensions` | injects vendor extensions into models or their properties without editing the input spec, in the format of `modelName.x-extension-name=value` (model) or `modelName.propertyBaseName.x-extension-name=value` (model property). To supply multiple annotations in a single value, separate them with spaces (e.g. `@Foo @Bar`), not commas, since an unquoted comma separates different injection targets. You can also have multiple occurrences of this option | -| `injectOperationVendorExtensions` | `openapi.generator.maven.plugin.injectOperationVendorExtensions` | injects vendor extensions into operations or their parameters without editing the input spec, in the format of `operationId.x-extension-name=value` (operation) or `operationId.paramBaseName.x-extension-name=value` (parameter, matched by its raw spec name). To supply multiple annotations in a single value, separate them with spaces (e.g. `@Foo @Bar`), not commas, since an unquoted comma separates different injection targets. You can also have multiple occurrences of this option | +| `injectModelVendorExtensions` | `openapi.generator.maven.plugin.injectModelVendorExtensions` | sets vendor extensions on a model or its properties without editing the spec, as `modelName.x-extension-name=value` (model) or `modelName.propertyBaseName.x-extension-name=value` (property). Values are strings, applied at render time, and overwrite existing values. For the extra-annotation extensions, separate multiple annotations in one value with spaces (`@Foo @Bar`), not commas (an unquoted comma separates injection targets). You can also have multiple occurrences of this option | +| `injectOperationVendorExtensions` | `openapi.generator.maven.plugin.injectOperationVendorExtensions` | sets vendor extensions on an operation or its parameters without editing the spec, as `operationId.x-extension-name=value` (operation) or `operationId.paramBaseName.x-extension-name=value` (parameter, matched by its raw spec name). Values are strings, applied at render time, and overwrite existing values. For the extra-annotation extensions, separate multiple annotations in one value with spaces (`@Foo @Bar`), not commas (an unquoted comma separates injection targets). You can also have multiple occurrences of this option | | `languageSpecificPrimitives` | `openapi.generator.maven.plugin.languageSpecificPrimitives` | specifies additional language specific primitive types in the format of type1,type2,type3,type3. For example: `String,boolean,Boolean,Double`. You can also have multiple occurrences of this option | | `additionalProperties` | `openapi.generator.maven.plugin.additionalProperties` | sets additional properties that can be referenced by the mustache templates in the format of name=value,name=value. You can also have multiple occurrences of this option | | `serverVariableOverrides` | `openapi.generator.maven.plugin.serverVariableOverrides` | A map of server variable overrides for specs that support server URL templating | From e730b8863e567fdf0904f13fd72881fb16bc59b8 Mon Sep 17 00:00:00 2001 From: Jachym Metlicka Date: Wed, 26 Aug 2026 19:24:55 +0200 Subject: [PATCH 18/20] test(maven): assert injected annotation is scoped to addPet body param Strengthen testInjectOperationVendorExtensions so it no longer passes on a mere substring match anywhere in the generated sources. It now locates PetApi.java, asserts the injected @com.example.MyValidation sits on addPet's @RequestBody body parameter, asserts a control operation (updatePet, which also has a body but no injection) does not receive it, and asserts the annotation appears exactly once. This guards the operation-scoped merge against non-selective or wrong-target regressions. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../codegen/plugin/CodeGenMojoTest.java | 66 ++++++++++++++----- 1 file changed, 51 insertions(+), 15 deletions(-) diff --git a/modules/openapi-generator-maven-plugin/src/test/java/org/openapitools/codegen/plugin/CodeGenMojoTest.java b/modules/openapi-generator-maven-plugin/src/test/java/org/openapitools/codegen/plugin/CodeGenMojoTest.java index 814065cdb4b0..ad8154199c20 100644 --- a/modules/openapi-generator-maven-plugin/src/test/java/org/openapitools/codegen/plugin/CodeGenMojoTest.java +++ b/modules/openapi-generator-maven-plugin/src/test/java/org/openapitools/codegen/plugin/CodeGenMojoTest.java @@ -117,27 +117,63 @@ public void testInjectOperationVendorExtensions() throws Exception { assertEquals(1, injected.size()); assertEquals("addPet.x-request-body-extra-annotation=@com.example.MyValidation", injected.get(0)); - // The injected request-body annotation is merged into the body parameter and rendered - // on the generated Spring API interface (verifies the full inject -> merge -> render path). + // The injected request-body annotation is merged into the body parameter and rendered on the + // generated Spring API interface. Assert it lands specifically on addPet's body parameter and + // is NOT applied to a control operation (updatePet) that also has a request body but no + // injection, so the test fails if the merge became non-selective or targeted the wrong method. final Path generatedDir = tempDir.resolve("target/generated-sources/inject-vendor-extensions"); - String allSources; + final Path petApi; try (Stream files = Files.walk(generatedDir)) { - allSources = files + petApi = files .filter(Files::isRegularFile) - .filter(path -> path.getFileName().toString().endsWith(".java")) - .map(path -> { - try { - return Files.readString(path); - } catch (IOException e) { - throw new RuntimeException(e); - } - }) - .collect(Collectors.joining("\n")); + .filter(path -> path.getFileName().toString().equals("PetApi.java")) + .findFirst() + .orElseThrow(() -> new AssertionError("PetApi.java was not generated under " + generatedDir)); } + final String petApiSource = Files.readString(petApi); + + final String addPetSignature = extractMethodSignature(petApiSource, "addPet"); assertTrue( - "Injected request-body annotation '@com.example.MyValidation' should appear in the generated sources", - allSources.contains("@com.example.MyValidation") + "Injected annotation should be rendered on addPet's request body parameter", + addPetSignature.contains("@com.example.MyValidation") + && addPetSignature.contains("@RequestBody") + ); + + final String updatePetSignature = extractMethodSignature(petApiSource, "updatePet"); + assertFalse( + "Injected annotation must not leak onto the control operation updatePet", + updatePetSignature.contains("@com.example.MyValidation") ); + + // Belt and suspenders: the injected annotation must appear exactly once in the whole + // interface, proving it was not applied to every operation. + int occurrences = petApiSource.split("@com.example.MyValidation", -1).length - 1; + assertEquals("Injected annotation should appear exactly once (only on addPet)", 1, occurrences); + } + + /** + * Extracts a method's declaration up to and including its balanced parameter-list parentheses, + * so parameter annotations can be asserted per-method. Assumes {@code methodName(} appears only + * at the method declaration (true for the generated Spring interface). + */ + private static String extractMethodSignature(String source, String methodName) { + int start = source.indexOf(methodName + "("); + assertTrue("Method '" + methodName + "' not found in generated source", start >= 0); + int depth = 0; + int i = start + methodName.length(); + for (; i < source.length(); i++) { + char c = source.charAt(i); + if (c == '(') { + depth++; + } else if (c == ')') { + depth--; + if (depth == 0) { + i++; + break; + } + } + } + return source.substring(start, i); } public void testHashGenerationFileContainsExecutionId() throws Exception { From 8e9585d85b64a126bf81f0e159b7f69199b0e987 Mon Sep 17 00:00:00 2001 From: Jachym Metlicka Date: Thu, 27 Aug 2026 01:42:31 +0200 Subject: [PATCH 19/20] Remove dead configOptions guards on maven inject-vendor-extensions params The inject-model/operation-vendor-extensions settings are top-level configurator options, not per-generator CliOptions, so a key placed in is never forwarded (CodeGenMojo only forwards keys matching config.cliOptions(), plus SOURCE_FOLDER). The guard therefore protected against an unreachable double-application. Simplify to a plain null check. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../org/openapitools/codegen/plugin/CodeGenMojo.java | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/modules/openapi-generator-maven-plugin/src/main/java/org/openapitools/codegen/plugin/CodeGenMojo.java b/modules/openapi-generator-maven-plugin/src/main/java/org/openapitools/codegen/plugin/CodeGenMojo.java index 358db2f86700..3be4972e2b16 100644 --- a/modules/openapi-generator-maven-plugin/src/main/java/org/openapitools/codegen/plugin/CodeGenMojo.java +++ b/modules/openapi-generator-maven-plugin/src/main/java/org/openapitools/codegen/plugin/CodeGenMojo.java @@ -1057,13 +1057,16 @@ public void execute() throws MojoExecutionException { applyOperationIdNameMappingsKvpList(operationIdNameMappings, configurator); } - // Apply Inject Model Vendor Extensions - if (injectModelVendorExtensions != null && (configOptions == null || !configOptions.containsKey("inject-model-vendor-extensions"))) { + // Apply Inject Model Vendor Extensions. + // Unlike the legacy mapping options above, there is no configOptions compatibility path + // for this setting (it is not a generator CliOption), so no configOptions guard is needed. + if (injectModelVendorExtensions != null) { applyInjectModelVendorExtensionsKvpList(injectModelVendorExtensions, configurator); } - // Apply Inject Operation Vendor Extensions - if (injectOperationVendorExtensions != null && (configOptions == null || !configOptions.containsKey("inject-operation-vendor-extensions"))) { + // Apply Inject Operation Vendor Extensions. + // No configOptions compatibility path exists for this setting either, so no guard is needed. + if (injectOperationVendorExtensions != null) { applyInjectOperationVendorExtensionsKvpList(injectOperationVendorExtensions, configurator); } From 578b006967de555cc267ab96e4c29e5e279e88ff Mon Sep 17 00:00:00 2001 From: Jachym Metlicka Date: Thu, 27 Aug 2026 01:44:02 +0200 Subject: [PATCH 20/20] Remove dead configOptions guards on maven *-name-mappings params name-mappings, parameter-name-mappings, model-name-mappings, enum-name-mappings and operation-id-name-mappings are not generator CliOptions and have no configOptions backwards-compat reader, so their configOptions.containsKey(...) guards protected against an unreachable double-application. Simplify to plain null checks. The inline-schema-options guard is retained because it does have a compat reader. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../openapitools/codegen/plugin/CodeGenMojo.java | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/modules/openapi-generator-maven-plugin/src/main/java/org/openapitools/codegen/plugin/CodeGenMojo.java b/modules/openapi-generator-maven-plugin/src/main/java/org/openapitools/codegen/plugin/CodeGenMojo.java index 3be4972e2b16..92b78b0625e4 100644 --- a/modules/openapi-generator-maven-plugin/src/main/java/org/openapitools/codegen/plugin/CodeGenMojo.java +++ b/modules/openapi-generator-maven-plugin/src/main/java/org/openapitools/codegen/plugin/CodeGenMojo.java @@ -1032,28 +1032,30 @@ public void execute() throws MojoExecutionException { applyInlineSchemaOptionsKvpList(inlineSchemaOptions, configurator); } - // Apply Name Mappings - if (nameMappings != null && (configOptions == null || !configOptions.containsKey("name-mappings"))) { + // Apply Name Mappings. + // These *-name-mappings options are not generator CliOptions and have no configOptions + // compatibility reader above, so a configOptions guard would protect nothing. + if (nameMappings != null) { applyNameMappingsKvpList(nameMappings, configurator); } // Apply Parameter Name Mappings - if (parameterNameMappings != null && (configOptions == null || !configOptions.containsKey("parameter-name-mappings"))) { + if (parameterNameMappings != null) { applyParameterNameMappingsKvpList(parameterNameMappings, configurator); } // Apply Model Name Mappings - if (modelNameMappings != null && (configOptions == null || !configOptions.containsKey("model-name-mappings"))) { + if (modelNameMappings != null) { applyModelNameMappingsKvpList(modelNameMappings, configurator); } // Apply Enum Name Mappings - if (enumNameMappings != null && (configOptions == null || !configOptions.containsKey("enum-name-mappings"))) { + if (enumNameMappings != null) { applyEnumNameMappingsKvpList(enumNameMappings, configurator); } // Apply Operation ID Name Mappings - if (operationIdNameMappings != null && (configOptions == null || !configOptions.containsKey("operation-id-name-mappings"))) { + if (operationIdNameMappings != null) { applyOperationIdNameMappingsKvpList(operationIdNameMappings, configurator); }