Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/samples-jdk17.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ on:
- samples/server/petstore/java-camel/**
- samples/server/petstore/java-helidon-server/v3/mp/**
- samples/server/petstore/java-helidon-server/v3/se/**
- samples/server/petstore/jaxrs-spec-sealed/**
pull_request:
paths:
# clients
Expand All @@ -44,6 +45,7 @@ on:
- samples/server/petstore/java-camel/**
- samples/server/petstore/java-helidon-server/v3/mp/**
- samples/server/petstore/java-helidon-server/v3/se/**
- samples/server/petstore/jaxrs-spec-sealed/**
jobs:
build:
name: Build with JDK17
Expand Down Expand Up @@ -72,6 +74,7 @@ jobs:
- samples/server/petstore/java-camel/
- samples/server/petstore/java-helidon-server/v3/mp/
- samples/server/petstore/java-helidon-server/v3/se
- samples/server/petstore/jaxrs-spec-sealed
steps:
- uses: actions/checkout@v7
- uses: actions/setup-java@v5
Expand Down
10 changes: 10 additions & 0 deletions bin/configs/jaxrs-spec-sealed.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
generatorName: jaxrs-spec
outputDir: samples/server/petstore/jaxrs-spec-sealed
inputSpec: modules/openapi-generator/src/test/resources/3_0/jaxrs-spec/oneof_interface.yaml
templateDir: modules/openapi-generator/src/main/resources/JavaJaxRS/spec
additionalProperties:
artifactId: jaxrs-spec-sealed-petstore-server
useOneOfInterfaces: "true"
useSealed: "true"
serializableModel: "true"
hideGenerationTimestamp: "true"
1 change: 1 addition & 0 deletions docs/generators/jaxrs-cxf-cdi.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ These options may be applied as additional-properties (cli) or configOptions (pl
|useMicroProfileOpenAPIAnnotations|Whether to generate Microprofile OpenAPI annotations. Only valid when library is set to quarkus.| |false|
|useMutiny|Whether to use Smallrye Mutiny instead of CompletionStage for asynchronous computation. Only valid when library is set to quarkus.| |false|
|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|
|useSealed|Whether to generate sealed model interfaces and classes.| |false|
|useSwaggerAnnotations|Whether to generate Swagger annotations.| |true|
|useSwaggerV3Annotations|Whether to generate Swagger v3 (OpenAPI v3) annotations.| |false|
|useTags|use tags for creating interface and controller classnames| |false|
Expand Down
1 change: 1 addition & 0 deletions docs/generators/jaxrs-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ These options may be applied as additional-properties (cli) or configOptions (pl
|useMicroProfileOpenAPIAnnotations|Whether to generate Microprofile OpenAPI annotations. Only valid when library is set to quarkus.| |false|
|useMutiny|Whether to use Smallrye Mutiny instead of CompletionStage for asynchronous computation. Only valid when library is set to quarkus.| |false|
|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|
|useSealed|Whether to generate sealed model interfaces and classes.| |false|
|useSwaggerAnnotations|Whether to generate Swagger annotations.| |true|
|useSwaggerV3Annotations|Whether to generate Swagger v3 (OpenAPI v3) annotations.| |false|
|useTags|use tags for creating interface and controller classnames| |false|
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ public class JavaJAXRSSpecServerCodegen extends AbstractJavaJAXRSServerCodegen {
public static final String GENERATE_JSON_CREATOR = "generateJsonCreator";
public static final String USE_JAKARTA_SECURITY_ANNOTATIONS = "useJakartaSecurityAnnotations";
public static final String USE_ENUM_CASE_INSENSITIVE = "useEnumCaseInsensitive";
public static final String USE_SEALED = "useSealed";

public static final String QUARKUS_LIBRARY = "quarkus";
public static final String THORNTAIL_LIBRARY = "thorntail";
Expand All @@ -79,6 +80,9 @@ public class JavaJAXRSSpecServerCodegen extends AbstractJavaJAXRSServerCodegen {
@Setter
private boolean useEnumCaseInsensitive = false;

@Setter
protected boolean useSealed = false;

private final JakartaSecurityAnnotationProcessor jakartaSecurityAnnotationProcessor = new JakartaSecurityAnnotationProcessor();

@Getter @Setter
Expand Down Expand Up @@ -162,6 +166,7 @@ public JavaJAXRSSpecServerCodegen() {
cliOptions.add(CliOption.newBoolean(USE_JAKARTA_SECURITY_ANNOTATIONS, "Whether to generate Jakarta security annotations (@RolesAllowed, @PermitAll). Requires useJakartaEe=true. Currently only supported when library is set to quarkus.", useJakartaSecurityAnnotations));
cliOptions.add(CliOption.newBoolean(GENERATE_JSON_CREATOR, "Whether to generate @JsonCreator constructor for required properties.", generateJsonCreator));
cliOptions.add(CliOption.newBoolean(USE_ENUM_CASE_INSENSITIVE, "Use `equalsIgnoreCase` when String for enum comparison", useEnumCaseInsensitive));
cliOptions.add(CliOption.newBoolean(USE_SEALED, "Whether to generate sealed model interfaces and classes.", useSealed));
}

@Override
Expand Down Expand Up @@ -205,6 +210,7 @@ public void processOpts() {

convertPropertyToBooleanAndWriteBack(GENERATE_JSON_CREATOR, this::setGenerateJsonCreator);
convertPropertyToBooleanAndWriteBack(USE_ENUM_CASE_INSENSITIVE, this::setUseEnumCaseInsensitive);
convertPropertyToBooleanAndWriteBack(USE_SEALED, this::setUseSealed);

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.

P1: Missing validation that useSealed=true requires useOneOfInterfaces=true. The PR description and design documentation both state that useSealed requires useOneOfInterfaces, but the code only wires up the option value without checking this prerequisite. When useSealed=true is set without useOneOfInterfaces=true, the sealed.mustache template still applies the final keyword to every POJO class (since the template outputs final for non-sealed, non-oneof-interface classes when useSealed is true), which silently changes the generated code and could break existing consumers that rely on subclassing generated model classes. The codebase already has an established validation pattern for cross-option dependencies — for example, useJakartaSecurityAnnotations validates that useJakartaEe=true is also set with a clear IllegalArgumentException. The same pattern should be applied here.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/JavaJAXRSSpecServerCodegen.java, line 213:

<comment>Missing validation that `useSealed=true` requires `useOneOfInterfaces=true`. The PR description and design documentation both state that `useSealed` requires `useOneOfInterfaces`, but the code only wires up the option value without checking this prerequisite. When `useSealed=true` is set without `useOneOfInterfaces=true`, the `sealed.mustache` template still applies the `final` keyword to every POJO class (since the template outputs `final` for non-sealed, non-oneof-interface classes when `useSealed` is true), which silently changes the generated code and could break existing consumers that rely on subclassing generated model classes. The codebase already has an established validation pattern for cross-option dependencies — for example, `useJakartaSecurityAnnotations` validates that `useJakartaEe=true` is also set with a clear `IllegalArgumentException`. The same pattern should be applied here.</comment>

<file context>
@@ -205,6 +210,7 @@ public void processOpts() {
 
         convertPropertyToBooleanAndWriteBack(GENERATE_JSON_CREATOR, this::setGenerateJsonCreator);
         convertPropertyToBooleanAndWriteBack(USE_ENUM_CASE_INSENSITIVE, this::setUseEnumCaseInsensitive);
+        convertPropertyToBooleanAndWriteBack(USE_SEALED, this::setUseSealed);
 
         if (additionalProperties.containsKey(OPEN_API_SPEC_FILE_LOCATION)) {
</file context>

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Valid catch that useSealed=true without useOneOfInterfaces was an untested combination, but it is resolved by making the combination first-class rather than rejecting it, for two reasons:

  1. useSealed is useful on its own: an allOf hierarchy driven by a parent discriminator generates public sealed class Pet permits Cat, Dog with final subclasses that extend it — no oneOf interfaces involved. Requiring useOneOfInterfaces would forbid that use case.
  2. It matches the existing useSealed semantics in the Spring generator, which has no such prerequisite (Spring renders oneOf containers as interfaces natively, so it never hits the broken case below).

There WAS a real bug in this combination: core adds oneOf members to CodegenModel.permits unconditionally, so a oneOf container rendered as a plain class (no useOneOfInterfaces) produced public sealed class PetRequest permits CatRequest, DogRequest while neither subtype extends it — uncompilable. Fixed in postProcessAllModels: oneOf-derived permits are dropped from models not rendered as oneOf interfaces, so the container degrades to a final class like any other model without subclasses.

The final-on-every-pojo behavior when useSealed=true is intentional and identical to the Spring generator's useSealed (the templates are the same); it only affects users who opt into the flag.

New tests cover all of this: testSealedClassHierarchyGeneration, testUseSealedWithoutOneOfInterfaces, and testWithoutUseSealedOutputIsUnchanged (defaults regression, pom stays 1.8).


if (additionalProperties.containsKey(OPEN_API_SPEC_FILE_LOCATION)) {
openApiSpecFileLocation = additionalProperties.get(OPEN_API_SPEC_FILE_LOCATION).toString();
Expand Down Expand Up @@ -424,6 +430,13 @@ public Map<String, ModelsMap> postProcessAllModels(Map<String, ModelsMap> objs)
}
}
}
// A oneOf container rendered as a plain class (useOneOfInterfaces disabled or the model
// not selected for interface generation) must not be sealed over its oneOf members: they
// do not extend it, so a permits clause would not compile. Only child-derived permits
// (subclasses that actually extend the model) may remain.
if (useSealed && !Boolean.TRUE.equals(model.getVendorExtensions().get("x-is-one-of-interface"))) {
model.permits.removeAll(model.oneOf);
}
}
}
return result;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
{{/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}}
{{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}}{{#-last}} {{/-last}}{{/permits}}{{/useSealed}}
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ import {{javaxPackage}}.xml.bind.annotation.XmlEnumValue;
{{#vendorExtensions.x-class-extra-annotation}}
{{{.}}}
{{/vendorExtensions.x-class-extra-annotation}}
public class {{classname}} {{#parent}}extends {{{.}}}{{/parent}} {{#vendorExtensions.x-implements}}{{#-first}}implements {{{.}}}{{/-first}}{{^-first}}, {{{.}}}{{/-first}}{{/vendorExtensions.x-implements}} {
public {{>sealed}}class {{classname}} {{#parent}}extends {{{.}}}{{/parent}} {{#vendorExtensions.x-implements}}{{#-first}}implements {{{.}}}{{/-first}}{{^-first}}, {{{.}}}{{/-first}}{{/vendorExtensions.x-implements}} {{>permits}}{
{{#vars}}
{{#isEnum}}
{{^isContainer}}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,7 @@
{{/openApiNullable}}
</dependencies>
<properties>
<java.version>1.8</java.version>
<java.version>{{#useSealed}}17{{/useSealed}}{{^useSealed}}1.8{{/useSealed}}</java.version>
<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
@@ -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 @@ -1371,6 +1371,167 @@ public void testDiscriminatorJsonIgnorePropertiesPropagatesToChildren_whenLegacy
}
}

/**
* With {@code useOneOfInterfaces=true} a oneOf schema is generated as a Java interface, and the
* concrete subtypes implement it. With {@code useSealed=true} the interface is {@code sealed} and
* {@code permits} its subtypes, which become {@code final} and {@code implements} the interface.
* The discriminator property is declared only on a shared non-discriminator base (acyclic pattern),
* so the interface getter type must resolve to the enum model (PetType) from the mapped children.
*/
@Test
public void testOneOfSealedInterfaceGeneration() throws Exception {
File output = Files.createTempDirectory("test").toFile().getCanonicalFile();
output.deleteOnExit();

Map<String, Object> properties = new HashMap<>();
properties.put("useOneOfInterfaces", "true");
properties.put("useSealed", "true");

final CodegenConfigurator configurator = new CodegenConfigurator()
.setGeneratorName("jaxrs-spec")
.setAdditionalProperties(properties)
.setInputSpec("src/test/resources/3_0/jaxrs-spec/oneof_interface.yaml")
.setOutputDir(output.getAbsolutePath().replace("\\", "/"));

DefaultGenerator generator = new DefaultGenerator();
generator.opts(configurator.toClientOptInput()).generate();

// assertFileContains is used rather than JavaFileAssert because the latter parses the source
// with a JavaParser language level that predates sealed types and would reject the `sealed`
// and `permits` keywords.
String modelDir = output.getAbsolutePath().replace("\\", "/") + "/src/gen/java/org/openapitools/model/";

// The oneOf schema becomes a sealed interface that permits its subtypes, and declares the
// discriminator getter with the resolved enum type rather than String.
assertFileContains(Paths.get(modelDir + "PetRequest.java"),
"public sealed interface PetRequest",
"permits CatRequest, DogRequest",
"PetType getPetType();");
// an interface is not a class and must not extend a parent
assertFileNotContains(Paths.get(modelDir + "PetRequest.java"), "class PetRequest");

// The concrete subtypes are final and implement (not extend) the interface, with a matching
// getter return type, so there is no cyclical extends/implements and no return-type clash.
assertFileContains(Paths.get(modelDir + "CatRequest.java"),
"public final class CatRequest",
"implements PetRequest",
"public PetType getPetType()");
assertFileNotContains(Paths.get(modelDir + "CatRequest.java"), "extends PetRequest");

assertFileContains(Paths.get(modelDir + "DogRequest.java"),
"public final class DogRequest",
"implements PetRequest",
"public PetType getPetType()");
assertFileNotContains(Paths.get(modelDir + "DogRequest.java"), "extends PetRequest");
}

