OpenAPITools / openapi-generator

OpenAPI Generator allows generation of API client libraries (SDK generation), server stubs, documentation and configuration automatically given an OpenAPI Spec (v2, v3)
https://openapi-generator.tech
Apache License 2.0
21.28k stars 6.44k forks source link

[REQ] Java Records (JEP 395) support #10490

Open lpandzic opened 2 years ago

lpandzic commented 2 years ago

Option to generate java models as records.

spacether commented 2 years ago

JEP 39 is defined here: https://openjdk.java.net/jeps/395

hgjd commented 1 year ago

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?

fabiofranco85 commented 1 year ago

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?

Kinae commented 1 year ago

Plus one on this

Aryesia commented 1 year ago

Plus one

grubeninspekteur commented 1 year ago

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).

GlobeDaBoarder commented 8 months ago

Intro

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

Limitations:

1. Records do not allow inheritance:

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.

2. Records do not allow default values (there is a workaround though):

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.

3. JsonNullable (jackson-databind-nullable) wrapper:

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:

1) Defined with a Non-Null Value:

2) Defined with a Null Value:

3) Undefined:

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:

From what I understand, achieving the 3-states of 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.

To conclude the limitations of using records as models for OpenAPI Springn generator:

Why Records could still be a reasonable approach for some APIs

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:

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."

How to generate records:

1. template files:

In order to generate models/DTOs as records we will have to place 2 custom template files in our /resources/templates folder:

/**
 * {{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

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:

2. Known issues with the template (as of post date):

Functionally, there are no issues I have discovered yet, there are however a few aesthetic/formatting issues:

Those little issues I will try to get to someday later.

3. Plugin configuration:**

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.

That's it!

Conclusions

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!

spacether commented 8 months ago

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.

Chrimle commented 4 days ago

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.