Add option to generate a fully sealed model in the JavaSpring generator (#20503)

* Generated sealed interfaces for oneOf

* Add generated data

* Add also  modifier

* Allow sealed for everything

* Fully seal model

* Disable html escaping

* Update modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/SpringCodegen.java

Co-authored-by: martin-mfg <2026226+martin-mfg@users.noreply.github.com>

* Update docs

* Check all oneOf scenarios

* Fix failed scenario

* Fix comments

* Remove unused import

* Adapt pom.xml also

* Add comment and remove unused function

---------

Co-authored-by: martin-mfg <2026226+martin-mfg@users.noreply.github.com>
This commit is contained in:
Alex 2025-02-19 09:15:09 +02:00 committed by GitHub
parent d5866feb2d
commit cd7cdb1e24
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 142 additions and 12 deletions

View File

@ -103,6 +103,7 @@ These options may be applied as additional-properties (cli) or configOptions (pl
|useOneOfInterfaces|whether to use a java interface to describe a set of oneOf options, where each option is a class that implements the interface| |false|
|useOptional|Use Optional container for optional parameters| |false|
|useResponseEntity|Use the `ResponseEntity` type to wrap return values of generated API methods. If disabled, method are annotated using a `@ResponseStatus` annotation, which has the status of the first response declared in the Api definition| |true|
|useSealed|Whether to generate sealed model interfaces and classes| |false|
|useSpringBoot3|Generate code and provide dependencies for use with Spring Boot 3.x. (Use jakarta instead of javax in imports). Enabling this option will also enable `useJakartaEe`.| |false|
|useSpringController|Annotate the generated API as a Spring Controller| |false|
|useSwaggerUI|Open the OpenApi specification in swagger-ui. Will also import and configure needed dependencies| |true|

View File

@ -96,6 +96,7 @@ These options may be applied as additional-properties (cli) or configOptions (pl
|useOneOfInterfaces|whether to use a java interface to describe a set of oneOf options, where each option is a class that implements the interface| |false|
|useOptional|Use Optional container for optional parameters| |false|
|useResponseEntity|Use the `ResponseEntity` type to wrap return values of generated API methods. If disabled, method are annotated using a `@ResponseStatus` annotation, which has the status of the first response declared in the Api definition| |true|
|useSealed|Whether to generate sealed model interfaces and classes| |false|
|useSpringBoot3|Generate code and provide dependencies for use with Spring Boot 3.x. (Use jakarta instead of javax in imports). Enabling this option will also enable `useJakartaEe`.| |false|
|useSpringController|Annotate the generated API as a Spring Controller| |false|
|useSwaggerUI|Open the OpenApi specification in swagger-ui. Will also import and configure needed dependencies| |true|

30
flake.lock generated
View File

@ -1,12 +1,15 @@
{
"nodes": {
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1676283394,
"narHash": "sha256-XX2f9c3iySLCw54rJ/CZs+ZK6IQy7GXNY4nSOyu2QG4=",
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "3db36a8b464d0c4532ba1c7dda728f4576d6d073",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github"
},
"original": {
@ -17,11 +20,11 @@
},
"nixpkgs": {
"locked": {
"lastModified": 1678083093,
"narHash": "sha256-eTkS9GcdSAYA3cE9zCAAs9wY3+oM2zT45ydIkAcEFFQ=",
"lastModified": 1737989155,
"narHash": "sha256-TFJAGK7tt/jj1v747xNOzopxZ4odBIeDi6EJlYDg/bI=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "684306b246d05168e42425a3610df7e2c4d51fcd",
"rev": "5861228f6e9e9dd5d3f8e0a26411f682fdade93a",
"type": "github"
},
"original": {
@ -35,6 +38,21 @@
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",

View File

@ -59,6 +59,9 @@ public class CodegenModel implements IJsonSchemaValidationProperties {
public Set<String> oneOf = new TreeSet<>();
public Set<String> allOf = new TreeSet<>();
// direct descendants that are allowed to extend the current model
public List<String> permits = new ArrayList<>();
// The schema name as written in the OpenAPI document
// If it's a reserved word, it will be escaped.
@Getter @Setter
@ -922,6 +925,7 @@ public class CodegenModel implements IJsonSchemaValidationProperties {
Objects.equals(parentModel, that.parentModel) &&
Objects.equals(interfaceModels, that.interfaceModels) &&
Objects.equals(children, that.children) &&
Objects.equals(permits, that.permits) &&
Objects.equals(anyOf, that.anyOf) &&
Objects.equals(oneOf, that.oneOf) &&
Objects.equals(allOf, that.allOf) &&
@ -975,7 +979,7 @@ public class CodegenModel implements IJsonSchemaValidationProperties {
@Override
public int hashCode() {
return Objects.hash(getParent(), getParentSchema(), getInterfaces(), getAllParents(), getParentModel(),
getInterfaceModels(), getChildren(), anyOf, oneOf, allOf, getName(), getSchemaName(), getClassname(), getTitle(),
getInterfaceModels(), getChildren(), permits, anyOf, oneOf, allOf, getName(), getSchemaName(), getClassname(), getTitle(),
getDescription(), getClassVarName(), getModelJson(), getDataType(), getXmlPrefix(), getXmlNamespace(),
getXmlName(), getClassFilename(), getUnescapedDescription(), getDiscriminator(), getDefaultValue(),
getArrayModelType(), isAlias, isString, isInteger, isLong, isNumber, isNumeric, isFloat, isDouble,
@ -1005,6 +1009,7 @@ public class CodegenModel implements IJsonSchemaValidationProperties {
sb.append(", allParents=").append(allParents);
sb.append(", parentModel=").append(parentModel);
sb.append(", children=").append(children != null ? children.size() : "[]");
sb.append(", permits=").append(permits != null ? permits.size() : "[]");
sb.append(", anyOf=").append(anyOf);
sb.append(", oneOf=").append(oneOf);
sb.append(", allOf=").append(allOf);

View File

@ -621,16 +621,25 @@ public class DefaultCodegen implements CodegenConfig {
// Let parent know about all its children
for (Map.Entry<String, CodegenModel> allModelsEntry : allModels.entrySet()) {
String name = allModelsEntry.getKey();
CodegenModel cm = allModelsEntry.getValue();
CodegenModel parent = allModels.get(cm.getParent());
if (parent != null) {
if (!parent.permits.contains(cm.classname) && parent.permits.stream()
.noneMatch(name -> name.equals(cm.getName()))) {
parent.permits.add(cm.classname);
}
}
// if a discriminator exists on the parent, don't add this child to the inheritance hierarchy
// TODO Determine what to do if the parent discriminator name == the grandparent discriminator name
while (parent != null) {
if (parent.getChildren() == null) {
parent.setChildren(new ArrayList<>());
}
parent.getChildren().add(cm);
if (parent.getChildren().stream().map(CodegenModel::getName)
.noneMatch(name -> name.equals(cm.getName()))) {
parent.getChildren().add(cm);
}
parent.hasChildren = true;
Schema parentSchema = this.openAPI.getComponents().getSchemas().get(parent.schemaName);
if (parentSchema == null) {
@ -2704,7 +2713,6 @@ public class DefaultCodegen implements CodegenConfig {
LOGGER.debug("{} (anyOf schema) already has `{}` defined and therefore it's skipped.", m.name, languageType);
} else {
m.anyOf.add(languageType);
}
} else if (composed.getOneOf() != null) {
if (m.oneOf.contains(languageType)) {
@ -2751,6 +2759,9 @@ public class DefaultCodegen implements CodegenConfig {
m.anyOf.add(modelName);
} else if (composed.getOneOf() != null) {
m.oneOf.add(modelName);
if (!m.permits.contains(modelName)) {
m.permits.add(modelName);
}
} else if (composed.getAllOf() != null) {
m.allOf.add(modelName);
} else {

View File

@ -113,6 +113,7 @@ public class SpringCodegen extends AbstractJavaCodegen
public static final String REQUEST_MAPPING_OPTION = "requestMappingMode";
public static final String USE_REQUEST_MAPPING_ON_CONTROLLER = "useRequestMappingOnController";
public static final String USE_REQUEST_MAPPING_ON_INTERFACE = "useRequestMappingOnInterface";
public static final String USE_SEALED = "useSealed";
@Getter public enum RequestMappingMode {
api_interface("Generate the @RequestMapping annotation on the generated Api Interface."),
@ -151,6 +152,7 @@ public class SpringCodegen extends AbstractJavaCodegen
protected boolean performBeanValidation = false;
@Setter protected boolean apiFirst = false;
protected boolean useOptional = false;
@Setter protected boolean useSealed = false;
@Setter protected boolean virtualService = false;
@Setter protected boolean hateoas = false;
@Setter protected boolean returnSuccessCode = false;
@ -229,6 +231,8 @@ public class SpringCodegen extends AbstractJavaCodegen
.add(CliOption.newBoolean(USE_BEANVALIDATION, "Use BeanValidation API annotations", useBeanValidation));
cliOptions.add(CliOption.newBoolean(PERFORM_BEANVALIDATION,
"Use Bean Validation Impl. to perform BeanValidation", performBeanValidation));
cliOptions.add(CliOption.newBoolean(USE_SEALED,
"Whether to generate sealed model interfaces and classes"));
cliOptions.add(CliOption.newBoolean(API_FIRST,
"Generate the API from the OAI spec at server compile time (API first approach)", apiFirst));
cliOptions
@ -423,6 +427,7 @@ public class SpringCodegen extends AbstractJavaCodegen
convertPropertyToBooleanAndWriteBack(GENERATE_CONSTRUCTOR_WITH_REQUIRED_ARGS, value -> this.generatedConstructorWithRequiredArgs=value);
convertPropertyToBooleanAndWriteBack(RETURN_SUCCESS_CODE, this::setReturnSuccessCode);
convertPropertyToBooleanAndWriteBack(USE_SWAGGER_UI, this::setUseSwaggerUI);
convertPropertyToBooleanAndWriteBack(USE_SEALED, this::setUseSealed);
if (getDocumentationProvider().equals(DocumentationProvider.NONE)) {
this.setUseSwaggerUI(false);
}

View File

@ -6,7 +6,12 @@
<name>{{artifactId}}</name>
<version>{{artifactVersion}}</version>
<properties>
{{#useSealed}}
<java.version>17</java.version>
{{/useSealed}}
{{^useSealed}}
<java.version>1.8</java.version>
{{/useSealed}}
<maven.compiler.source>${java.version}</maven.compiler.source>
<maven.compiler.target>${java.version}</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>

View File

@ -6,7 +6,12 @@
<name>{{artifactId}}</name>
<version>{{artifactVersion}}</version>
<properties>
{{#useSealed}}
<java.version>17</java.version>
{{/useSealed}}
{{^useSealed}}
<java.version>8</java.version>
{{/useSealed}}
<maven.compiler.source>${java.version}</maven.compiler.source>
<maven.compiler.target>${java.version}</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>

View File

@ -6,7 +6,12 @@
<name>{{artifactId}}</name>
<version>{{artifactVersion}}</version>
<properties>
{{#useSealed}}
<java.version>17</java.version>
{{/useSealed}}
{{^useSealed}}
<java.version>1.8</java.version>
{{/useSealed}}
<maven.compiler.source>${java.version}</maven.compiler.source>
<maven.compiler.target>${java.version}</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>

View File

@ -6,7 +6,7 @@
{{>typeInfoAnnotation}}
{{/discriminator}}
{{>generatedAnnotation}}
public interface {{classname}}{{#vendorExtensions.x-implements}}{{#-first}} extends {{{.}}}{{/-first}}{{^-first}}, {{{.}}}{{/-first}}{{/vendorExtensions.x-implements}} {
public {{>sealed}}interface {{classname}}{{#vendorExtensions.x-implements}}{{#-first}} extends {{{.}}}{{/-first}}{{^-first}}, {{{.}}}{{/-first}}{{/vendorExtensions.x-implements}} {{>permits}}{
{{#discriminator}}
public {{propertyType}} {{propertyGetter}}();
{{/discriminator}}

View File

@ -0,0 +1 @@
{{#useSealed}}{{#permits}}{{#-first}}permits {{/-first}}{{{.}}}{{^-last}}, {{/-last}}{{/permits}} {{/useSealed}}

View File

@ -31,7 +31,7 @@
{{#vendorExtensions.x-class-extra-annotation}}
{{{vendorExtensions.x-class-extra-annotation}}}
{{/vendorExtensions.x-class-extra-annotation}}
public class {{classname}}{{#parent}} extends {{{parent}}}{{/parent}}{{^parent}}{{#hateoas}} extends RepresentationModel<{{classname}}> {{/hateoas}}{{/parent}}{{#vendorExtensions.x-implements}}{{#-first}} implements {{{.}}}{{/-first}}{{^-first}}, {{{.}}}{{/-first}}{{/vendorExtensions.x-implements}} {
public {{>sealed}}class {{classname}}{{#parent}} extends {{{parent}}}{{/parent}}{{^parent}}{{#hateoas}} extends RepresentationModel<{{classname}}> {{/hateoas}}{{/parent}}{{#vendorExtensions.x-implements}}{{#-first}} implements {{{.}}}{{/-first}}{{^-first}}, {{{.}}}{{/-first}}{{/vendorExtensions.x-implements}} {{>permits}}{
{{#serializableModel}}
private static final long serialVersionUID = 1L;

View File

@ -0,0 +1 @@
{{#useSealed}}{{#permits.0}}sealed {{/permits.0}}{{^permits.0}}{{^vendorExtensions.x-is-one-of-interface}}final {{/vendorExtensions.x-is-one-of-interface}}{{/permits.0}}{{/useSealed}}

View File

@ -2259,6 +2259,78 @@ public class SpringCodegenTest {
.assertParameter("pageable").hasType("Pageable");
}
@DataProvider(name = "sealedScenarios")
public static Object[][] sealedScenarios() {
return new Object[][]{
{"oneof_polymorphism_and_inheritance.yaml", Map.of(
"Foo.java", "public final class Foo extends Entity implements FooRefOrValue",
"FooRef.java", "public final class FooRef extends EntityRef implements FooRefOrValue",
"FooRefOrValue.java", "public sealed interface FooRefOrValue permits Foo, FooRef ",
"Entity.java", "public sealed class Entity extends RepresentationModel<Entity> permits Bar, BarCreate, Foo, Pasta, Pizza {")},
{"oneOf_additionalProperties.yaml", Map.of(
"SchemaA.java", "public final class SchemaA extends RepresentationModel<SchemaA> implements PostRequest {",
"PostRequest.java", "public sealed interface PostRequest permits SchemaA {")},
{"oneOf_array.yaml", Map.of(
"MyExampleGet200Response.java", "public interface MyExampleGet200Response")},
{"oneOf_duplicateArray.yaml", Map.of(
"Example.java", "public interface Example {")},
{"oneOf_nonPrimitive.yaml", Map.of(
"Example.java", "public interface Example {")},
{"oneOf_primitive.yaml", Map.of(
"Child.java", "public final class Child extends RepresentationModel<Child> implements Example {",
"Example.java", "public sealed interface Example permits Child {")},
{"oneOf_primitiveAndArray.yaml", Map.of(
"Example.java", "public interface Example {")},
{"oneOf_reuseRef.yaml", Map.of(
"Fruit.java", "public sealed interface Fruit permits Apple, Banana {",
"Banana.java", "public final class Banana extends RepresentationModel<Banana> implements Fruit {",
"Apple.java", "public final class Apple extends RepresentationModel<Apple> implements Fruit {")},
{"oneOf_twoPrimitives.yaml", Map.of(
"MyExamplePostRequest.java", "public interface MyExamplePostRequest {")},
{"oneOfArrayMapImport.yaml", Map.of(
"Fruit.java", "public interface Fruit {",
"Grape.java", "public final class Grape extends RepresentationModel<Grape> {",
"Apple.java", "public final class Apple extends RepresentationModel<Apple> {")},
{"oneOfDiscriminator.yaml", Map.of(
"FruitAllOfDisc.java", "public sealed interface FruitAllOfDisc permits AppleAllOfDisc, BananaAllOfDisc {",
"FruitReqDisc.java", "public sealed interface FruitReqDisc permits AppleReqDisc, BananaReqDisc {\n")}
};
}
@Test(dataProvider = "sealedScenarios", description = "sealed scenarios")
public void sealedScenarios(String apiFile, Map<String, String> definitions) throws IOException {
File output = Files.createTempDirectory("test").toFile().getCanonicalFile();
output.deleteOnExit();
String outputPath = output.getAbsolutePath().replace('\\', '/');
OpenAPI openAPI = new OpenAPIParser()
.readLocation("src/test/resources/3_0/" + apiFile, null, new ParseOptions()).getOpenAPI();
SpringCodegen codegen = new SpringCodegen();
codegen.setOutputDir(output.getAbsolutePath());
codegen.additionalProperties().put(CXFServerFeatures.LOAD_TEST_DATA_FROM_FILE, "true");
codegen.setUseOneOfInterfaces(true);
codegen.setUseSealed(true);
ClientOptInput input = new ClientOptInput();
input.openAPI(openAPI);
input.config(codegen);
DefaultGenerator generator = new DefaultGenerator();
codegen.setHateoas(true);
generator.setGenerateMetadata(false); // skip metadata and only generate models
generator.setGeneratorPropertyDefault(CodegenConstants.MODELS, "true");
generator.setGeneratorPropertyDefault(CodegenConstants.MODEL_TESTS, "false");
generator.setGeneratorPropertyDefault(CodegenConstants.MODEL_DOCS, "false");
generator.setGeneratorPropertyDefault(CodegenConstants.LEGACY_DISCRIMINATOR_BEHAVIOR, "false");
codegen.setLegacyDiscriminatorBehavior(false);
generator.opts(input).generate();
definitions.forEach((file, check) ->
assertFileContains(Paths.get(outputPath + "/src/main/java/org/openapitools/model/" + file), check));
}
@Test
public void shouldSetDefaultValueForMultipleArrayItems() throws IOException {
Map<String, Object> additionalProperties = new HashMap<>();