/**
* With {@code useSealed=true} an allOf class hierarchy driven by a parent discriminator is
* generated as a sealed parent class that permits its subclasses; the subclasses extend the
* parent and become final. A standalone model with no subtypes becomes final, and the generated
* pom targets Java 17 (sealed types need JDK 17+).
*/
@Test
public void testSealedClassHierarchyGeneration() throws Exception {
File output = Files.createTempDirectory("test").toFile().getCanonicalFile();
output.deleteOnExit();

Map<String, Object> properties = new HashMap<>();
properties.put("useSealed", "true");

final CodegenConfigurator configurator = new CodegenConfigurator()
.setGeneratorName("jaxrs-spec")
.setAdditionalProperties(properties)
.setInputSpec("src/test/resources/3_0/jaxrs-spec/sealed_hierarchy.yaml")
.setOutputDir(output.getAbsolutePath().replace("\\", "/"));

DefaultGenerator generator = new DefaultGenerator();
generator.opts(configurator.toClientOptInput()).generate();

String outputDir = output.getAbsolutePath().replace("\\", "/");
String modelDir = outputDir + "/src/gen/java/org/openapitools/model/";

assertFileContains(Paths.get(modelDir + "Pet.java"),
"public sealed class Pet",
"permits Cat, Dog");
assertFileContains(Paths.get(modelDir + "Cat.java"),
"public final class Cat",
"extends Pet");
assertFileContains(Paths.get(modelDir + "Dog.java"),
"public final class Dog",
"extends Pet");
// a standalone model has no subtypes to seal over and becomes final
assertFileContains(Paths.get(modelDir + "Toy.java"), "public final class Toy");
assertFileNotContains(Paths.get(modelDir + "Toy.java"), "sealed ", "permits ");

assertFileContains(Paths.get(outputDir + "/pom.xml"), "<java.version>17</java.version>");
}

