Skip to content

[generators] Give clients a way to inject vendor overrides - #24571

Open
devhl-labs wants to merge 1 commit into
OpenAPITools:masterfrom
devhl-labs:devhl/override-property-setter-access-modifier
Open

[generators] Give clients a way to inject vendor overrides#24571
devhl-labs wants to merge 1 commit into
OpenAPITools:masterfrom
devhl-labs:devhl/override-property-setter-access-modifier

Conversation

@devhl-labs

@devhl-labs devhl-labs commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Add --inject-model-vendor-extensions CLI option

Summary

Adds a new --inject-model-vendor-extensions CLI option (and equivalent config file key) that injects vendor extensions into model classes or their properties at code generation time, without requiring modifications to the OpenAPI spec.

Motivation

Generated REST clients often need to patch server response data after deserialization. For example, a server may omit a field in certain scenarios where the value is actually known and derivable. Without a setter on the generated model, the client library has no way to correct the data.

Previously the only option was to add x-* extensions directly to the OpenAPI spec, which is not always possible (e.g. third-party specs, specs under separate ownership). This option allows injecting extensions as part of the generator invocation.

Key Format

Extensions are specified as key=value pairs passed to --inject-model-vendor-extensions. Two formats are supported:

Class-level (2-part key) — injects into model.vendorExtensions:

ModelName.x-extension-name=value

Property-level (3-part key) — injects into a specific property's vendorExtensions, matched by baseName:

ModelName.propertyBaseName.x-extension-name=value

The option can be specified multiple times or as a comma-separated list.

Example CLI usage

openapi-generator generate \
  --inject-model-vendor-extensions "ClanWar.attacksPerMember.x-setter-visibility=internal" \
  --inject-model-vendor-extensions "ClanWar.clan.x-setter-visibility=private" \
  ...

Example config file usage

injectModelVendorExtensions:
  ClanWar.attacksPerMember.x-setter-visibility: internal
  ClanWar.clan.x-setter-visibility: private

Changes

Core plumbing (all generators)

  • GeneratorSettings.java — Added injectModelVendorExtensions field with builder support and config file deserialization.
  • CodegenConfig.java — Added injectModelVendorExtensions() interface method.
  • DefaultCodegen.java — Added field + getter. Injection logic runs in postProcessAllModels() after all models have been processed: iterates all models and their property lists (vars, allVars, readWriteVars, requiredVars, optionalVars, parentVars, readOnlyVars, nonNullableVars) to ensure all property instances are updated.
  • CodegenConfigurator.java — Added field, addInjectModelVendorExtension() / setInjectModelVendorExtensions() methods, and wiring in toClientOptInput().
  • CodegenConfiguratorUtils.java — Added applyInjectModelVendorExtensionsKvpList() and applyInjectModelVendorExtensionsKvp() helper methods.
  • Generate.java — Added --inject-model-vendor-extensions CLI option with description, and calls applyInjectModelVendorExtensionsKvpList() in run().

C# generichost template — x-setter-visibility support

The x-setter-visibility vendor extension is now supported by the C# generichost generator. It controls the setter visibility on a generated property, with valid values: private, internal, protected, public.

Primary use case: Allowing client library code to patch server response data post-deserialization (e.g. in a partial void OnCreated() method) while keeping the property effectively read-only from external consumers' perspective.

AbstractCSharpCodegen.patchProperty()

Normalizes x-setter-visibility as part of C#-specific property processing:

  • "public" → removes the extension and sets isReadOnly = false. This avoids the C# compiler error CS0274 (public set on a public property is invalid) and produces the standard set; accessor.
  • Any other value ("private", "internal", "protected") → sets isReadOnly = true. This ensures the template's {{^isReadOnly}}set;{{/isReadOnly}} default path is suppressed, and the restricted setter is emitted instead.

Setting isReadOnly = true is semantically appropriate for these properties: from external consumers' perspective they are read-only (populated by the server); the library patching them is an implementation detail. Note that readOnlyVars / readWriteVars lists are pre-computed before patchProperty runs and are unaffected.

modelGeneric.mustache

