Skip to content

Commit fbb99d2

Browse files
authored
Add support for Optional<T> in Dart generator (both dart and dart-dio) to distinguish absent, null, and present states (#22257)
* Add support for `Optional<T>` in Dart generator (both dart and dart-dio) to distinguish absent, null, and present states * Add useOptional and patchOnly options to the Dart client configurations thing for testing (setting both options to "true" for both types) * Add documentation for useOptional and patchOnly options * Tune the dart mustache (pluss class mustache) to get rid of the extra whitespace * More tuning of the dart mustache files to adjust amount of whitespace - match previously generated setup * Tune dart mustache templates to fix whitespace stuff by tips from wing328 * Fix the logic gap where useOptional=true without patchOnly=true appears to do nothing * Rename getString() to extractModelNameFromBodyParam() * Add behavioral tests * useOptional flag wrapping non-required properties * patchOnly mode PATCH schema detection * patchOnly=true auto-enabling useOptional * Parameter unwrapping behavior * Fix inconsistency (my own) in native_class.mustache * Remove "dead code" (because of timing). postProcessModels has already executed before postProcessOperationsWithModels. And then we don't even need the extractModelNameFromBodyParam method... * Fix Optional<T> to properly distinguish between absend and null Had issues in dio * Regenerate Dart samples * Fix extra blank lines in dart-dio json_serializable template output
1 parent 4bf4637 commit fbb99d2

19 files changed

Lines changed: 657 additions & 18 deletions

File tree

docs/generators/dart-dio.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ These options may be applied as additional-properties (cli) or configOptions (pl
2626
|equalityCheckMethod|Specify equality check method. Takes effect only in case if serializationLibrary is json_serializable.|<dl><dt>**default**</dt><dd>[DEFAULT] Built in hash code generation method</dd><dt>**equatable**</dt><dd>Uses equatable library for equality checking</dd></dl>|default|
2727
|finalProperties|Whether properties are marked as final when using Json Serializable for serialization| |true|
2828
|legacyDiscriminatorBehavior|Set to false for generators with better support for discriminators. (Python, Java, Go, PowerShell, C# have this enabled by default).|<dl><dt>**true**</dt><dd>The mapping in the discriminator includes descendent schemas that allOf inherit from self and the discriminator mapping schemas in the OAS document.</dd><dt>**false**</dt><dd>The mapping in the discriminator includes any descendent schemas that allOf inherit from self, any oneOf schemas, any anyOf schemas, any x-discriminator-values, and the discriminator mapping schemas in the OAS document AND Codegen validates that oneOf and anyOf schemas contain the required discriminator and throws an error if the discriminator is missing.</dd></dl>|true|
29+
|patchOnly|Only apply Optional&lt;T&gt; to PATCH operation request bodies (requires useOptional=true)| |false|
2930
|prependFormOrBodyParameters|Add form or body parameters to the beginning of the parameter list.| |false|
3031
|pubAuthor|Author name in generated pubspec| |Author|
3132
|pubAuthorEmail|Email address of the author in generated pubspec| |author@homepage|
@@ -42,6 +43,7 @@ These options may be applied as additional-properties (cli) or configOptions (pl
4243
|sortParamsByRequiredFlag|Sort method arguments to place required parameters before optional parameters.| |true|
4344
|sourceFolder|source folder for generated code| |src|
4445
|useEnumExtension|Allow the 'x-enum-values' extension for enums| |false|
46+
|useOptional|Use Optional&lt;T&gt; to distinguish absent, null, and present for optional fields (Dart 3+)| |false|
4547

4648
## IMPORT MAPPING
4749

docs/generators/dart.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ These options may be applied as additional-properties (cli) or configOptions (pl
2323
|ensureUniqueParams|Whether to ensure parameter names are unique in an operation (rename parameters that are not).| |true|
2424
|enumUnknownDefaultCase|If the server adds new enum cases, that are unknown by an old spec/client, the client will fail to parse the network response.With this option enabled, each enum will have a new case, 'unknown_default_open_api', so that when the server sends an enum case that is not known by the client/spec, they can safely fallback to this case.|<dl><dt>**false**</dt><dd>No changes to the enum's are made, this is the default option.</dd><dt>**true**</dt><dd>With this option enabled, each enum will have a new case, 'unknown_default_open_api', so that when the enum case sent by the server is not known by the client/spec, can safely be decoded to this case.</dd></dl>|false|
2525
|legacyDiscriminatorBehavior|Set to false for generators with better support for discriminators. (Python, Java, Go, PowerShell, C# have this enabled by default).|<dl><dt>**true**</dt><dd>The mapping in the discriminator includes descendent schemas that allOf inherit from self and the discriminator mapping schemas in the OAS document.</dd><dt>**false**</dt><dd>The mapping in the discriminator includes any descendent schemas that allOf inherit from self, any oneOf schemas, any anyOf schemas, any x-discriminator-values, and the discriminator mapping schemas in the OAS document AND Codegen validates that oneOf and anyOf schemas contain the required discriminator and throws an error if the discriminator is missing.</dd></dl>|true|
26+
|patchOnly|Only apply Optional&lt;T&gt; to PATCH operation request bodies (requires useOptional=true)| |false|
2627
|prependFormOrBodyParameters|Add form or body parameters to the beginning of the parameter list.| |false|
2728
|pubAuthor|Author name in generated pubspec| |Author|
2829
|pubAuthorEmail|Email address of the author in generated pubspec| |author@homepage|
@@ -38,6 +39,7 @@ These options may be applied as additional-properties (cli) or configOptions (pl
3839
|sortParamsByRequiredFlag|Sort method arguments to place required parameters before optional parameters.| |true|
3940
|sourceFolder|source folder for generated code| |src|
4041
|useEnumExtension|Allow the 'x-enum-values' extension for enums| |false|
42+
|useOptional|Use Optional&lt;T&gt; to distinguish absent, null, and present for optional fields (Dart 3+)| |false|
4143

4244
## IMPORT MAPPING
4345

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

Lines changed: 131 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
package org.openapitools.codegen.languages;
22

33
import com.google.common.collect.Sets;
4+
import io.swagger.v3.oas.models.OpenAPI;
45
import io.swagger.v3.oas.models.Operation;
56
import io.swagger.v3.oas.models.media.Schema;
67
import io.swagger.v3.oas.models.media.StringSchema;
8+
import io.swagger.v3.oas.models.parameters.Parameter;
9+
import io.swagger.v3.oas.models.parameters.RequestBody;
710
import io.swagger.v3.oas.models.servers.Server;
811
import lombok.Setter;
912
import org.apache.commons.io.FilenameUtils;
@@ -45,6 +48,8 @@ public abstract class AbstractDartCodegen extends DefaultCodegen {
4548
public static final String PUB_REPOSITORY = "pubRepository";
4649
public static final String PUB_PUBLISH_TO = "pubPublishTo";
4750
public static final String USE_ENUM_EXTENSION = "useEnumExtension";
51+
public static final String USE_OPTIONAL = "useOptional";
52+
public static final String PATCH_ONLY = "patchOnly";
4853

4954
@Setter protected String pubLibrary = "openapi.api";
5055
@Setter protected String pubName = "openapi";
@@ -56,8 +61,12 @@ public abstract class AbstractDartCodegen extends DefaultCodegen {
5661
@Setter protected String pubRepository = null;
5762
@Setter protected String pubPublishTo = null;
5863
@Setter protected boolean useEnumExtension = false;
64+
@Setter protected boolean useOptional = false;
65+
@Setter protected boolean patchOnly = false;
5966
@Setter protected String sourceFolder = "src";
6067
protected String libPath = "lib" + File.separator;
68+
69+
protected Set<String> patchRequestSchemas = new HashSet<>();
6170
protected String apiDocPath = "doc/";
6271
protected String modelDocPath = "doc/";
6372
protected String apiTestPath = "test" + File.separator;
@@ -196,6 +205,8 @@ public AbstractDartCodegen() {
196205
addOption(PUB_REPOSITORY, "Repository in generated pubspec", pubRepository);
197206
addOption(PUB_PUBLISH_TO, "Publish_to in generated pubspec", pubPublishTo);
198207
addOption(USE_ENUM_EXTENSION, "Allow the 'x-enum-values' extension for enums", String.valueOf(useEnumExtension));
208+
addOption(USE_OPTIONAL, "Use Optional<T> to distinguish absent, null, and present for optional fields (Dart 3+)", String.valueOf(useOptional));
209+
addOption(PATCH_ONLY, "Only apply Optional<T> to PATCH operation request bodies (requires useOptional=true)", String.valueOf(patchOnly));
199210
addOption(CodegenConstants.SOURCE_FOLDER, CodegenConstants.SOURCE_FOLDER_DESC, sourceFolder);
200211
}
201212

@@ -302,6 +313,24 @@ public void processOpts() {
302313
additionalProperties.put(USE_ENUM_EXTENSION, useEnumExtension);
303314
}
304315

316+
if (additionalProperties.containsKey(USE_OPTIONAL)) {
317+
this.setUseOptional(convertPropertyToBooleanAndWriteBack(USE_OPTIONAL));
318+
} else {
319+
additionalProperties.put(USE_OPTIONAL, useOptional);
320+
}
321+
322+
if (additionalProperties.containsKey(PATCH_ONLY)) {
323+
this.setPatchOnly(convertPropertyToBooleanAndWriteBack(PATCH_ONLY));
324+
} else {
325+
additionalProperties.put(PATCH_ONLY, patchOnly);
326+
}
327+
328+
if (patchOnly && !useOptional) {
329+
LOGGER.warn("patchOnly=true requires useOptional=true. Setting useOptional=true.");
330+
this.setUseOptional(true);
331+
additionalProperties.put(USE_OPTIONAL, true);
332+
}
333+
305334
if (additionalProperties.containsKey(CodegenConstants.SOURCE_FOLDER)) {
306335
String srcFolder = (String) additionalProperties.get(CodegenConstants.SOURCE_FOLDER);
307336
this.setSourceFolder(srcFolder.replace('/', File.separatorChar));
@@ -545,6 +574,35 @@ public String getTypeDeclaration(Schema p) {
545574
return super.getTypeDeclaration(p);
546575
}
547576

577+
@Override
578+
public void preprocessOpenAPI(OpenAPI openAPI) {
579+
super.preprocessOpenAPI(openAPI);
580+
581+
if (patchOnly && openAPI.getPaths() != null) {
582+
openAPI.getPaths().forEach((path, pathItem) -> {
583+
if (pathItem.getPatch() != null) {
584+
Operation patchOp = pathItem.getPatch();
585+
if (patchOp.getRequestBody() != null) {
586+
RequestBody requestBody = ModelUtils.getReferencedRequestBody(openAPI, patchOp.getRequestBody());
587+
if (requestBody != null && requestBody.getContent() != null) {
588+
requestBody.getContent().forEach((mediaType, content) -> {
589+
if (content.getSchema() != null) {
590+
String ref = content.getSchema().get$ref();
591+
if (ref != null) {
592+
String schemaName = ModelUtils.getSimpleRef(ref);
593+
String modelName = toModelName(schemaName);
594+
patchRequestSchemas.add(modelName);
595+
LOGGER.info("Identified '{}' as PATCH request schema (will use Optional<T>)", modelName);
596+
}
597+
}
598+
});
599+
}
600+
}
601+
}
602+
});
603+
}
604+
}
605+
548606
@Override
549607
public String getSchemaType(Schema p) {
550608
String openAPIType = super.getSchemaType(p);
@@ -559,7 +617,49 @@ public String getSchemaType(Schema p) {
559617

560618
@Override
561619
public ModelsMap postProcessModels(ModelsMap objs) {
562-
return postProcessModelsEnum(objs);
620+
objs = postProcessModelsEnum(objs);
621+
622+
if (useOptional) {
623+
for (ModelMap modelMap : objs.getModels()) {
624+
CodegenModel model = modelMap.getModel();
625+
626+
boolean shouldUseOptional;
627+
628+
if (patchOnly) {
629+
shouldUseOptional = patchRequestSchemas.contains(model.classname);
630+
} else {
631+
Boolean schemaUseOptional = (Boolean) model.vendorExtensions.get("x-use-optional");
632+
shouldUseOptional = schemaUseOptional == null || schemaUseOptional;
633+
}
634+
635+
if (shouldUseOptional) {
636+
for (CodegenProperty prop : model.vars) {
637+
if (!prop.required && !prop.dataType.startsWith("Optional<")) {
638+
wrapPropertyWithOptional(prop);
639+
}
640+
}
641+
}
642+
}
643+
}
644+
645+
return objs;
646+
}
647+
648+
private void wrapPropertyWithOptional(CodegenProperty property) {
649+
property.vendorExtensions.put("x-unwrapped-datatype", property.dataType);
650+
property.vendorExtensions.put("x-is-optional", true);
651+
property.vendorExtensions.put("x-original-is-number", property.isNumber);
652+
property.vendorExtensions.put("x-original-is-integer", property.isInteger);
653+
654+
boolean hasNullableSuffix = property.dataType.endsWith("?");
655+
String baseType = hasNullableSuffix ? property.dataType.substring(0, property.dataType.length() - 1) : property.dataType;
656+
property.dataType = "Optional<" + baseType + "?" + ">";
657+
658+
if (property.datatypeWithEnum != null && !property.datatypeWithEnum.startsWith("Optional<")) {
659+
hasNullableSuffix = property.datatypeWithEnum.endsWith("?");
660+
baseType = hasNullableSuffix ? property.datatypeWithEnum.substring(0, property.datatypeWithEnum.length() - 1) : property.datatypeWithEnum;
661+
property.datatypeWithEnum = "Optional<" + baseType + "?" + ">";
662+
}
563663
}
564664

565665
@Override
@@ -624,6 +724,19 @@ public CodegenProperty fromProperty(String name, Schema p, boolean required) {
624724
return property;
625725
}
626726

727+
@Override
728+
public CodegenParameter fromParameter(Parameter parameter, Set<String> imports) {
729+
final CodegenParameter param = super.fromParameter(parameter, imports);
730+
731+
if (useOptional && param.dataType != null && param.dataType.startsWith("Optional<")) {
732+
param.dataType = param.dataType.substring("Optional<".length(), param.dataType.length() - 1);
733+
param.vendorExtensions.remove("x-is-optional");
734+
param.vendorExtensions.remove("x-unwrapped-datatype");
735+
}
736+
737+
return param;
738+
}
739+
627740
@Override
628741
public CodegenOperation fromOperation(String path, String httpMethod, Operation operation, List<Server> servers) {
629742
final CodegenOperation op = super.fromOperation(path, httpMethod, operation, servers);
@@ -660,6 +773,13 @@ public OperationsMap postProcessOperationsWithModels(OperationsMap objs, List<Mo
660773
if (operations != null) {
661774
List<CodegenOperation> ops = operations.getOperation();
662775
for (CodegenOperation op : ops) {
776+
if (useOptional) {
777+
unwrapOptionalFromParameters(op.pathParams);
778+
unwrapOptionalFromParameters(op.queryParams);
779+
unwrapOptionalFromParameters(op.headerParams);
780+
unwrapOptionalFromParameters(op.formParams);
781+
}
782+
663783
if (op.hasConsumes) {
664784
if (!op.formParams.isEmpty() || op.isMultipart) {
665785
// DefaultCodegen only sets this if the first consumes mediaType
@@ -681,6 +801,16 @@ public OperationsMap postProcessOperationsWithModels(OperationsMap objs, List<Mo
681801
return objs;
682802
}
683803

804+
private void unwrapOptionalFromParameters(List<CodegenParameter> params) {
805+
if (params == null) return;
806+
for (CodegenParameter param : params) {
807+
if (param.dataType != null && param.dataType.startsWith("Optional<")) {
808+
param.dataType = param.dataType.substring("Optional<".length(), param.dataType.length() - 1);
809+
param.vendorExtensions.remove("x-is-optional");
810+
}
811+
}
812+
}
813+
684814
private List<Map<String, String>> prioritizeContentTypes(List<Map<String, String>> consumes) {
685815
if (consumes.size() <= 1) {
686816
// no need to change any order

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,11 @@ public void processOpts() {
7474
supportingFiles.add(new SupportingFile("auth/http_bearer_auth.mustache", authFolder, "http_bearer_auth.dart"));
7575
supportingFiles.add(new SupportingFile("auth/api_key_auth.mustache", authFolder, "api_key_auth.dart"));
7676
supportingFiles.add(new SupportingFile("auth/oauth.mustache", authFolder, "oauth.dart"));
77+
78+
if (useOptional) {
79+
supportingFiles.add(new SupportingFile("optional.mustache", libPath, "optional.dart"));
80+
}
81+
7782
supportingFiles.add(new SupportingFile("git_push.sh.mustache", "", "git_push.sh"));
7883
supportingFiles.add(new SupportingFile("gitignore.mustache", "", ".gitignore"));
7984
supportingFiles.add(new SupportingFile("README.mustache", "", "README.md"));

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,10 @@ public void processOpts() {
220220
supportingFiles.add(new SupportingFile("auth/oauth.mustache", authFolder, "oauth.dart"));
221221
supportingFiles.add(new SupportingFile("auth/auth.mustache", authFolder, "auth.dart"));
222222

223+
if (useOptional) {
224+
supportingFiles.add(new SupportingFile("optional.mustache", srcFolder, "optional.dart"));
225+
}
226+
223227
configureSerializationLibrary(srcFolder);
224228
configureEqualityCheckMethod(srcFolder);
225229
configureDateLibrary(srcFolder);
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
{{>header}}
2+
import 'package:json_annotation/json_annotation.dart';
3+
4+
/// Represents an optional value that can be either absent or present.
5+
///
6+
/// This is used to distinguish between three states in PATCH operations:
7+
/// - Absent: Field is not set (omitted from JSON)
8+
/// - Present with null: Field is explicitly set to null
9+
/// - Present with value: Field has a value
10+
///
11+
/// Example usage:
12+
/// ```dart
13+
/// // Field absent - not sent in request
14+
/// final patch1 = Model();
15+
///
16+
/// // Field explicitly null - sends {"field": null}
17+
/// final patch2 = Model(field: const Optional.present(null));
18+
///
19+
/// // Field has value - sends {"field": "value"}
20+
/// final patch3 = Model(field: const Optional.present('value'));
21+
/// ```
22+
sealed class Optional<T> {
23+
const Optional();
24+
25+
/// Creates an Optional with an absent value (not set).
26+
const factory Optional.absent() = Absent<T>;
27+
28+
/// Creates an Optional with a present value (can be null).
29+
const factory Optional.present(T value) = Present<T>;
30+
31+
/// Returns true if this Optional has a value (even if that value is null).
32+
bool get isPresent;
33+
34+
/// Returns true if this Optional does not have a value.
35+
bool get isEmpty => !isPresent;
36+
37+
/// Returns the value if present, throws if absent.
38+
T get value;
39+
40+
/// Returns the value if present, otherwise returns [defaultValue].
41+
T orElse(T defaultValue);
42+
43+
/// Returns the value if present, otherwise returns the result of calling [defaultValue].
44+
T orElseGet(T Function() defaultValue);
45+
46+
/// Maps the value if present using [transform], otherwise returns an absent Optional.
47+
Optional<R> map<R>(R Function(T value) transform);
48+
}
49+
50+
/// Represents an absent Optional value.
51+
final class Absent<T> extends Optional<T> {
52+
const Absent();
53+
54+
@override
55+
bool get isPresent => false;
56+
57+
@override
58+
T get value => throw StateError('No value present');
59+
60+
@override
61+
T orElse(T defaultValue) => defaultValue;
62+
63+
@override
64+
T orElseGet(T Function() defaultValue) => defaultValue();
65+
66+
@override
67+
Optional<R> map<R>(R Function(T value) transform) => const Absent();
68+
69+
@override
70+
bool operator ==(Object other) => other is Absent<T>;
71+
72+
@override
73+
int get hashCode => 0;
74+
75+
@override
76+
String toString() => 'Optional.absent()';
77+
}
78+
79+
/// Represents a present Optional value.
80+
final class Present<T> extends Optional<T> {
81+
const Present(this._value);
82+
83+
final T _value;
84+
85+
@override
86+
bool get isPresent => true;
87+
88+
@override
89+
T get value => _value;
90+
91+
@override
92+
T orElse(T defaultValue) => _value;
93+
94+
@override
95+
T orElseGet(T Function() defaultValue) => _value;
96+
97+
@override
98+
Optional<R> map<R>(R Function(T value) transform) => Optional.present(transform(_value));
99+
100+
@override
101+
bool operator ==(Object other) =>
102+
identical(this, other) ||
103+
(other is Present<T> && _value == other._value);
104+
105+
@override
106+
int get hashCode => _value.hashCode;
107+
108+
@override
109+
String toString() => 'Optional.present($_value)';
110+
}
111+
112+
class _OptionalAbsentSentinel {
113+
const _OptionalAbsentSentinel();
114+
}
115+
116+
const _optionalAbsentSentinel = _OptionalAbsentSentinel();
117+
118+
/// Used with @JsonKey(readValue:) to distinguish absent keys from null values.
119+
Object? readOptionalValue(Map map, String key) {
120+
return map.containsKey(key) ? map[key] : _optionalAbsentSentinel;
121+
}
122+
123+
class OptionalConverter<T> implements JsonConverter<Optional<T>, Object?> {
124+
const OptionalConverter();
125+
126+
@override
127+
Optional<T> fromJson(Object? json) {
128+
return json is _OptionalAbsentSentinel
129+
? const Optional.absent()
130+
: Optional.present(json as T);
131+
}
132+
133+
@override
134+
Object? toJson(Optional<T> object) {
135+
return object.isPresent ? object.value : null;
136+
}
137+
}

0 commit comments

Comments
 (0)