/**
* {@code useSealed=true} without {@code useOneOfInterfaces}: the oneOf container is rendered as
* a plain class whose oneOf members do not extend it, so it must not carry a sealed/permits
* clause over them (the generated code would not compile). Like any other model without
* subclasses it becomes final.
*/
@Test
public void testUseSealedWithoutOneOfInterfaces() throws Exception {
File output = Files.createTempDirectory("test").toFile().getCanonicalFile();
output.deleteOnExit();

Map<String, Object> properties = new HashMap<>();
properties.put("useSealed", "true");

final CodegenConfigurator configurator = new CodegenConfigurator()
.setGeneratorName("jaxrs-spec")
.setAdditionalProperties(properties)
.setInputSpec("src/test/resources/3_0/jaxrs-spec/oneof_interface.yaml")
.setOutputDir(output.getAbsolutePath().replace("\\", "/"));

DefaultGenerator generator = new DefaultGenerator();
generator.opts(configurator.toClientOptInput()).generate();

String modelDir = output.getAbsolutePath().replace("\\", "/") + "/src/gen/java/org/openapitools/model/";

assertFileContains(Paths.get(modelDir + "PetRequest.java"), "public final class PetRequest");
assertFileNotContains(Paths.get(modelDir + "PetRequest.java"), "sealed ", "permits ");
assertFileContains(Paths.get(modelDir + "CatRequest.java"), "public final class CatRequest");
assertFileContains(Paths.get(modelDir + "DogRequest.java"), "public final class DogRequest");
assertFileContains(Paths.get(modelDir + "PetBase.java"), "public final class PetBase");
}

/**
* Without {@code useSealed} the output is unchanged: no sealed/final/permits modifiers are
* emitted (even though the permits list is populated on the models) and the generated pom
* still targets Java 1.8.
*/
@Test
public void testWithoutUseSealedOutputIsUnchanged() throws Exception {
File output = Files.createTempDirectory("test").toFile().getCanonicalFile();
output.deleteOnExit();

Map<String, Object> properties = new HashMap<>();
properties.put("useOneOfInterfaces", "true");

final CodegenConfigurator configurator = new CodegenConfigurator()
.setGeneratorName("jaxrs-spec")
.setAdditionalProperties(properties)
.setInputSpec("src/test/resources/3_0/jaxrs-spec/oneof_interface.yaml")
.setOutputDir(output.getAbsolutePath().replace("\\", "/"));

DefaultGenerator generator = new DefaultGenerator();
generator.opts(configurator.toClientOptInput()).generate();

String outputDir = output.getAbsolutePath().replace("\\", "/");
String modelDir = outputDir + "/src/gen/java/org/openapitools/model/";

assertFileContains(Paths.get(modelDir + "PetRequest.java"), "public interface PetRequest");
assertFileNotContains(Paths.get(modelDir + "PetRequest.java"), "sealed ", "permits ");
assertFileContains(Paths.get(modelDir + "CatRequest.java"), "public class CatRequest");
assertFileNotContains(Paths.get(modelDir + "CatRequest.java"), "final class");

assertFileContains(Paths.get(outputDir + "/pom.xml"), "<java.version>1.8</java.version>");
}