Updated all 5 public property definition locations to use simplified setter logic. Old pattern (double-branch inversion):

{{#vendorExtensions.x-setter-visibility}}{{vendorExtensions.x-setter-visibility}} set; {{/vendorExtensions.x-setter-visibility}}{{^vendorExtensions.x-setter-visibility}}{{^isReadOnly}}set; {{/isReadOnly}}{{/vendorExtensions.x-setter-visibility}}

New pattern (two independent, mutually exclusive branches):

{{^isReadOnly}}set; {{/isReadOnly}}{{#vendorExtensions.x-setter-visibility}}{{.}} set; {{/vendorExtensions.x-setter-visibility}}

The two branches are guaranteed mutually exclusive by the Java normalization: when x-setter-visibility is present (non-public), isReadOnly is true; when absent or public, isReadOnly reflects its original value.

Option backing properties (FooOption) always use private set and are unaffected.

Test coverage

  • Added InjectedVendorExtensionsTest schema to petstore-with-fake-endpoints-models-for-testing-with-http-signature.yaml with properties covering all scenarios:
    • unalteredProperty — no extension, truly readOnly: true → no setter
    • potentiallyOverriddenPropertyToPrivatex-setter-visibility: private in YAML → private set
    • potentiallyOverriddenPropertyToInternal — injected via config with x-setter-visibility: internalinternal set
    • potentiallyOverriddenPropertyToPublic — injected via config with x-setter-visibility: public (overrides spec-defined private) → standard set
  • Updated bin/configs/csharp-generichost-net10.yaml with injectModelVendorExtensions entries for the test schema.
  • Regenerated InjectedVendorExtensionsTest.cs in the net10 Petstore sample confirming correct output for all 4 cases.

PR checklist

  • Read the contribution guidelines.
  • Run the following to build the project and update samples:
    ./mvnw clean package || exit
    ./bin/generate-samples.sh ./bin/configs/*.yaml || exit
    ./bin/utils/export_docs_generators.sh || exit
    
    (For Windows users, please run the script in WSL)
    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.

Summary by cubic

Adds --inject-model-vendor-extensions (and injectModelVendorExtensions config key) to inject vendor extensions into generated models or properties without changing the OpenAPI spec. Adds C# generichost support for x-setter-visibility to control property setter visibility.

  • New Features

    • New CLI flag and config key inject extensions during model post-processing, keeping all property lists in sync.
    • C# generichost: supports x-setter-visibility (private, internal, protected, public). Normalizes so public yields the standard setter, others emit restricted setters; template updated accordingly.
    • Added tests and regenerated samples (Petstore) covering unaltered, private, internal, and public cases.
  • Migration

    • Use the CLI: --inject-model-vendor-extensions ModelName.propertyBaseName.x-setter-visibility=internal (repeatable or comma-separated). Class-level format is ModelName.x-extension-name=value; property-level is ModelName.propertyBaseName.x-extension-name=value.
    • Or set in config: injectModelVendorExtensions: { ModelName.propertyBaseName.x-setter-visibility: internal }. No changes needed if you don’t use this feature.

Written for commit 90ced51. Summary will update on new commits.

Review in cubic

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

8 issues found across 166 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="samples/client/petstore/csharp/generichost/net8/Petstore/src/Org.OpenAPITools/Model/InjectedVendorExtensionsTest.cs">

<violation number="1" location="samples/client/petstore/csharp/generichost/net8/Petstore/src/Org.OpenAPITools/Model/InjectedVendorExtensionsTest.cs:88">
P2: The newly added net8 InjectedVendorExtensionsTest sample does not actually exercise the injectModelVendorExtensions feature this PR introduces. Every property is generated get-only (readonly), so none of the x-setter-visibility overrides (public/internal/private setter) that the PR is meant to demonstrate are present — compare the net10 sample, which correctly emits public `set`, `internal set`, and `private set` for these three properties. The reason is that bin/configs/csharp-generichost-net8.yaml was left without the injectModelVendorExtensions block that csharp-generichost-net10.yaml has, so the sample was regenerated as a plain read-only model. Consider updating the net8 config and regenerating so the sample actually validates the feature, and keep it consistent with the net10 sample.</violation>
</file>

<file name="modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/AbstractCSharpCodegen.java">

<violation number="1" location="modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/AbstractCSharpCodegen.java:894">
P2: The model's mutability flag (X_MODEL_IS_MUTABLE), which controls whether the generated C# model gets a public or internal constructor, is computed in postProcessModels() at line 647 before patchProperty() runs, so the new x-setter-visibility normalization that flips property.isReadOnly isn't reflected in that decision. For the 'public' override direction (a spec readOnly property turned into a public setter), the model can still be generated with an internal constructor even though its property now has a public setter, preventing external callers from actually using it. Consider recomputing model mutability after this normalization (or computing it after the per-property patches) so the constructor visibility matches the effective setter access.</violation>
</file>

<file name="samples/client/petstore/csharp/generichost/net10/NullReferenceTypes/src/Org.OpenAPITools/Model/InjectedVendorExtensionsTest.cs">

<violation number="1" location="samples/client/petstore/csharp/generichost/net10/NullReferenceTypes/src/Org.OpenAPITools/Model/InjectedVendorExtensionsTest.cs:90">
P2: The regenerated sample for this new feature doesn't actually demonstrate it. The config injects `x-setter-visibility: private/internal/public` onto three properties, and the PR says public should emit a normal public setter while private/internal should emit restricted setters. But in the added `InjectedVendorExtensionsTest.cs` all four properties (including PotentiallyOverriddenPropertyToPublic) are emitted as get-only `{ get { return this.XOption.Value; } }` with no setter at all, and the model doc still lists all as `[readonly]`. So the regenerated output doesn't reflect the injected vendor extensions — either the sample was generated without the feature actually applying, or the setter-visibility handling isn't reaching these properties. Worth confirming by regenerating with the new code and verifying the public/internal/private setters appear as intended before merging.</violation>
</file>

<file name="samples/client/petstore/csharp/generichost/net10/SourceGeneration/src/Org.OpenAPITools/Model/InjectedVendorExtensionsTest.cs">

<violation number="1" location="samples/client/petstore/csharp/generichost/net10/SourceGeneration/src/Org.OpenAPITools/Model/InjectedVendorExtensionsTest.cs:91">
P2: In the regenerated SourceGeneration sample, the three properties that were injected with x-setter-visibility (private/internal/public) are all emitted as get-only with no setter, so the new feature has no observable effect in this sample. The same config's non-SourceGeneration Petstore sample correctly emits `private set`, `internal set`, and public `set`, while `unalteredProperty` stays get-only. This suggests either the SourceGeneration sample was regenerated before the setter template change, or the SourceGeneration code path isn't honoring the injected extension. Please regenerate with the updated template and confirm the SourceGeneration output also shows private/internal/public setters so the sample actually exercises the new behavior.</violation>
</file>

<file name="samples/client/petstore/csharp/restsharp/net8/Petstore/src/Org.OpenAPITools/Model/InjectedVendorExtensionsTest.cs">

<violation number="1" location="samples/client/petstore/csharp/restsharp/net8/Petstore/src/Org.OpenAPITools/Model/InjectedVendorExtensionsTest.cs:89">
P2: This net8/restsharp sample output renders potentiallyOverriddenPropertyToInternal and potentiallyOverriddenPropertyToPublic as `private set` with a `ShouldSerialize*()` returning false, which contradicts the setter-visibility behavior described in this PR (public → standard set with isReadOnly=false; internal → internal modifier). Either the sample was regenerated before the feature landed or the restsharp template is not wired to the x-setter-visibility normalization; either way the added sample does not actually verify the public/internal cases it is intended to cover. Consider regenerating this sample with injectModelVendorExtensions applied (or removing it if restsharp isn't part of the feature) so the committed output matches the generator's actual behavior.</violation>
</file>

<file name="samples/client/petstore/csharp/generichost/net8/FormModels/src/Org.OpenAPITools/Model/InjectedVendorExtensionsTest.cs">

<violation number="1" location="samples/client/petstore/csharp/generichost/net8/FormModels/src/Org.OpenAPITools/Model/InjectedVendorExtensionsTest.cs:88">
P2: The generated sample does not reflect the intended x-setter-visibility behavior, which points at a post-processing ordering problem in the new feature. These properties are injected with x-setter-visibility, but the regenerated output shows no setter at all (e.g. PotentiallyOverriddenPropertyToPublic should end up with an effective public/default setter, and PotentiallyOverriddenPropertyToPrivate with a `private set`). The cause: the injection runs in DefaultCodegen.postProcessAllModels(), which is invoked after AbstractCSharpCodegen analyzes properties in postProcessModelsProperty() where x-setter-visibility is normalized to set/clear isReadOnly (AbstractCSharpCodegen ~line 887). Because that normalization has already run before the extensions are injected, the injected values never drive isReadOnly, so the C# setter-visibility feature cannot take effect for config-injected extensions. Since this file is presented as the confirmation sample, the sample either needs regenerating after fixing the pipeline order, or the injection must occur before the C# visibility normalization so injected x-setter-visibility also flips isReadOnly (currently a `private`/`internal` injection would also leave isReadOnly=false, causing the template to emit both a default `set {...}` and a `private set {...}`, i.e. duplicate setters).</violation>
</file>

<file name="samples/client/petstore/csharp/generichost/net10/FormModels/src/Org.OpenAPITools/Model/InjectedVendorExtensionsTest.cs">

<violation number="1" location="samples/client/petstore/csharp/generichost/net10/FormModels/src/Org.OpenAPITools/Model/InjectedVendorExtensionsTest.cs:88">
P2: The generated InjectedVendorExtensionsTest.cs doesn't reflect the x-setter-visibility feature from this PR: all four properties (including potentiallyOverriddenPropertyToPublic, which per the PR should get a standard public setter, and the internal/private ones, which should get restricted setters) are emitted as read-only get-only properties with no setter. This suggests the sample wasn't regenerated with the new logic, or the setter-generation path isn't emitting the expected `public set`/`internal set`/`private set` for these cases. Please regenerate the net10 Petstore sample and confirm the overridden properties actually carry their setters, otherwise the sample provides no coverage for the new option.</violation>
</file>

<file name="samples/client/petstore/csharp/generichost/net4.8/FormModels/src/Org.OpenAPITools/Model/InjectedVendorExtensionsTest.cs">

<violation number="1" location="samples/client/petstore/csharp/generichost/net4.8/FormModels/src/Org.OpenAPITools/Model/InjectedVendorExtensionsTest.cs:107">
P2: This generated model declares support for additional properties via `[JsonExtensionData]`, but its custom deserialization converter never populates that dictionary — the `default: break;` branch in `Read` silently discards every JSON field that isn't one of the four known properties. Because the custom converter suppresses System.Text.Json's automatic extension-data handling, any payload carrying extra fields will have them dropped rather than preserved in `AdditionalProperties`. Consider routing unknown property values into `AdditionalProperties` in the `Read` loop.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

@wing328

wing328 commented Aug 3, 2026

Copy link
Copy Markdown
Member

thanks for the enhancement as always

that injects vendor extensions into model classes or their properties at code generation time, without requiring modifications to the OpenAPI spec.

quick feedback on this. should this be better done in the openapi normalizer, which intends to modify the incoming openapi spec without having the users manually editing it? in other words, add normalizer rules to allows users inject extensions in models, properties, parameters, operations, etc

@devhl-labs

Copy link
Copy Markdown
Contributor Author

Doesn't matter to me, but this isn't exactly "normalizing". Ill defer to you.

@wing328

wing328 commented Aug 3, 2026

Copy link
Copy Markdown
Member

if possible, i would suggest starting as a generator's option instead of global option as only csharp (generichost) supports this new feature.

if it's a lot of work to make it a generator's option, we can go with what you've so far but suggest documenting it clearly only csharp (generaichost) supports it so far

(a user trying this option with python client generator would have no clue this new feature is not yet supported)

@devhl-labs

Copy link
Copy Markdown
Contributor Author

As written, every generator is supported. The only c# specific thing is the specific vendor extension i added here. But users could inject any vendor extension.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants