diff --git a/docs/generators/java-camel.md b/docs/generators/java-camel.md index f6c41195627..b0285c683b7 100644 --- a/docs/generators/java-camel.md +++ b/docs/generators/java-camel.md @@ -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| diff --git a/docs/generators/spring.md b/docs/generators/spring.md index ec4a499b39a..d44b91c69be 100644 --- a/docs/generators/spring.md +++ b/docs/generators/spring.md @@ -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| diff --git a/flake.lock b/flake.lock index da1901930c2..f5068e35e84 100644 --- a/flake.lock +++ b/flake.lock @@ -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", diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/CodegenModel.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/CodegenModel.java index 5303598ed7a..28735ad2df3 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/CodegenModel.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/CodegenModel.java @@ -59,6 +59,9 @@ public class CodegenModel implements IJsonSchemaValidationProperties { public Set oneOf = new TreeSet<>(); public Set allOf = new TreeSet<>(); + // direct descendants that are allowed to extend the current model + public List 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); 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 7cf86d38465..2cf47ef4748 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 @@ -621,16 +621,25 @@ public class DefaultCodegen implements CodegenConfig { // Let parent know about all its children for (Map.Entry 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 { 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 9a613c3bb67..d5466ef16db 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 @@ -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); } diff --git a/modules/openapi-generator/src/main/resources/JavaSpring/libraries/spring-boot/pom.mustache b/modules/openapi-generator/src/main/resources/JavaSpring/libraries/spring-boot/pom.mustache index 2eaf236d286..95dd2b9137f 100644 --- a/modules/openapi-generator/src/main/resources/JavaSpring/libraries/spring-boot/pom.mustache +++ b/modules/openapi-generator/src/main/resources/JavaSpring/libraries/spring-boot/pom.mustache @@ -6,7 +6,12 @@ {{artifactId}} {{artifactVersion}} + {{#useSealed}} + 17 + {{/useSealed}} + {{^useSealed}} 1.8 + {{/useSealed}} ${java.version} ${java.version} UTF-8 diff --git a/modules/openapi-generator/src/main/resources/JavaSpring/libraries/spring-cloud/pom-sb3.mustache b/modules/openapi-generator/src/main/resources/JavaSpring/libraries/spring-cloud/pom-sb3.mustache index 54c4735c89f..058403027de 100644 --- a/modules/openapi-generator/src/main/resources/JavaSpring/libraries/spring-cloud/pom-sb3.mustache +++ b/modules/openapi-generator/src/main/resources/JavaSpring/libraries/spring-cloud/pom-sb3.mustache @@ -6,7 +6,12 @@ {{artifactId}} {{artifactVersion}} + {{#useSealed}} + 17 + {{/useSealed}} + {{^useSealed}} 8 + {{/useSealed}} ${java.version} ${java.version} UTF-8 diff --git a/modules/openapi-generator/src/main/resources/JavaSpring/libraries/spring-cloud/pom.mustache b/modules/openapi-generator/src/main/resources/JavaSpring/libraries/spring-cloud/pom.mustache index c1a8d9a96e4..3ac3b868332 100644 --- a/modules/openapi-generator/src/main/resources/JavaSpring/libraries/spring-cloud/pom.mustache +++ b/modules/openapi-generator/src/main/resources/JavaSpring/libraries/spring-cloud/pom.mustache @@ -6,7 +6,12 @@ {{artifactId}} {{artifactVersion}} + {{#useSealed}} + 17 + {{/useSealed}} + {{^useSealed}} 1.8 + {{/useSealed}} ${java.version} ${java.version} UTF-8 diff --git a/modules/openapi-generator/src/main/resources/JavaSpring/oneof_interface.mustache b/modules/openapi-generator/src/main/resources/JavaSpring/oneof_interface.mustache index 679fe3d8831..e83ac99867f 100644 --- a/modules/openapi-generator/src/main/resources/JavaSpring/oneof_interface.mustache +++ b/modules/openapi-generator/src/main/resources/JavaSpring/oneof_interface.mustache @@ -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}} diff --git a/modules/openapi-generator/src/main/resources/JavaSpring/permits.mustache b/modules/openapi-generator/src/main/resources/JavaSpring/permits.mustache new file mode 100644 index 00000000000..5d92534d655 --- /dev/null +++ b/modules/openapi-generator/src/main/resources/JavaSpring/permits.mustache @@ -0,0 +1 @@ +{{#useSealed}}{{#permits}}{{#-first}}permits {{/-first}}{{{.}}}{{^-last}}, {{/-last}}{{/permits}} {{/useSealed}} \ No newline at end of file diff --git a/modules/openapi-generator/src/main/resources/JavaSpring/pojo.mustache b/modules/openapi-generator/src/main/resources/JavaSpring/pojo.mustache index 6dc4ec4da3c..9ad38706d6a 100644 --- a/modules/openapi-generator/src/main/resources/JavaSpring/pojo.mustache +++ b/modules/openapi-generator/src/main/resources/JavaSpring/pojo.mustache @@ -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; diff --git a/modules/openapi-generator/src/main/resources/JavaSpring/sealed.mustache b/modules/openapi-generator/src/main/resources/JavaSpring/sealed.mustache new file mode 100644 index 00000000000..a5c0af00270 --- /dev/null +++ b/modules/openapi-generator/src/main/resources/JavaSpring/sealed.mustache @@ -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}} \ 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 0645b61eafb..ef7230270fb 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 @@ -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 permits Bar, BarCreate, Foo, Pasta, Pizza {")}, + {"oneOf_additionalProperties.yaml", Map.of( + "SchemaA.java", "public final class SchemaA extends RepresentationModel 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 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 implements Fruit {", + "Apple.java", "public final class Apple extends RepresentationModel 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 {", + "Apple.java", "public final class Apple extends RepresentationModel {")}, + {"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 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 additionalProperties = new HashMap<>();