Skip to content

Commit 98fdba1

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 b11b429 commit 98fdba1

23 files changed

Lines changed: 778 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

@@ -59,6 +61,7 @@ public class JavaJAXRSSpecServerCodegen extends AbstractJavaJAXRSServerCodegen {
5961
public static final String GENERATE_JSON_CREATOR = "generateJsonCreator";
6062
public static final String USE_JAKARTA_SECURITY_ANNOTATIONS = "useJakartaSecurityAnnotations";
6163
public static final String USE_SEALED = "useSealed";
64+
public static final String USE_RECORDS = "useRecords";
6265

6366
public static final String QUARKUS_LIBRARY = "quarkus";
6467
public static final String THORNTAIL_LIBRARY = "thorntail";
@@ -79,6 +82,9 @@ public class JavaJAXRSSpecServerCodegen extends AbstractJavaJAXRSServerCodegen {
7982
@Setter
8083
protected boolean useSealed = false;
8184

85+
@Setter
86+
protected boolean useRecords = false;
87+
8288
private final JakartaSecurityAnnotationProcessor jakartaSecurityAnnotationProcessor = new JakartaSecurityAnnotationProcessor();
8389

8490
@Getter @Setter
@@ -162,6 +168,7 @@ public JavaJAXRSSpecServerCodegen() {
162168
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));
163169
cliOptions.add(CliOption.newBoolean(GENERATE_JSON_CREATOR, "Whether to generate @JsonCreator constructor for required properties.", generateJsonCreator));
164170
cliOptions.add(CliOption.newBoolean(USE_SEALED, "Whether to generate sealed model interfaces and classes.", useSealed));
171+
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));
165172
}
166173

167174
@Override
@@ -205,6 +212,7 @@ public void processOpts() {
205212

206213
convertPropertyToBooleanAndWriteBack(GENERATE_JSON_CREATOR, this::setGenerateJsonCreator);
207214
convertPropertyToBooleanAndWriteBack(USE_SEALED, this::setUseSealed);
215+
convertPropertyToBooleanAndWriteBack(USE_RECORDS, this::setUseRecords);
208216

209217
if (additionalProperties.containsKey(OPEN_API_SPEC_FILE_LOCATION)) {
210218
openApiSpecFileLocation = additionalProperties.get(OPEN_API_SPEC_FILE_LOCATION).toString();
@@ -394,16 +402,20 @@ public OperationsMap postProcessOperationsWithModels(OperationsMap objs, List<Mo
394402
public Map<String, ModelsMap> postProcessAllModels(Map<String, ModelsMap> objs) {
395403
Map<String, ModelsMap> result = super.postProcessAllModels(objs);
396404

397-
// Index the discriminators of the generated oneOf interfaces by classname. A child of a oneOf
398-
// interface (useOneOfInterfaces) inherits a shared base via allOf and therefore has no Java
399-
// parentModel carrying the discriminator - the discriminator lives on the interface it implements.
405+
// Collect, for the interfaces generated by the useOneOfInterfaces feature: their classnames (to
406+
// identify which concrete models implement one of them and are therefore record candidates) and
407+
// their discriminators indexed by classname (a child of a oneOf interface inherits a shared base
408+
// via allOf and has no Java parentModel carrying the discriminator - it lives on the interface).
409+
Set<String> oneOfInterfaceNames = new HashSet<>();
400410
Map<String, CodegenDiscriminator> oneOfInterfaceDiscriminators = new HashMap<>();
401411
for (ModelsMap modelsMap : result.values()) {
402412
for (ModelMap modelMap : modelsMap.getModels()) {
403413
CodegenModel model = modelMap.getModel();
404-
if (Boolean.TRUE.equals(model.getVendorExtensions().get("x-is-one-of-interface"))
405-
&& model.getDiscriminator() != null) {
406-
oneOfInterfaceDiscriminators.put(model.classname, model.getDiscriminator());
414+
if (Boolean.TRUE.equals(model.getVendorExtensions().get("x-is-one-of-interface"))) {
415+
oneOfInterfaceNames.add(model.classname);
416+
if (model.getDiscriminator() != null) {
417+
oneOfInterfaceDiscriminators.put(model.classname, model.getDiscriminator());
418+
}
407419
}
408420
}
409421
}
@@ -424,6 +436,9 @@ public Map<String, ModelsMap> postProcessAllModels(Map<String, ModelsMap> objs)
424436
}
425437
}
426438
}
439+
if (useRecords && isOneOfInterfaceRecordCandidate(model, oneOfInterfaceNames)) {
440+
model.getVendorExtensions().put("x-jaxrs-record", true);
441+
}
427442
}
428443
}
429444
return result;
@@ -450,6 +465,32 @@ private CodegenDiscriminator discriminatorOfImplementedOneOfInterface(
450465
return null;
451466
}
452467

