Skip to content

Commit 940a93b

Browse files
committed
[jaxrs-spec] Support useRecords
Adds a useRecords option to the jaxrs-spec generator. When useRecords=true, the concrete subtypes that implement a generated oneOf interface render as Java records instead of mutable classes. - JavaJAXRSSpecServerCodegen: useRecords option, and postProcessAllModels marks record-eligible implementors with x-jaxrs-record (no parent, no additional properties, implements a generated oneOf interface). - record.mustache renders the record; model.mustache routes x-jaxrs-record to it. - oneof_interface.mustache declares the discriminator accessor in record style (petType()) under useRecords so records satisfy the interface via their canonical accessors; sealed.mustache no longer marks a record final. - pom.mustache bumps java.version to 17 when useRecords (records need JDK 16+). - Adds testOneOfRecordImplementationGeneration and a jaxrs-spec-records sample (registered in the JDK17 samples workflow). useRecords is independent of useSealed. The feature stays opt-in; default jaxrs-spec output is unchanged.
1 parent 36bc084 commit 940a93b

23 files changed

Lines changed: 782 additions & 10 deletions

File tree

.github/workflows/samples-jdk17.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ on:
2323
- samples/server/petstore/java-helidon-server/v3/mp/**
2424
- samples/server/petstore/java-helidon-server/v3/se/**
2525
- samples/server/petstore/jaxrs-spec-sealed/**
26+
- samples/server/petstore/jaxrs-spec-records/**
2627
pull_request:
2728
paths:
2829
# clients
@@ -46,6 +47,7 @@ on:
4647
- samples/server/petstore/java-helidon-server/v3/mp/**
4748
- samples/server/petstore/java-helidon-server/v3/se/**
4849
- samples/server/petstore/jaxrs-spec-sealed/**
50+
- samples/server/petstore/jaxrs-spec-records/**
4951
jobs:
5052
build:
5153
name: Build with JDK17
@@ -75,6 +77,7 @@ jobs:
7577
- samples/server/petstore/java-helidon-server/v3/mp/
7678
- samples/server/petstore/java-helidon-server/v3/se
7779
- samples/server/petstore/jaxrs-spec-sealed
80+
- samples/server/petstore/jaxrs-spec-records
7881
steps:
7982
- uses: actions/checkout@v7
8083
- uses: actions/setup-java@v5
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
generatorName: jaxrs-spec
2+
outputDir: samples/server/petstore/jaxrs-spec-records
3+
inputSpec: modules/openapi-generator/src/test/resources/3_0/jaxrs-spec/oneof_interface.yaml
4+
templateDir: modules/openapi-generator/src/main/resources/JavaJaxRS/spec
5+
additionalProperties:
6+
artifactId: jaxrs-spec-records-petstore-server
7+
useOneOfInterfaces: "true"
8+
useSealed: "true"
9+
useRecords: "true"
10+
serializableModel: "true"
11+
hideGenerationTimestamp: "true"

modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/JavaJAXRSSpecServerCodegen.java

Lines changed: 47 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,10 @@
3434
import java.io.File;
3535
import java.util.Collection;
3636
import java.util.HashMap;
37+
import java.util.HashSet;
3738
import java.util.List;
3839
import java.util.Map;
40+
import java.util.Set;
3941

4042
import static org.openapitools.codegen.languages.features.GzipFeatures.USE_GZIP_FEATURE;
4143

@@ -60,6 +62,7 @@ public class JavaJAXRSSpecServerCodegen extends AbstractJavaJAXRSServerCodegen {
6062
public static final String USE_JAKARTA_SECURITY_ANNOTATIONS = "useJakartaSecurityAnnotations";
6163
public static final String USE_ENUM_CASE_INSENSITIVE = "useEnumCaseInsensitive";
6264
public static final String USE_SEALED = "useSealed";
65+
public static final String USE_RECORDS = "useRecords";
6366

6467
public static final String QUARKUS_LIBRARY = "quarkus";
6568
public static final String THORNTAIL_LIBRARY = "thorntail";
@@ -83,6 +86,9 @@ public class JavaJAXRSSpecServerCodegen extends AbstractJavaJAXRSServerCodegen {
8386
@Setter
8487
protected boolean useSealed = false;
8588

89+
@Setter
90+
protected boolean useRecords = false;
91+
8692
private final JakartaSecurityAnnotationProcessor jakartaSecurityAnnotationProcessor = new JakartaSecurityAnnotationProcessor();
8793

8894
@Getter @Setter
@@ -167,6 +173,7 @@ public JavaJAXRSSpecServerCodegen() {
167173
cliOptions.add(CliOption.newBoolean(GENERATE_JSON_CREATOR, "Whether to generate @JsonCreator constructor for required properties.", generateJsonCreator));
168174
cliOptions.add(CliOption.newBoolean(USE_ENUM_CASE_INSENSITIVE, "Use `equalsIgnoreCase` when String for enum comparison", useEnumCaseInsensitive));
169175
cliOptions.add(CliOption.newBoolean(USE_SEALED, "Whether to generate sealed model interfaces and classes.", useSealed));
176+
cliOptions.add(CliOption.newBoolean(USE_RECORDS, "Whether to render the implementations of a generated oneOf interface (useOneOfInterfaces=true) as Java records instead of classes.", useRecords));
170177
}
171178

172179
@Override
@@ -211,6 +218,7 @@ public void processOpts() {
211218
convertPropertyToBooleanAndWriteBack(GENERATE_JSON_CREATOR, this::setGenerateJsonCreator);
212219
convertPropertyToBooleanAndWriteBack(USE_ENUM_CASE_INSENSITIVE, this::setUseEnumCaseInsensitive);
213220
convertPropertyToBooleanAndWriteBack(USE_SEALED, this::setUseSealed);
221+
convertPropertyToBooleanAndWriteBack(USE_RECORDS, this::setUseRecords);
214222

215223
if (additionalProperties.containsKey(OPEN_API_SPEC_FILE_LOCATION)) {
216224
openApiSpecFileLocation = additionalProperties.get(OPEN_API_SPEC_FILE_LOCATION).toString();
@@ -400,16 +408,20 @@ public OperationsMap postProcessOperationsWithModels(OperationsMap objs, List<Mo
400408
public Map<String, ModelsMap> postProcessAllModels(Map<String, ModelsMap> objs) {
401409
Map<String, ModelsMap> result = super.postProcessAllModels(objs);
402410

403-
// Index the discriminators of the generated oneOf interfaces by classname. A child of a oneOf
404-
// interface (useOneOfInterfaces) inherits a shared base via allOf and therefore has no Java
405-
// parentModel carrying the discriminator - the discriminator lives on the interface it implements.
411+
// Collect, for the interfaces generated by the useOneOfInterfaces feature: their classnames (to
412+
// identify which concrete models implement one of them and are therefore record candidates) and
413+
// their discriminators indexed by classname (a child of a oneOf interface inherits a shared base
414+
// via allOf and has no Java parentModel carrying the discriminator - it lives on the interface).
415+
Set<String> oneOfInterfaceNames = new HashSet<>();
406416
Map<String, CodegenDiscriminator> oneOfInterfaceDiscriminators = new HashMap<>();
407417
for (ModelsMap modelsMap : result.values()) {
408418
for (ModelMap modelMap : modelsMap.getModels()) {
409419
CodegenModel model = modelMap.getModel();
410-
if (Boolean.TRUE.equals(model.getVendorExtensions().get("x-is-one-of-interface"))
411-
&& model.getDiscriminator() != null) {
412-
oneOfInterfaceDiscriminators.put(model.classname, model.getDiscriminator());
420+
if (Boolean.TRUE.equals(model.getVendorExtensions().get("x-is-one-of-interface"))) {
421+
oneOfInterfaceNames.add(model.classname);
422+
if (model.getDiscriminator() != null) {
423+
oneOfInterfaceDiscriminators.put(model.classname, model.getDiscriminator());
424+
}
413425
}
414426
}
415427
}
@@ -437,6 +449,9 @@ public Map<String, ModelsMap> postProcessAllModels(Map<String, ModelsMap> objs)
437449
if (useSealed && !Boolean.TRUE.equals(model.getVendorExtensions().get("x-is-one-of-interface"))) {
438450
model.permits.removeAll(model.oneOf);
439451
}
452+
if (useRecords && isOneOfInterfaceRecordCandidate(model, oneOfInterfaceNames)) {
453+
model.getVendorExtensions().put("x-jaxrs-record", true);
454+
}
440455
}
441456
}
442457
return result;
@@ -463,6 +478,32 @@ private CodegenDiscriminator discriminatorOfImplementedOneOfInterface(
463478
return null;
464479
}
465480

481+
/**
482+
* A concrete model is rendered as a record only when it implements a oneOf interface produced by the
483+
* useOneOfInterfaces feature, and it is structurally record-compatible: it has no Java parent (records
484+
* cannot extend) and no additional (undeclared) properties (those require a mutable map-backed pojo).
485+
*
486+
* @param model the concrete model that may implement a oneOf interface
487+
* @param oneOfInterfaceNames the classnames of the generated oneOf interfaces
488+
* @return {@code true} when the model should be rendered as a record
489+
*/
490+
@SuppressWarnings("unchecked")
491+
private boolean isOneOfInterfaceRecordCandidate(CodegenModel model, Set<String> oneOfInterfaceNames) {
492+
if (model.getParent() != null || model.getAdditionalPropertiesType() != null) {
493+
return false;
494+
}
495+
Object implementsExtension = model.getVendorExtensions().get("x-implements");
496+
if (!(implementsExtension instanceof Collection)) {
497+
return false;
498+
}
499+
for (Object intf : (Collection<Object>) implementsExtension) {
500+
if (oneOfInterfaceNames.contains(String.valueOf(intf))) {
501+
return true;
502+
}
503+
}
504+
return false;
505+
}
506+
466507
@Override
467508
public CodegenOperation fromOperation(String path, String httpMethod, Operation operation, List<Server> servers) {
468509
CodegenOperation op = super.fromOperation(path, httpMethod, operation, servers);

modules/openapi-generator/src/main/resources/JavaJaxRS/spec/model.mustache

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,6 @@ import {{javaxPackage}}.validation.Valid;
1616
{{>enumOuterClass}}
1717

1818
{{/isEnum}}
19-
{{^isEnum}}{{#vendorExtensions.x-is-one-of-interface}}{{>oneof_interface}}{{/vendorExtensions.x-is-one-of-interface}}{{^vendorExtensions.x-is-one-of-interface}}{{>pojo}}{{/vendorExtensions.x-is-one-of-interface}}{{/isEnum}}
19+
{{^isEnum}}{{#vendorExtensions.x-is-one-of-interface}}{{>oneof_interface}}{{/vendorExtensions.x-is-one-of-interface}}{{^vendorExtensions.x-is-one-of-interface}}{{#vendorExtensions.x-jaxrs-record}}{{>record}}{{/vendorExtensions.x-jaxrs-record}}{{^vendorExtensions.x-jaxrs-record}}{{>pojo}}{{/vendorExtensions.x-jaxrs-record}}{{/vendorExtensions.x-is-one-of-interface}}{{/isEnum}}
2020
{{/model}}
2121
{{/models}}

modules/openapi-generator/src/main/resources/JavaJaxRS/spec/oneof_interface.mustache

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,6 @@
1616

1717
public {{>sealed}}interface {{classname}}{{#vendorExtensions.x-implements}}{{#-first}} extends {{{.}}}{{/-first}}{{^-first}}, {{{.}}}{{/-first}}{{/vendorExtensions.x-implements}} {{>permits}}{
1818
{{#discriminator}}
19-
{{propertyType}} {{propertyGetter}}();
19+
{{propertyType}} {{#useRecords}}{{propertyName}}{{/useRecords}}{{^useRecords}}{{propertyGetter}}{{/useRecords}}();
2020
{{/discriminator}}
2121
}

modules/openapi-generator/src/main/resources/JavaJaxRS/spec/pom.mustache

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -184,7 +184,7 @@
184184
{{/openApiNullable}}
185185
</dependencies>
186186
<properties>
187-
<java.version>{{#useSealed}}17{{/useSealed}}{{^useSealed}}1.8{{/useSealed}}</java.version>
187+
<java.version>{{#useSealed}}17{{/useSealed}}{{^useSealed}}{{#useRecords}}17{{/useRecords}}{{^useRecords}}1.8{{/useRecords}}{{/useSealed}}</java.version>
188188
<maven.compiler.source>${java.version}</maven.compiler.source>
189189
<maven.compiler.target>${java.version}</maven.compiler.target>
190190
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
{{#useSwaggerAnnotations}}
2+
import io.swagger.annotations.*;
3+
{{/useSwaggerAnnotations}}
4+
{{#useSwaggerV3Annotations}}
5+
import io.swagger.v3.oas.annotations.media.Schema;
6+
{{/useSwaggerV3Annotations}}
7+
{{#jackson}}
8+
import com.fasterxml.jackson.annotation.JsonProperty;
9+
import com.fasterxml.jackson.annotation.JsonValue;
10+
import com.fasterxml.jackson.annotation.JsonTypeName;
11+
{{/jackson}}
12+
{{#withXml}}
13+
import {{javaxPackage}}.xml.bind.annotation.XmlElement;
14+
import {{javaxPackage}}.xml.bind.annotation.XmlRootElement;
15+
import {{javaxPackage}}.xml.bind.annotation.XmlAccessType;
16+
import {{javaxPackage}}.xml.bind.annotation.XmlAccessorType;
17+
import {{javaxPackage}}.xml.bind.annotation.XmlType;
18+
import {{javaxPackage}}.xml.bind.annotation.XmlEnum;
19+
import {{javaxPackage}}.xml.bind.annotation.XmlEnumValue;
20+
{{/withXml}}
21+
22+
{{#discriminator}}{{>typeInfoAnnotation}}{{/discriminator}}{{#description}}/**
23+
* {{.}}
24+
{{#isDeprecated}}
25+
* @deprecated
26+
{{/isDeprecated}}
27+
**/{{/description}}
28+
{{#isDeprecated}}
29+
@Deprecated
30+
31+
{{/isDeprecated}}{{#useSwaggerAnnotations}}{{#description}}@ApiModel(description = "{{{.}}}"){{/description}}{{/useSwaggerAnnotations}}{{#useSwaggerV3Annotations}}
32+
@Schema({{#title}}title="{{{.}}}", {{/title}}{{#description}}description="{{{.}}}"{{/description}}{{^description}}description=""{{/description}}{{#isDeprecated}}, deprecated = true{{/isDeprecated}}){{/useSwaggerV3Annotations}}{{#useMicroProfileOpenAPIAnnotations}}
33+
@org.eclipse.microprofile.openapi.annotations.media.Schema({{#title}}title="{{{.}}}", {{/title}}{{#description}}description="{{{.}}}"{{/description}}{{^description}}description=""{{/description}}{{#isDeprecated}}, deprecated = true{{/isDeprecated}}){{/useMicroProfileOpenAPIAnnotations}}
34+
{{#jackson}}
35+
@JsonTypeName("{{#vendorExtensions.x-discriminator-value}}{{{vendorExtensions.x-discriminator-value}}}{{/vendorExtensions.x-discriminator-value}}{{^vendorExtensions.x-discriminator-value}}{{name}}{{/vendorExtensions.x-discriminator-value}}")
36+
{{/jackson}}
37+
{{>generatedAnnotation}}{{>additionalModelTypeAnnotations}}
38+
{{#vendorExtensions.x-class-extra-annotation}}
39+
{{{vendorExtensions.x-class-extra-annotation}}}
40+
{{/vendorExtensions.x-class-extra-annotation}}
41+
public {{>sealed}}record {{classname}}(
42+
{{#vars}}
43+
{{#vendorExtensions.x-field-extra-annotation}}
44+
{{{.}}}
45+
{{/vendorExtensions.x-field-extra-annotation}}
46+
{{#deprecated}}
47+
@Deprecated
48+
{{/deprecated}}
49+
{{#jackson}}@JsonProperty({{#required}}required = {{required}}, value = {{/required}}"{{baseName}}") {{/jackson}}{{#useBeanValidation}}{{>beanValidation}}{{/useBeanValidation}}{{{datatypeWithEnum}}} {{name}}{{^-last}},{{/-last}}
50+
{{/vars}}
51+
) {{#vendorExtensions.x-implements}}{{#-first}}implements {{{.}}}{{/-first}}{{^-first}}, {{{.}}}{{/-first}}{{/vendorExtensions.x-implements}} {
52+
{{#vars}}
53+
{{#isEnum}}
54+
{{^isContainer}}
55+
{{>enumClass}}{{! prevent indent}}
56+
{{/isContainer}}
57+
{{#isContainer}}
58+
{{#mostInnerItems}}
59+
{{>enumClass}}{{! prevent indent}}
60+
{{/mostInnerItems}}
61+
{{/isContainer}}
62+
{{/isEnum}}
63+
{{/vars}}
64+
}
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
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}}
1+
{{#useSealed}}{{#permits.0}}sealed {{/permits.0}}{{^permits.0}}{{^vendorExtensions.x-is-one-of-interface}}{{^vendorExtensions.x-jaxrs-record}}final {{/vendorExtensions.x-jaxrs-record}}{{/vendorExtensions.x-is-one-of-interface}}{{/permits.0}}{{/useSealed}}

modules/openapi-generator/src/test/java/org/openapitools/codegen/java/jaxrs/JavaJAXRSSpecServerCodegenTest.java

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1532,6 +1532,63 @@ public void testWithoutUseSealedOutputIsUnchanged() throws Exception {
15321532
assertFileContains(Paths.get(outputDir + "/pom.xml"), "<java.version>1.8</java.version>");
15331533
}
15341534

1535+
/**
1536+
* With {@code useRecords=true} the concrete subtypes of a generated oneOf interface render as Java
1537+
* records rather than mutable classes. Because a record component {@code petType} exposes the
1538+
* canonical accessor {@code petType()} (not {@code getPetType()}), the interface declares the
1539+
* discriminator accessor in record style so the records satisfy it without bridge methods. The
1540+
* record components carry the {@code @JsonProperty} annotations. Combined here with
1541+
* {@code useSealed=true} so the interface is also sealed and permits the record subtypes.
1542+
*/
1543+
@Test
1544+
public void testOneOfRecordImplementationGeneration() throws Exception {
1545+
File output = Files.createTempDirectory("test").toFile().getCanonicalFile();
1546+
output.deleteOnExit();
1547+
1548+
Map<String, Object> properties = new HashMap<>();
1549+
properties.put("useOneOfInterfaces", "true");
1550+
properties.put("useSealed", "true");
1551+
properties.put("useRecords", "true");
1552+
1553+
final CodegenConfigurator configurator = new CodegenConfigurator()
1554+
.setGeneratorName("jaxrs-spec")
1555+
.setAdditionalProperties(properties)
1556+
.setInputSpec("src/test/resources/3_0/jaxrs-spec/oneof_interface.yaml")
1557+
.setOutputDir(output.getAbsolutePath().replace("\\", "/"));
1558+
1559+
DefaultGenerator generator = new DefaultGenerator();
1560+
generator.opts(configurator.toClientOptInput()).generate();
1561+
1562+
// assertFileContains is used rather than JavaFileAssert because the latter parses the source
1563+
// with a JavaParser language level that predates sealed/record types.
1564+
String modelDir = output.getAbsolutePath().replace("\\", "/") + "/src/gen/java/org/openapitools/model/";
1565+
1566+
// The interface is sealed, permits the record subtypes, and declares the discriminator accessor
1567+
// in record style (petType(), not getPetType()) so the records implement it via their canonical
1568+
// accessors.
1569+
assertFileContains(Paths.get(modelDir + "PetRequest.java"),
1570+
"public sealed interface PetRequest",
1571+
"permits CatRequest, DogRequest",
1572+
"PetType petType();");
1573+
assertFileNotContains(Paths.get(modelDir + "PetRequest.java"), "getPetType");
1574+
1575+
// The concrete subtypes are records implementing (not extending) the interface, with @JsonProperty
1576+
// on the components and no JavaBean getters or final-class declaration.
1577+
assertFileContains(Paths.get(modelDir + "CatRequest.java"),
1578+
"public record CatRequest(",
1579+
"@JsonProperty(required = true, value = \"petType\")",
1580+
"PetType petType",
1581+
"implements PetRequest");
1582+
assertFileNotContains(Paths.get(modelDir + "CatRequest.java"),
1583+
"class CatRequest", "extends PetRequest", "public PetType getPetType()");
1584+
1585+
assertFileContains(Paths.get(modelDir + "DogRequest.java"),
1586+
"public record DogRequest(",
1587+
"implements PetRequest");
1588+
assertFileNotContains(Paths.get(modelDir + "DogRequest.java"),
1589+
"class DogRequest", "extends PetRequest");
1590+
}
1591+
15351592
@Test
15361593
public void testGenerateJsonNullableListFieldsHelperMethodReferences_issue23251() throws Exception {
15371594
Map<String, Object> properties = new HashMap<>();
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# OpenAPI Generator Ignore
2+
# Generated by openapi-generator https://github.com/openapitools/openapi-generator
3+
4+
# Use this file to prevent files from being overwritten by the generator.
5+
# The patterns follow closely to .gitignore or .dockerignore.
6+
7+
# As an example, the C# client generator defines ApiClient.cs.
8+
# You can make changes and tell OpenAPI Generator to ignore just this file by uncommenting the following line:
9+
#ApiClient.cs
10+
11+
# You can match any string of characters against a directory, file or extension with a single asterisk (*):
12+
#foo/*/qux
13+
# The above matches foo/bar/qux and foo/baz/qux, but not foo/bar/baz/qux
14+
15+
# You can recursively match patterns against a directory, file or extension with a double asterisk (**):
16+
#foo/**/qux
17+
# This matches foo/bar/qux, foo/baz/qux, and foo/bar/baz/qux
18+
19+
# You can also negate patterns with an exclamation (!).
20+
# For example, you can ignore all files in a docs folder with the file extension .md:
21+
#docs/*.md
22+
# Then explicitly reverse the ignore rule for a single file:
23+
#!docs/README.md

0 commit comments

Comments
 (0)