@Test
public void testGenerateJsonNullableListFieldsHelperMethodReferences_issue23251() throws Exception {
Map<String, Object> properties = new HashMap<>();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
openapi: 3.0.3
info:
title: sealed class hierarchy
description: >
An allOf class hierarchy driven by a parent discriminator. Under useSealed the
parent class is sealed and permits its subclasses, which extend it and become final.
version: 1.0.0
paths:
/pets:
post:
operationId: createPet
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/Pet'
responses:
'201':
description: created
components:
schemas:
Pet:
type: object
required:
- petType
- name
properties:
petType:
type: string
name:
type: string
discriminator:
propertyName: petType
mapping:
CAT: '#/components/schemas/Cat'
DOG: '#/components/schemas/Dog'

Cat:
allOf:
- $ref: '#/components/schemas/Pet'
- type: object
properties:
indoor:
type: boolean

Dog:
allOf:
- $ref: '#/components/schemas/Pet'
- type: object
properties:
trained:
type: boolean

Toy:
type: object
properties:
name:
type: string
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# OpenAPI Generator Ignore
# Generated by openapi-generator https://github.com/openapitools/openapi-generator

# Use this file to prevent files from being overwritten by the generator.
# The patterns follow closely to .gitignore or .dockerignore.

# As an example, the C# client generator defines ApiClient.cs.
# You can make changes and tell OpenAPI Generator to ignore just this file by uncommenting the following line:
#ApiClient.cs

# You can match any string of characters against a directory, file or extension with a single asterisk (*):
#foo/*/qux
# The above matches foo/bar/qux and foo/baz/qux, but not foo/bar/baz/qux

# You can recursively match patterns against a directory, file or extension with a double asterisk (**):
#foo/**/qux
# This matches foo/bar/qux, foo/baz/qux, and foo/bar/baz/qux

# You can also negate patterns with an exclamation (!).
# For example, you can ignore all files in a docs folder with the file extension .md:
#docs/*.md
# Then explicitly reverse the ignore rule for a single file:
#!docs/README.md
11 changes: 11 additions & 0 deletions samples/server/petstore/jaxrs-spec-sealed/.openapi-generator/FILES
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
README.md
pom.xml
src/gen/java/org/openapitools/api/PetsApi.java
src/gen/java/org/openapitools/api/RestApplication.java
src/gen/java/org/openapitools/api/RestResourceRoot.java
src/gen/java/org/openapitools/model/CatRequest.java
src/gen/java/org/openapitools/model/DogRequest.java
src/gen/java/org/openapitools/model/PetBase.java
src/gen/java/org/openapitools/model/PetRequest.java
src/gen/java/org/openapitools/model/PetType.java
src/main/openapi/openapi.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
7.24.0-SNAPSHOT
Loading
Loading