[JAVA-SPRING;KOTLIN-SPRING] feature: `x-field-extra-annotation` parity for kotlin-spring params + new `x-request-body-extra-annotation` + `--inject-operation-vendor-extensions`
Summary
Adds custom-annotation support for previously uncovered spots in the Spring generators:
x-field-extra-annotationon kotlin-spring path/query/form/header params: brings kotlin-spring
to parity with java-spring, which already renders this extension on parameters.- New operation-level
x-request-body-extra-annotation(java-spring and kotlin-spring):
lets you attach annotations to the generated request-body parameter, even when the body$refs a
shared model. x-field-extra-annotationon cookie params (java-spring and kotlin-spring): closes the
last parameter kind that supported it in neither generator.- New
--inject-operation-vendor-extensionsCLI/config flag (all generators): injects vendor
extensions onto operations and their parameters from the command line or config, without editing
the spec. This complements the existing--inject-model-vendor-extensionsflag and is the natural
way to apply the two extensions above when you cannot or do not want to touch the source contract.
Both accept a string or a list of strings, are applied per operation (selective), and are a
no-op when absent.
Motivation
Users commonly reference reusable ID/model schemas from many operations:
components:
schemas:
OrgId: { type: string, format: uuid }
Employee: { ... }
To add a validation/framework annotation to a specific usage (e.g. Hibernate Validator / LSP rules
that require constraints to live on the generated interface, not the impl), you need a placement that
is both ref-safe and per-operation:
-
Path / query / form / header / cookie params support two placements, and you can pick based
on the scope you want:- On the shared schema (for example on
OrgId): applies globally to every parameter that
$refs that schema. Use this when the annotation should always accompany the type. The generator
already merges a referenced parameter schema's extensions onto the parameter, so this works for
the simple alias schemas typically used by parameters. - On the Parameter Object (sibling of
name/in): applies selectively to that single
usage, and keeps the sharedschema: { $ref: ... }clean and reusable for other operations. Use
this when only some usages should carry the annotation. - Previously java-spring rendered the annotation on path/query/form/header params but not cookie,
while kotlin-spring rendered it on none of them. Parts 1 and 3 close those gaps so every parameter
kind behaves consistently in both generators.
- On the shared schema (for example on
-
Request bodies usually
$refa shared model, so there is no per-usage object to annotate:- keys placed next to a
$refare ignored (OpenAPI 3.0/3.1), - annotating the referenced model applies globally to every operation.
Part 2 solves this with an operation-level extension: operations are never
$reftargets, so
the annotation is inherently per-usage and ref-safe. - keys placed next to a
Example (Part 1: parameter object)
parameters:
- name: orgId
in: path
required: true
x-field-extra-annotation: "@com.example.ValidOrgId" # on the parameter object
schema:
$ref: '#/components/schemas/OrgId' # ref stays clean/reusable
Example (Part 2: request body)
paths:
/employees:
post:
x-request-body-extra-annotation: "@com.example.MyValidation"
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/Employee'
Generates (java-spring):
ResponseEntity<Void> createEmployee(
@com.example.MyValidation @Valid @RequestBody Employee employee
) { ... }
Request-body annotation placements (three working scopes)
Besides the new operation-level extension, the body param also honors annotations declared on the
RequestBody Object itself (these are merged onto the generated body parameter). This gives three
placements, chosen by the scope you want:
- Operation-level
x-request-body-extra-annotation(the new extension): per operation. Works
even when the operation'srequestBodyis a bare$ref. - Inline
requestBodyobjectx-field-extra-annotation: per operation.requestBody: x-field-extra-annotation: "@com.example.InlineBodyValidation" content: { application/json: { schema: { $ref: '#/components/schemas/Employee' } } } - Reusable
components.requestBodiesobjectx-field-extra-annotation: applies to every
operation that$refs that reusable request body (a shared subset).components: requestBodies: AnnotatedEmployeeBody: x-field-extra-annotation: "@com.example.ReusableBodyValidation" content: { application/json: { schema: { $ref: '#/components/schemas/Employee' } } }
Caveat: a key placed as a sibling of $ref in the operation's requestBody is ignored (OpenAPI
3.0/3.1), so to annotate a $ref-ed reusable body the extension must live inside the reusable
components.requestBodies object (option 3), not next to the $ref.
Example (injecting the extensions from the CLI, no spec edit)
When you cannot modify the source contract, the same result is achievable via
--inject-operation-vendor-extensions:
openapi-generator generate -g spring -i api.yaml -o out \
--inject-operation-vendor-extensions \
"createEmployee.x-request-body-extra-annotation=@com.example.MyValidation" \
--inject-operation-vendor-extensions \
"createEmployee.orgId.x-field-extra-annotation=@com.example.ValidOrgId"
Key formats:
operationId.x-extension-name=valuetargets the operation (for example the request-body
extension above).operationId.paramBaseName.x-extension-name=valuetargets a parameter, matched by its spec
name (baseName). TheoperationIdsegment is matched against the spec-authored operationId when
present, falling back to the generated operationId only when the spec omits one.
Changes
New extension & wiring
VendorExtension: addedX_REQUEST_BODY_EXTRA_ANNOTATION(OPERATION level).- Registered in
getSupportedVendorExtensions()for bothspringandkotlin-spring. DefaultCodegen: new shared helpermergeOperationVendorExtensionIntoBodyParams(...)that copies
the operation-level values onto the body parameter'sx-field-extra-annotationlist, so both
extensions render through the existing body-param template path.- Normalization (string-or-list →
List<String>) applied in bothSpringCodegenand
KotlinSpringServerCodegen(the latter gains a small parameter-normalization helper, since it does
not share the Java generator's base class).
Vendor-extension injection flag (all generators)
- New
--inject-operation-vendor-extensionsCLI option (repeatable) plus the matching
CodegenConfigurator.addInjectOperationVendorExtension/GeneratorSettingswiring, mirroring the
existing--inject-model-vendor-extensionsplumbing. - Injection is applied in
DefaultCodegen.fromOperation, so it runs for every generator and before
generator-specific post-processing. This means an injectedx-request-body-extra-annotationflows
through the same normalize-and-merge path as one declared in the spec.
Templates
- kotlin-spring
pathParams/queryParams/formParams/headerParams: render
x-field-extra-annotationbefore the parameter binding (java-spring parity). - java-spring & kotlin-spring
cookieParams: renderx-field-extra-annotationbefore@CookieValue
/ the cookie binding (previously unsupported in both). - java-spring & kotlin-spring
bodyParams: render the merged annotation before@RequestBody/
the body binding. CoversuseOptionaland reactive (Mono/Flux,Flow/suspend) variants. - Header and cookie params required no generator-code change: both generators already normalize those
parameter collections toList<String>, so only the templates were missing.
Samples (compile-verified)
- To exercise the features in real, compiled samples without adding slow new build targets, the two
shared petstore specs were copied and the copies annotated:
petstore-with-fake-endpoints-models-for-testing-extra-annotation.yaml(java) and
3_0/kotlin/petstore-with-extra-annotation.yaml(kotlin). The originals are untouched, so there is
no ripple to the 140+ other configs that consume them. - Four existing samples were repointed to the copies, covering both generators across reactive and
non-reactive:springboot-useoptional(java,useOptional),springboot-reactive(java,
Mono/Flux),kotlin-springboot-delegate(kotlin), andkotlin-springboot-reactive(kotlin,
Flow/suspend). All four compile locally. - The annotated copies demonstrate: operation-level
x-request-body-extra-annotationonaddPet
(a$refbody) withupdatePetleft un-annotated (selectivity); paramx-field-extra-annotation
on a path, a list-valued query, and a form param; and, on the kotlin copy,x-extra-importsso the
annotations are referenced by short name. - The same four samples also exercise the
--inject-operation-vendor-extensionsside-loading path
(expressed as aninjectOperationVendorExtensions:block in each sample config, the config-file
equivalent of the CLI flag). Without editing the spec, they inject an operation-level
x-request-body-extra-annotationontoplaceOrderand a parameter-levelx-field-extra-annotation
onto thegetOrderByIdpath param. Because the java base spec names that paramorder_idwhile the
kotlin base spec names itorderId, the two configs use different keys, which validates that the
parameter segment is matched against the raw specparamBaseName. On the kotlin samples the configs
also side-loadx-extra-importsnext to the injected annotations, so the injected@NonNullis
imported and referenced by short name (the java samples keep the fully-qualified form, since
java-spring has nox-extra-importssupport). The injected annotations render and compile alongside
the spec-declared ones.
Docs
- Regenerated
docs/generators/spring.mdanddocs/generators/kotlin-spring.md(vendor-extension
tables) via the docs task, not hand-edited. (No table change for the header/cookie additions, which
reuse the existingx-field-extra-annotation.)
Tests
- java-spring: request-body annotation with a
$refbody + a second operation on the same model
without the extension (proves selectivity); covers a non-default body branch. Cookie-param
coverage added to the existing parameter-annotation test. - kotlin-spring:
x-field-extra-annotationon path/query/form/header/cookie params (incl.
list-valued cases), plus request-body annotation with selectivity. - injection flag: unit test in
DefaultCodegenTest(operation-level and parameter-level landing,
non-matching operationId is a no-op) plus java-spring and kotlin-spring end-to-end tests that inject
both extensions and assert selective rendering. - request-body placements: the java-spring and kotlin-spring request-body tests also assert that an
x-field-extra-annotationdeclared on the inlinerequestBodyobject and on a reusable
components.requestBodiesobject (referenced by two operations) render on the body param.
Design notes / trade-offs
- Merge into
x-field-extra-annotationinstead of a new template branch. The operation-level
request-body value is merged onto the body parameter's existing annotation list, so rendering
reuses one code path and multiple annotations compose naturally. Existing param-level annotations
are preserved and appear first. - Values normalized to
List<String>. A single string and a list are handled uniformly, extra
values can be appended, and an absent extension yields an empty list → renders nothing (no change
when unused). - The param extension supports both schema-level and Parameter Object placement. Placing it on
the shared schema applies it globally to every usage of that schema; placing it on the Parameter
Object applies it selectively to a single usage and keeps the shared$refreusable. Both are
valid; choose based on whether you want global or per-usage scope.
Known limitation (out of scope)
If a requestBody declares different schemas per content type, the generator still models it as a
single body parameter / single @RequestBody argument (Spring itself binds one body per method),
so the annotation applies to that one binding, and it cannot be varied per content type. Generating one
method per content type (via consumes dispatch) would be the fully spec-faithful approach but is a
broader change (it breaks the 1:1 operation→method contract across all generators) and is not
addressed here. The operation-level design does not preclude adding a media-type-level extension
later if that path is ever taken.
Acceptance
- kotlin-spring renders
x-field-extra-annotationon path/query/form/header params (java-spring
parity), including list values; model-field behavior unchanged. x-field-extra-annotationrenders on cookie params in both java-spring and kotlin-spring.x-request-body-extra-annotationrenders before the body param in java-spring and kotlin-spring,
works with$refbodies, is selective per operation, and coversuseOptional/ reactive variants.- Both extensions accept a string or a list of strings; absent extension = no change.
--inject-operation-vendor-extensionsapplies operation- and parameter-level extensions from the
CLI/config for any generator, selectively per operation, with an empty/absent map being a no-op.- Docs regenerated; new tests pass with no regressions.
- Four existing samples (java + kotlin, reactive + non-reactive) were repointed to annotated copies
of the shared petstore specs and compile with the emitted annotations, verifying the feature
end-to-end without adding new build targets. The same samples also inject both extensions via an
injectOperationVendorExtensions:config block (the side-loading path), confirming the CLI/config
injection mechanism renders and compiles too.
Plugin parity (Gradle + Maven)
The injectModelVendorExtensions / injectOperationVendorExtensions side-loading maps were only
reachable from the plugins through a configFile. They are now first-class properties on both the
Gradle and Maven plugins, matching the CLI and the existing parity of the other mapping properties
(nameMappings, globalProperties, etc.):
- Gradle (
openApiGenerate { ... }): twoMapProperty<String, String>fields wired through the
extension, plugin, andGenerateTaskto the configurator. - Maven (
<configuration>): twoList<String>KVP parameters
(openapi.generator.maven.plugin.inject{Model,Operation}VendorExtensions) applied via the shared
applyInject*KvpListhelpers. These settings are configurator-level (not generatorCliOptions)
and have noconfigOptionsbackwards-compat reader, so unlike the legacy mapping options they need
noconfigOptionsguard; the same review also removed the equally deadconfigOptionsguards from
the five*-name-mappingsparameters (theinline-schema-optionsguard is kept, since it does have
a compat reader). - Docs: added to the Gradle
README.adocand MavenREADME.mdconfig tables. The CLI help for
--inject-*-vendor-extensionsnow clarifies that multiple annotations in a single value are
space-separated (emitted verbatim into source); an unquoted comma is the separator between
different injection targets, so comma-bearing annotation attributes should use the spec-level
extension (string or YAML list) instead. - Tests: a Gradle
ParameterWiringRegressionTestcase asserts the injected annotation reaches
generated sources, and a MavenCodeGenMojoTestcase (newinject-vendor-extensionsresource
project) asserts the injectedx-request-body-extra-annotationis merged onto the body parameter
and rendered on the generated Spring API.
PR checklist
- Read the contribution guidelines.
- Run the following to build the project and update samples:
(For Windows users, please run the script in WSL)./mvnw clean package || exit ./bin/generate-samples.sh ./bin/configs/*.yaml || exit ./bin/utils/export_docs_generators.sh || exit
Commit all changed files.
This is important, as CI jobs will verify all generator outputs of your HEAD commit as it would merge with master.
These must match the expectations made by your contribution.
You may regenerate an individual generator by passing the relevant config(s) as an argument to the script, for example./bin/generate-samples.sh bin/configs/java*.
IMPORTANT: Do NOT purge/delete any folders/files (e.g. tests) when regenerating the samples as manually written tests may be removed. - If your PR is targeting a particular programming language, @mention the technical committee members, so they are more likely to review the pull request.