Skip to content

Commit aa69e5a

Browse files
committed
Add support for Optional<T> in Dart generator (both dart and dart-dio) to distinguish absent, null, and present states
1 parent f9d2b8b commit aa69e5a

File tree

12 files changed

+523
-17
lines changed

12 files changed

+523
-17
lines changed

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

Lines changed: 152 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;
@@ -195,6 +204,8 @@ public AbstractDartCodegen() {
195204
addOption(PUB_REPOSITORY, "Repository in generated pubspec", pubRepository);
196205
addOption(PUB_PUBLISH_TO, "Publish_to in generated pubspec", pubPublishTo);
197206
addOption(USE_ENUM_EXTENSION, "Allow the 'x-enum-values' extension for enums", String.valueOf(useEnumExtension));
207+
addOption(USE_OPTIONAL, "Use Optional<T> to distinguish absent, null, and present for optional fields (Dart 3+)", String.valueOf(useOptional));
208+
addOption(PATCH_ONLY, "Only apply Optional<T> to PATCH operation request bodies (requires useOptional=true)", String.valueOf(patchOnly));
198209
addOption(CodegenConstants.SOURCE_FOLDER, CodegenConstants.SOURCE_FOLDER_DESC, sourceFolder);
199210
}
200211

@@ -301,6 +312,24 @@ public void processOpts() {
301312
additionalProperties.put(USE_ENUM_EXTENSION, useEnumExtension);
302313
}
303314

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

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

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

564664
@Override
@@ -623,6 +723,19 @@ public CodegenProperty fromProperty(String name, Schema p, boolean required) {
623723
return property;
624724
}
625725

726+
@Override
727+
public CodegenParameter fromParameter(Parameter parameter, Set<String> imports) {
728+
final CodegenParameter param = super.fromParameter(parameter, imports);
729+
730+
if (useOptional && param.dataType != null && param.dataType.startsWith("Optional<")) {
731+
param.dataType = param.dataType.substring("Optional<".length(), param.dataType.length() - 1);
732+
param.vendorExtensions.remove("x-is-optional");
733+
param.vendorExtensions.remove("x-unwrapped-datatype");
734+
}
735+
736+
return param;
737+
}
738+
626739
@Override
627740
public CodegenOperation fromOperation(String path, String httpMethod, Operation operation, List<Server> servers) {
628741
final CodegenOperation op = super.fromOperation(path, httpMethod, operation, servers);
@@ -659,6 +772,21 @@ public OperationsMap postProcessOperationsWithModels(OperationsMap objs, List<Mo
659772
if (operations != null) {
660773
List<CodegenOperation> ops = operations.getOperation();
661774
for (CodegenOperation op : ops) {
775+
if (patchOnly && "PATCH".equalsIgnoreCase(op.httpMethod)) {
776+
if (op.bodyParam != null && op.bodyParam.dataType != null) {
777+
String modelName = getString(op);
778+
patchRequestSchemas.add(modelName);
779+
LOGGER.debug("Marked schema '{}' for Optional wrapping (PATCH request body)", modelName);
780+
}
781+
}
782+
783+
if (useOptional) {
784+
unwrapOptionalFromParameters(op.pathParams);
785+
unwrapOptionalFromParameters(op.queryParams);
786+
unwrapOptionalFromParameters(op.headerParams);
787+
unwrapOptionalFromParameters(op.formParams);
788+
}
789+
662790
if (op.hasConsumes) {
663791
if (!op.formParams.isEmpty() || op.isMultipart) {
664792
// DefaultCodegen only sets this if the first consumes mediaType
@@ -680,6 +808,29 @@ public OperationsMap postProcessOperationsWithModels(OperationsMap objs, List<Mo
680808
return objs;
681809
}
682810

811+
private static String getString(CodegenOperation op) {
812+
String modelName = op.bodyParam.dataType;
813+
if (modelName.startsWith("List<") || modelName.startsWith("Map<")) {
814+
int start = modelName.indexOf('<') + 1;
815+
int end = modelName.lastIndexOf('>');
816+
if (start > 0 && end > start) {
817+
modelName = modelName.substring(start, end);
818+
}
819+
}
820+
modelName = modelName.replace("?", "");
821+
return modelName;
822+
}
823+
824+
private void unwrapOptionalFromParameters(List<CodegenParameter> params) {
825+
if (params == null) return;
826+
for (CodegenParameter param : params) {
827+
if (param.dataType != null && param.dataType.startsWith("Optional<")) {
828+
param.dataType = param.dataType.substring("Optional<".length(), param.dataType.length() - 1);
829+
param.vendorExtensions.remove("x-is-optional");
830+
}
831+
}
832+
}
833+
683834
private List<Map<String, String>> prioritizeContentTypes(List<Map<String, String>> consumes) {
684835
if (consumes.size() <= 1) {
685836
// 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+
/// JSON converter for Optional<T> values.
113+
///
114+
/// This converter handles serialization and deserialization of Optional values:
115+
/// - Optional.absent() -> field omitted from JSON
116+
/// - Optional.present(null) -> {"field": null}
117+
/// - Optional.present(value) -> {"field": value}
118+
///
119+
/// Usage with json_serializable:
120+
/// ```dart
121+
/// @JsonKey()
122+
/// @OptionalConverter()
123+
/// final Optional<String?> field;
124+
/// ```
125+
class OptionalConverter<T> implements JsonConverter<Optional<T>, T?> {
126+
const OptionalConverter();
127+
128+
@override
129+
Optional<T> fromJson(T? json) {
130+
return json == null ? const Optional.absent() : Optional.present(json);
131+
}
132+
133+
@override
134+
T? toJson(Optional<T> object) {
135+
return object.isPresent ? object.value : null;
136+
}
137+
}

0 commit comments

Comments
 (0)