468+
/**
469+
* A concrete model is rendered as a record only when it implements a oneOf interface produced by the
470+
* useOneOfInterfaces feature, and it is structurally record-compatible: it has no Java parent (records
471+
* cannot extend) and no additional (undeclared) properties (those require a mutable map-backed pojo).
472+
*
473+
* @param model the concrete model that may implement a oneOf interface
474+
* @param oneOfInterfaceNames the classnames of the generated oneOf interfaces
475+
* @return {@code true} when the model should be rendered as a record
476+
*/
477+
@SuppressWarnings("unchecked")
478+
private boolean isOneOfInterfaceRecordCandidate(CodegenModel model, Set<String> oneOfInterfaceNames) {
479+
if (model.getParent() != null || model.getAdditionalPropertiesType() != null) {
480+
return false;
481+
}
482+
Object implementsExtension = model.getVendorExtensions().get("x-implements");
483+
if (!(implementsExtension instanceof Collection)) {
484+
return false;
485+
}
486+
for (Object intf : (Collection<Object>) implementsExtension) {
487+
if (oneOfInterfaceNames.contains(String.valueOf(intf))) {
488+
return true;
489+
}
490+
}
491+
return false;
492+
}
493+
453494
@Override
454495
public CodegenOperation fromOperation(String path, String httpMethod, Operation operation, List<Server> servers) {
455496
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
@@ -180,7 +180,7 @@
180180
{{/openApiNullable}}
181181
</dependencies>
182182
<properties>
183-
<java.version>{{#useSealed}}17{{/useSealed}}{{^useSealed}}1.8{{/useSealed}}</java.version>
183+
<java.version>{{#useSealed}}17{{/useSealed}}{{^useSealed}}{{#useRecords}}17{{/useRecords}}{{^useRecords}}1.8{{/useRecords}}{{/useSealed}}</java.version>
184184
<maven.compiler.source>${java.version}</maven.compiler.source>
185185
<maven.compiler.target>${java.version}</maven.compiler.target>
186186
<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
@@ -1425,6 +1425,63 @@ public void testOneOfSealedInterfaceGeneration() throws Exception {
14251425
assertFileNotContains(Paths.get(modelDir + "DogRequest.java"), "extends PetRequest");
14261426
}
14271427

1428+
/**
1429+
* With {@code useRecords=true} the concrete subtypes of a generated oneOf interface render as Java
1430+
* records rather than mutable classes. Because a record component {@code petType} exposes the
1431+
* canonical accessor {@code petType()} (not {@code getPetType()}), the interface declares the
1432+
* discriminator accessor in record style so the records satisfy it without bridge methods. The
1433+
* record components carry the {@code @JsonProperty} annotations. Combined here with
1434+
* {@code useSealed=true} so the interface is also sealed and permits the record subtypes.
1435+
*/
1436+
@Test
1437+
public void testOneOfRecordImplementationGeneration() throws Exception {
1438+
File output = Files.createTempDirectory("test").toFile().getCanonicalFile();
1439+
output.deleteOnExit();
1440+
1441+
Map<String, Object> properties = new HashMap<>();
1442+
properties.put("useOneOfInterfaces", "true");
1443+
properties.put("useSealed", "true");
1444+
properties.put("useRecords", "true");
1445+
1446+
final CodegenConfigurator configurator = new CodegenConfigurator()
1447+
.setGeneratorName("jaxrs-spec")
1448+
.setAdditionalProperties(properties)
1449+
.setInputSpec("src/test/resources/3_0/jaxrs-spec/oneof_interface.yaml")
1450+
.setOutputDir(output.getAbsolutePath().replace("\\", "/"));
1451+
1452+
DefaultGenerator generator = new DefaultGenerator();
1453+
generator.opts(configurator.toClientOptInput()).generate();
1454+
1455+
// assertFileContains is used rather than JavaFileAssert because the latter parses the source
1456+
// with a JavaParser language level that predates sealed/record types.
1457+
String modelDir = output.getAbsolutePath().replace("\\", "/") + "/src/gen/java/org/openapitools/model/";
1458+
1459+
// The interface is sealed, permits the record subtypes, and declares the discriminator accessor
1460+
// in record style (petType(), not getPetType()) so the records implement it via their canonical
1461+
// accessors.
1462+
assertFileContains(Paths.get(modelDir + "PetRequest.java"),
1463+
"public sealed interface PetRequest",
1464+
"permits CatRequest, DogRequest",
1465+
"PetType petType();");
1466+
assertFileNotContains(Paths.get(modelDir + "PetRequest.java"), "getPetType");
1467+
1468+
// The concrete subtypes are records implementing (not extending) the interface, with @JsonProperty
1469+
// on the components and no JavaBean getters or final-class declaration.
1470+
assertFileContains(Paths.get(modelDir + "CatRequest.java"),
1471+
"public record CatRequest(",
1472+
"@JsonProperty(required = true, value = \"petType\")",
1473+
"PetType petType",
1474+
"implements PetRequest");
1475+
assertFileNotContains(Paths.get(modelDir + "CatRequest.java"),
1476+
"class CatRequest", "extends PetRequest", "public PetType getPetType()");
1477+
1478+
assertFileContains(Paths.get(modelDir + "DogRequest.java"),
1479+
"public record DogRequest(",
1480+
"implements PetRequest");
1481+
assertFileNotContains(Paths.get(modelDir + "DogRequest.java"),
1482+
"class DogRequest", "extends PetRequest");
1483+
}
1484+
14281485
@Test
14291486
public void testGenerateJsonNullableListFieldsHelperMethodReferences_issue23251() throws Exception {
14301487
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)