Skip to content

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

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 15 commits into from
Feb 19, 2025
1 change: 1 addition & 0 deletions docs/generators/java-camel.md
Original file line number Diff line number Diff line change
Expand Up @@ -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|
Expand Down
1 change: 1 addition & 0 deletions docs/generators/spring.md
Original file line number Diff line number Diff line change
Expand Up @@ -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|
Expand Down
30 changes: 24 additions & 6 deletions flake.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -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<>();
Copy link
Member

Choose a reason for hiding this comment

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

can you please add a 1-liner explain what permits is?

Copy link
Member

Choose a reason for hiding this comment

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

is that "similar to" children (List<CodegenModel>) ?

Copy link
Contributor Author

@alex-nt alex-nt Feb 11, 2025

Choose a reason for hiding this comment

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

Similar but different. children contains the full list of children, even indirect children. permits contains only the direct descendants (first level).

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@wing328 do you see this differently? Any suggestion on how I should change it? :D


// The schema name as written in the OpenAPI document
// If it's a reserved word, it will be escaped.
@Getter @Setter
Expand Down Expand Up @@ -922,6 +925,7 @@ public boolean equals(Object o) {
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) &&
Expand Down Expand Up @@ -975,7 +979,7 @@ public boolean equals(Object o) {
@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,
Expand Down Expand Up @@ -1005,6 +1009,7 @@ public String toString() {
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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -621,16 +621,25 @@ public Map<String, ModelsMap> updateAllModels(Map<String, ModelsMap> objs) {

// 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) {
Expand Down Expand Up @@ -2704,7 +2713,6 @@ protected void updateModelForComposedSchema(CodegenModel m, Schema schema, Map<S
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)) {
Expand Down Expand Up @@ -2751,6 +2759,9 @@ protected void updateModelForComposedSchema(CodegenModel m, Schema schema, Map<S
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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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."),
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -229,6 +231,8 @@ public SpringCodegen() {
.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
Expand Down Expand Up @@ -423,6 +427,7 @@ public void processOpts() {
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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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}}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{{#useSealed}}{{#permits}}{{#-first}}permits {{/-first}}{{{.}}}{{^-last}}, {{/-last}}{{/permits}} {{/useSealed}}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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}}
Original file line number Diff line number Diff line change
Expand Up @@ -2259,6 +2259,78 @@ public void paramPageableIsNotSpringPaginated_issue13052() throws Exception {
.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<>();
Expand Down
Loading