Open lpandzic opened 2 years ago
JEP 39 is defined here: https://openjdk.java.net/jeps/395
It could even be a sensible default when using the Spring generator with useSpringBoot3
, as that requires Java 17.
The model cannot be an enum or extend another class. Am I missing any other limitations?
It would be amazing if we got this improvement - specially when we set useSpringBoot3
One question though is: will the records DTOs cope well with swagger annotations?
Plus one on this
Plus one
Please don't write "plus one", it clutters the issue and generates notifications for subscribers. You can "thumbs up" the initial description.
I would really like to help implementing this feature, but I am overwhelmed after looking into the pojo mustache template. There seem to be so many special cases to consider (Jackson nullables for example).
So, this issue caught my attention a few months ago. And recently I started working on setting up a new project with OpenAPI generator and decided to give generating records as models a try.
I'm excited to share that this endeavor has been largely successful, and our project now leverages the immutable nature of records for its DTOs.
I'm here to share my findings, solutions, and templates with you. It's important to note, however, that while this approach has worked well for us, it might not be universally suitable, particularly due to some inherent limitations of records. In my experience, this strategy shows the most promise for simpler APIs, where the benefits of records truly shine.
Before diving into the solutions and templates, let's first unpack the limitations of records. Understanding these constraints is crucial for evaluating whether records are the right fit for your API development needs
OpenAPI spec allows inheritance in the API and so does the default template used by openapi generator for spring for example. Since records are technically a very special type of class that is final and immutable, it also extends the java. lang. Record
class. Due to Java's nature of "single inheritance" (it can extend only one class), creating inheritance models with records can not be achieved.
I believe there is no good workaround for that in records. Of course, records can still implement interfaces and we could do some logical coupling using interfaces but it is not really the same as using inheritance. Additionally, from what I tested out SpringDoc doesn't really work well with picking up interface getter methods as fields of models, so as a result your API will end up having a model that looks like '{}'
in SwaggerUI and I don't think this is what you would want.
Another possible workaround is using composition. This workaround is also not ideal since you will have some sort of nested parent object in your model and JSON as well.
So, if inheritance in your API is what you need, I'm afraid records are not the way to go and there is no workaround. Use classes, be it a default template provided by the generator out of the box or some other one that supports inheritance generation.
OpenAPI spec has a default:
keyword which poses certain implementation challenges in records. It's not as straightforward as in classes and requires a certain workaround with a constructor. An example of that could be something like this:
public record RecordWithDefault(
String withDefaultValue,
String normalRecordField
) {
@Builder
public RecordWithDefault(String withDefaultValue, String normalRecordField) {
this.withDefaultValue = Objects.requireNonNullElse(withDefaultValue, "default");
this.normalRecordField = normalRecordField;
}
}
This would technically work and although perhaps a bit messy, some could consider it still a good solution due to records advantage of immutability over classes.
But, remember that records are meant to be simple and immutable data carriers. It is kind of the core philosophy behind them. Of course, we can do stuff like this. But, perhaps at that point, you could be better off using just simple classes if default values are what you need in your API. A class solution like this looks much cleaner and more intuitive:
@Builder
@Data
public class ClassWithDefault {
@Builder.Default
private String withDefaultValue = "default";
private String normalRecordField;
}
Generally, it all boils down to whether you want to sacrifice the simplicity of records and pollute them with this "default through constructor" approach but retain immutability, or go for a simpler class approach, although it potentially being more problematic due to its mutable nature
So far, due to the complexity of this workaround, I have not gotten to implement it in my template file. Although it seems like it is technically possible I will leave it as "an idea for future improvement" type of thing.
As mentioned by @grubeninspekteur, is indeed a tough case to consider.
Before we dive deeper into implementation details though, I feel like it is very important to mention and to fully understand how JsonNullable works. The underlying idea behind JsonNullable is that unlike Optional
or just null
it can represent 3 states:
JsonNullable
wraps a non-null value. It represents a field that is explicitly set with a specific value. For instance, if you have a JSON field age and it is set to 30, JsonNullable
would wrap this value as JsonNullable.of(30).
JsonNullable<String> jsonValue = JsonNullable.of("Hello");
{ "jsonValue": "Hello" }
JsonNullable
explicitly wraps a null
value. This is different from a field not being present at all; it's a clear indication that the field is set but its value is null. This is particularly important in APIs where setting a field to null
might have specific implications, such as clearing a value.JsonNullable<String> jsonValue = JsonNullable.of(null);
In JSON: { "jsonValue": null }
JsonNullable<String> jsonValue = JsonNullable.undefined();
{} (the field jsonValue does not appear in the JSON)
Here's a snipped from the documentation describing that:
The JsonNullable wrapper shall be used to wrap Java bean fields for which it is important to distinguish between an explicit "null" and the field not being present. A typical usage is when implementing Json Merge Patch where an explicit "null"has the meaning "set this field to null / remove this field" whereas a non-present field has the meaning "don't change the value of this field".
The default generation template approaches this 3-state scenario in a smart way. If the field is said to be nullable:
in the OpenAPI spec, here's what get's generated:
Field:
private JsonNullable<String> nullableField = JsonNullable.<String>undefined();
Fluent setter:
public VisibilityObject nullableField(String nullableField) {
this.nullableField = JsonNullable.of(nullableField);
return this;
}
Getter:
@Schema(name = "nullableField", requiredMode = Schema.RequiredMode.NOT_REQUIRED)
@JsonProperty("nullableField")
public JsonNullable<String> getNullableField() {
return nullableField;
}
This is a smart way to stick to this 3-way logic because:
isPresent
to false
and the underlying object is null
. This means that the field doesn't even really exist and can be omitted from JSONisPresent
set to true
. But the underlying object will be null
. String
object will contain text supplied and isPresent
will be set to true
. JsonNullable
in records is impossible..In records, it's impossible to achieve the three distinct states of JsonNullable effectively. This limitation arises because you can't set a field's default value in a record to JsonNullable.undefined() directly. Moreover, using constructors to create default values typically involves null checks, which doesn't allow for distinguishing between JsonNullable being explicitly set to null, having a non-null value, or being undefined.
If we try something like this:
public record RecordWithJsonNullable(
String normalValue,
JsonNullable<String> jsonNullableValue
) {
@Builder
public RecordWithJsonNullable(String normalRecordField, String JsonNullableValue) {
this(
normalRecordField,
Objects.requireNonNullElse(JsonNullable.of(JsonNullableValue), JsonNullable.undefined())
);
}
}
Or this:
public record RecordWithJsonNullable(
String normalValue,
JsonNullable<String> jsonNullableValue
) {
@Builder
public RecordWithJsonNullable(String normalRecordField, String JsonNullableValue) {
this(
normalRecordField,
JsonNullable.of(JsonNullableValue)
);
}
}
We are sacrificing one of the states with such construction trickery. In the first case, ourjsonNullableValue
can never be .of(null)
and in the second case it can never be undefined()
.
So, if you want to be using JsonNullable
you are better off using the default template or some other templates that are based on classes.
At this point after reading through all of the limitations you might be wondering: "Why even try to use records at this point?"
Well, I want you to think about a few things in regard to your API:
JsonNullable
? While the first two questions usually have clear answers based on your business requirements, the necessity of JsonNullable might not be as intuitive. As previously mentioned, JsonNullable
is crucial for JSON Merge Patch to differentiate between three states. However, consider if JSON Merge Patch is necessary for your API. Alternative RESTful solutions for updating a resource include:
If any of these alternatives align with your API's requirements, you might not need JsonNullable
and could consider using records as DTOs, leveraging their immutability and reduced boilerplate for simpler API structures."
In order to generate models/DTOs as records we will have to place 2 custom template files in our /resources/templates
folder:
pojo.mustache
-- this is an actual model generation template. In our case, it will be a template file responsible for generating records as models. /**
* {{description}}{{^description}}{{classname}}{{/description}}{{#isDeprecated}}
* @deprecated{{/isDeprecated}}
*/
{{>additionalModelTypeAnnotations}}
{{#description}}
{{#isDeprecated}}
@Deprecated
{{/isDeprecated}}
{{#swagger2AnnotationLibrary}}
@Schema({{#name}}name = "{{name}}", {{/name}}description = "{{{description}}}"{{#deprecated}}, deprecated = true{{/deprecated}})
{{/swagger2AnnotationLibrary}}
{{/description}}
{{#discriminator}}
{{>typeInfoAnnotation}}
{{/discriminator}}
{{#jackson}}
{{#isClassnameSanitized}}
{{^hasDiscriminatorWithNonEmptyMapping}}
@JsonTypeName("{{name}}")
{{/hasDiscriminatorWithNonEmptyMapping}}
{{/isClassnameSanitized}}
{{/jackson}}
{{>generatedAnnotation}}
{{#vendorExtensions.x-class-extra-annotation}}
{{{vendorExtensions.x-class-extra-annotation}}}
{{/vendorExtensions.x-class-extra-annotation}}
@Builder(toBuilder = true)
public record {{classname}} (
{{#vars}}
{{#isNullable}}
@Nullable()
{{/isNullable}}
{{#useBeanValidation}}
{{>beanValidation}}
{{/useBeanValidation}}
{{^useBeanValidation}}
{{#required}}@NotNull{{/required}}
{{/useBeanValidation}}
{{#deprecated}}
@Deprecated
{{/deprecated}}
{{#swagger2AnnotationLibrary}}
@Schema(name = "{{{baseName}}}"{{#isReadOnly}}, accessMode = Schema.AccessMode.READ_ONLY{{/isReadOnly}}{{#example}}, example = "{{{.}}}"{{/example}}{{#description}}, description = "{{{.}}}"{{/description}}{{#deprecated}}, deprecated = true{{/deprecated}}, requiredMode = {{#required}}Schema.RequiredMode.REQUIRED{{/required}}{{^required}}Schema.RequiredMode.NOT_REQUIRED{{/required}})
{{/swagger2AnnotationLibrary}}
{{#jackson}}
@JsonProperty("{{baseName}}")
{{/jackson}}
{{#isDate}}
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE)
{{/isDate}}
{{#isDateTime}}
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME)
{{/isDateTime}}
{{#vendorExtensions.x-extra-annotation}}
{{{vendorExtensions.x-extra-annotation}}}
{{/vendorExtensions.x-extra-annotation}}
{{{datatypeWithEnum}}} {{name}}{{#-last}}{{/-last}}{{^-last}},{{/-last}}
{{/vars}}
) {
}
You can also find it here, in my little demo GitHub repo I made
model.mustahce
primarily includes imports and references pojo.mustache
. In our case, this removes a few imports from the standard model template file and adds Lombok's @Builder
annotation for the sake of convenience. package {{package}};
import lombok.Builder;
{{#imports}}import {{import}};
{{/imports}}
{{#openApiNullable}}
import jakarta.annotation.Nullable;
{{/openApiNullable}}
{{#useBeanValidation}}
import {{javaxPackage}}.validation.Valid;
import {{javaxPackage}}.validation.constraints.*;
{{/useBeanValidation}}
{{^useBeanValidation}}
import {{javaxPackage}}.validation.constraints.NotNull;
{{/useBeanValidation}}
{{#performBeanValidation}}
import org.hibernate.validator.constraints.*;
{{/performBeanValidation}}
{{#jackson}}
{{/jackson}}
{{#swagger2AnnotationLibrary}}
import io.swagger.v3.oas.annotations.media.Schema;
{{/swagger2AnnotationLibrary}}
{{^parent}}
{{#hateoas}}
import org.springframework.hateoas.RepresentationModel;
{{/hateoas}}
{{/parent}}
import java.util.*;
import {{javaxPackage}}.annotation.Generated;
{{#models}}
{{#model}}
{{#additionalPropertiesType}}
import java.util.Map;
import java.util.HashMap;
import com.fasterxml.jackson.annotation.JsonAnyGetter;
import com.fasterxml.jackson.annotation.JsonAnySetter;
{{/additionalPropertiesType}}
{{#isEnum}}
{{>enumOuterClass}}
{{/isEnum}}
{{^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}}
{{/model}}
{{/models}}
You can find this one here:
Functionally, there are no issues I have discovered yet, there are however a few aesthetic/formatting issues:
pojo.mustache
template. It has to do purely with formatting. I can not figure out how to consistently have a single line break after each row. So far the generated records can have 0-2 rows in between fields which is a bit annoying and aesthetically unpleasing. If you know where the corresponding issue in the template is, please let me know. @Pattern
or @Email
or @Size
or whatever are all generated into one line. Would like to have them generated each on a new line. Those little issues I will try to get to someday later.
Make sure to include <templateDirectory>src/main/resources/templates</templateDirectory>
and you're pretty much good to go
If you want to see a more detailed configuration of the plugin, feel free to look at the pom.xml
file in my repository.
To sum up, while records are not a one-size-fits-all solution for every API and use case, they can be quite useful, particularly for certain scenarios and simpler APIs, thanks to their immutable nature. This was the motivation behind my work on these templates. As always, I encourage you to consider your specific needs, feel free to experiment, and don't hesitate to reach out with any questions about the templates I've created.
If you think I've overlooked certain limitations or made an error in this discussion, please let me know—let's make this a collaborative effort.
For future improvements, I'm considering exploring the implementation of default values through constructors, as it seems technically feasible, as well as trying to figure out line formatting issues. Your input or interest in this feature would be greatly appreciated, so do let me know if this is something you'd find useful.
I apologize for the length of this comment, but I hope it proves helpful in your decision-making process regarding the use of records in API development. Thank you for reading, and I look forward to your thoughts and contributions!
Another reason that Java Records would not fully support json schema object payloads is that they do not support properties where invalid java names are used. If one built the model by implementing the Map interface, those invalidly named keys would be preserved and one could write getters for them. That's what I do in my Java implementation.
This is a matter of overriding .mustache
template files to generate Java records instead of Java classes.
Here are some templates which generates Java records (which does not use Lombok): openapi-to-java-records-mustache-templates. Results may vary, as it generates the bare minimum for a valid Java record.
Option to generate java models as records.