diff --git a/swagger_parser/CHANGELOG.md b/swagger_parser/CHANGELOG.md index 1c23a9819..fdeadf77a 100644 --- a/swagger_parser/CHANGELOG.md +++ b/swagger_parser/CHANGELOG.md @@ -1,3 +1,10 @@ +## 1.46.0 + +* Adds `freezed_nullable_presence` (default `false`) to generate a `Present` flag + for each optional nullable property of Freezed models, so `toJson` can tell an omitted + key from an explicit `null`, for example to clear a field in a PATCH request body + ([#477](https://github.com/Carapacik/swagger_parser/issues/477)). + ## 1.45.1 * Fixes OpenAPI 3 schemas without a `required` array generating every property as `required` diff --git a/swagger_parser/README.md b/swagger_parser/README.md index 7dab7d2a0..e01178794 100644 --- a/swagger_parser/README.md +++ b/swagger_parser/README.md @@ -199,6 +199,13 @@ swagger_parser: # Default: true, preserving the existing JSON-enabled generation. freezed_json_serialization: true + # Optional (dart & freezed only). Set true to tell an omitted optional nullable + # field from an explicit null in toJson: each such field gets a `Present` + # flag, and setting it with a null value sends `"": null` (e.g. to clear + # a field in a PATCH body). Requires freezed_json_serialization: true. + # Default: false. + freezed_nullable_presence: false + # Optional (dart & freezed/dart_mappable). Set string value to use fallbackUnion parameter. # For freezed: adds fallbackUnion: to the @Freezed annotation. # For dart_mappable: creates additional fallback variant for unknown discriminator values. diff --git a/swagger_parser/lib/src/config/swp_config.dart b/swagger_parser/lib/src/config/swp_config.dart index fc9275b0b..29d802dff 100644 --- a/swagger_parser/lib/src/config/swp_config.dart +++ b/swagger_parser/lib/src/config/swp_config.dart @@ -40,6 +40,7 @@ class SWPConfig { this.useXNullable = false, this.useFreezed3 = false, this.freezedJsonSerialization = true, + this.freezedNullablePresence = false, this.useMultipartFile = false, this.fallbackUnion, this.dartMappableConvenientWhen = false, @@ -88,6 +89,7 @@ class SWPConfig { required this.useXNullable, required this.useFreezed3, required this.freezedJsonSerialization, + required this.freezedNullablePresence, required this.useMultipartFile, required this.excludeTags, required this.includeTags, @@ -283,6 +285,10 @@ class SWPConfig { yamlMap['freezed_json_serialization'] as bool? ?? rootConfig?.freezedJsonSerialization; + final freezedNullablePresence = + yamlMap['freezed_nullable_presence'] as bool? ?? + rootConfig?.freezedNullablePresence; + final useMultipartFile = yamlMap['use_multipart_file'] as bool? ?? rootConfig?.useMultipartFile; @@ -431,6 +437,8 @@ class SWPConfig { useFreezed3: useFreezed3 ?? dc.useFreezed3, freezedJsonSerialization: freezedJsonSerialization ?? dc.freezedJsonSerialization, + freezedNullablePresence: + freezedNullablePresence ?? dc.freezedNullablePresence, useMultipartFile: useMultipartFile ?? dc.useMultipartFile, fallbackUnion: fallbackUnion, excludeTags: excludedTags ?? dc.excludeTags, @@ -614,6 +622,12 @@ class SWPConfig { /// Generate Freezed JSON helpers through json_serializable. final bool freezedJsonSerialization; + /// DART & FREEZED ONLY + /// Optional. Set `true` to track whether optional nullable fields were + /// present in JSON, so `toJson` can tell an omitted key from an explicit + /// `null` (e.g. to clear a field in a PATCH request body). + final bool freezedNullablePresence; + /// DART ONLY /// Optional. Set `true` to use MultipartFile instead of File as argument type /// for file parameters. @@ -740,6 +754,7 @@ class SWPConfig { generateValidator: generateValidator, useFreezed3: useFreezed3, freezedJsonSerialization: freezedJsonSerialization, + freezedNullablePresence: freezedNullablePresence, useMultipartFile: useMultipartFile, fallbackUnion: fallbackUnion, dartMappableConvenientWhen: dartMappableConvenientWhen, diff --git a/swagger_parser/lib/src/generator/config/generator_config.dart b/swagger_parser/lib/src/generator/config/generator_config.dart index 85d557247..39c2c1abd 100644 --- a/swagger_parser/lib/src/generator/config/generator_config.dart +++ b/swagger_parser/lib/src/generator/config/generator_config.dart @@ -28,6 +28,7 @@ class GeneratorConfig { this.generateValidator = false, this.useFreezed3 = false, this.freezedJsonSerialization = true, + this.freezedNullablePresence = false, this.useMultipartFile = false, this.fallbackUnion, this.dartMappableConvenientWhen = true, @@ -133,6 +134,12 @@ class GeneratorConfig { /// Generate Freezed JSON helpers through json_serializable. final bool freezedJsonSerialization; + /// DART & FREEZED ONLY + /// Optional. Set `true` to track whether optional nullable fields were + /// present in JSON, so `toJson` can tell an omitted key from an explicit + /// `null` (e.g. to clear a field in a PATCH request body). + final bool freezedNullablePresence; + /// DART ONLY /// Optional. Set `true` to use MultipartFile instead of File as argument type /// for file parameters. diff --git a/swagger_parser/lib/src/generator/generator/fill_controller.dart b/swagger_parser/lib/src/generator/generator/fill_controller.dart index b9f28e062..bcb985899 100644 --- a/swagger_parser/lib/src/generator/generator/fill_controller.dart +++ b/swagger_parser/lib/src/generator/generator/fill_controller.dart @@ -34,6 +34,7 @@ final class FillController { generateValidator: config.generateValidator, useFreezed3: config.useFreezed3, freezedJsonSerialization: config.freezedJsonSerialization, + freezedNullablePresence: config.freezedNullablePresence, useMultipartFile: config.useMultipartFile, dartMappableConvenientWhen: config.dartMappableConvenientWhen, useDartMappableNaming: config.useDartMappableNaming, diff --git a/swagger_parser/lib/src/generator/model/programming_language.dart b/swagger_parser/lib/src/generator/model/programming_language.dart index a4889e0ab..806a1ca56 100644 --- a/swagger_parser/lib/src/generator/model/programming_language.dart +++ b/swagger_parser/lib/src/generator/model/programming_language.dart @@ -50,6 +50,7 @@ enum ProgrammingLanguage { required bool generateValidator, required bool useFreezed3, required bool freezedJsonSerialization, + required bool freezedNullablePresence, required bool useMultipartFile, required bool dartMappableConvenientWhen, required bool useDartMappableNaming, @@ -80,6 +81,7 @@ enum ProgrammingLanguage { generateValidator: generateValidator, isV3: useFreezed3, jsonSerialization: freezedJsonSerialization, + nullablePresence: freezedNullablePresence, useMultipartFile: useMultipartFile, includeIfNull: includeIfNull, useFlutterCompute: useFlutterCompute, diff --git a/swagger_parser/lib/src/generator/templates/dart_freezed_dto_template.dart b/swagger_parser/lib/src/generator/templates/dart_freezed_dto_template.dart index 038021cf5..2bfff127d 100644 --- a/swagger_parser/lib/src/generator/templates/dart_freezed_dto_template.dart +++ b/swagger_parser/lib/src/generator/templates/dart_freezed_dto_template.dart @@ -10,6 +10,7 @@ String dartFreezedDtoTemplate( UniversalComponentClass dataClass, { required bool useMultipartFile, required bool includeIfNull, + bool nullablePresence = false, bool generateValidator = false, bool isV3 = false, bool jsonSerialization = true, @@ -26,6 +27,10 @@ String dartFreezedDtoTemplate( 'Flutter compute requires Freezed JSON serialization.', ); } + final trackNullablePresence = nullablePresence && + jsonSerialization && + !isUnion && + dataClass.parameters.any(_needsPresenceFlag); final serializerClass = useFlutterCompute ? _generateFlutterComputeSerializer(className) : ''; final asyncImport = useFlutterCompute ? "import 'dart:async';\n\n" : ''; @@ -42,12 +47,13 @@ ${descriptionComment(dataClass.description)}@Freezed(${[ fallbackUnion != null && fallbackUnion.isNotEmpty) "fallbackUnion: '$fallbackUnion'", + if (trackNullablePresence) ...['fromJson: false', 'toJson: false'], ].join(', ')}) ${_classModifier(isUnion: isUnion, isV3: isV3)}class $className with _\$$className { -${_factories(dataClass, className, useMultipartFile, includeIfNull, fallbackUnion, isUnion: isUnion, jsonSerialization: jsonSerialization)} -${jsonSerialization ? _jsonFactories(className, dataClass.undiscriminatedUnionVariants) : ''} +${trackNullablePresence ? ' const $className._();\n\n' : ''}${_factories(dataClass, className, useMultipartFile, includeIfNull, fallbackUnion, isUnion: isUnion, jsonSerialization: jsonSerialization, includePresenceFlags: trackNullablePresence)} +${jsonSerialization ? (trackNullablePresence ? _presenceAwareJsonMethods(className, dataClass.parameters) : _jsonFactories(className, dataClass.undiscriminatedUnionVariants)) : ''} ${generateValidator ? dataClass.parameters.map(_validationString).nonNulls.join() : ''}} -${generateValidator ? _validateMethod(className, dataClass.parameters) : ''}$serializerClass'''; +${trackNullablePresence ? _presenceJsonHelperClass(className, dataClass.parameters, useMultipartFile, includeIfNull) : ''}${generateValidator ? _validateMethod(className, dataClass.parameters) : ''}$serializerClass'''; } String _classModifier({required bool isUnion, required bool isV3}) { @@ -183,10 +189,12 @@ String _validateMethod(String className, Set types) { String _factories(UniversalComponentClass dataClass, String className, bool useMultipartFile, bool includeIfNull, String? fallbackUnion, - {required bool isUnion, required bool jsonSerialization}) { + {required bool isUnion, + required bool jsonSerialization, + bool includePresenceFlags = false}) { if (!isUnion) { return ''' - const factory $className(${dataClass.parameters.isNotEmpty ? '{' : ''}${_parametersToString(dataClass.parameters, useMultipartFile, includeIfNull)}${dataClass.parameters.isNotEmpty ? '\n }' : ''}) = _$className;'''; + const factory $className(${dataClass.parameters.isNotEmpty ? '{' : ''}${_parametersToString(dataClass.parameters, useMultipartFile, includeIfNull, includePresenceFlags: includePresenceFlags)}${dataClass.parameters.isNotEmpty ? '\n }' : ''}) = _$className;'''; } if (dataClass.undiscriminatedUnionVariants case final variants? @@ -324,7 +332,8 @@ String? _validationString(UniversalType type) { } String _parametersToString( - Set parameters, bool useMultipartFile, bool includeIfNull) { + Set parameters, bool useMultipartFile, bool includeIfNull, + {bool includePresenceFlags = false}) { final sortedByRequired = Set.from( parameters.sorted((a, b) => a.compareTo(b)), ); @@ -333,26 +342,15 @@ String _parametersToString( (i, e) => '\n${i != 0 && (e.description?.isNotEmpty ?? false) ? '\n' : ''}${descriptionComment(e.description, tab: ' ')}' '${_jsonKey(e, includeIfNull)} ${_required(e)}' - '${e.toSuitableType(ProgrammingLanguage.dart, useMultipartFile: useMultipartFile)} ${e.name},', + '${e.toSuitableType(ProgrammingLanguage.dart, useMultipartFile: useMultipartFile)} ${e.name},' + '${includePresenceFlags && _needsPresenceFlag(e) ? _presenceFlagParameter(e) : ''}', ) .join(); } String _jsonKey(UniversalType t, bool includeIfNull) { final sb = StringBuffer(); - final jsonKeyParams = {}; - - if (includeIfNull) { - if (t.isRequired && (t.nullable || t.referencedNullable)) { - jsonKeyParams['includeIfNull'] = 'true'; - } else if (!t.isRequired && (t.nullable || t.referencedNullable)) { - jsonKeyParams['includeIfNull'] = 'false'; - } - } - - if (t.jsonKey != null && t.name != t.jsonKey) { - jsonKeyParams['name'] = "'${protectJsonKey(t.jsonKey)}'"; - } + final jsonKeyParams = _jsonKeyParams(t, includeIfNull); if (jsonKeyParams.isNotEmpty) { sb.write( @@ -370,6 +368,24 @@ String _jsonKey(UniversalType t, bool includeIfNull) { return sb.toString(); } +Map _jsonKeyParams(UniversalType t, bool includeIfNull) { + final jsonKeyParams = {}; + + if (includeIfNull) { + if (t.isRequired && (t.nullable || t.referencedNullable)) { + jsonKeyParams['includeIfNull'] = 'true'; + } else if (!t.isRequired && (t.nullable || t.referencedNullable)) { + jsonKeyParams['includeIfNull'] = 'false'; + } + } + + if (t.jsonKey != null && t.name != t.jsonKey) { + jsonKeyParams['name'] = "'${protectJsonKey(t.jsonKey)}'"; + } + + return jsonKeyParams; +} + /// return required if isRequired String _required(UniversalType t) => t.isRequired && t.defaultValue == null ? 'required ' : ''; @@ -378,6 +394,96 @@ String _required(UniversalType t) => String _defaultValue(UniversalType t) => '${t.enumType != null ? '${t.type}.${protectDefaultEnum(t.defaultValue)?.toCamel}' : protectDefaultValue(t.defaultValue, type: t.type)}'; +/// Optional nullable fields get a `Present` flag so `toJson` can tell an +/// omitted key from an explicit `null` (e.g. clearing a field in a PATCH body). +bool _needsPresenceFlag(UniversalType t) => + !t.isRequired && (t.nullable || t.referencedNullable); + +String _presenceFlagName(UniversalType t) => '${t.name}Present'; + +String _presenceFlagParameter(UniversalType t) => '\n' + ' @Default(false)\n' + ' bool ${_presenceFlagName(t)},'; + +String _presenceJsonHelperName(String className) => '_${className}Json'; + +/// `fromJson`/`toJson` for presence-aware classes. Freezed JSON generation is +/// disabled for them, and a private json_serializable helper converts values, +/// so the presence flags can be set from, and applied to, the raw JSON map. +String _presenceAwareJsonMethods( + String className, Set parameters) { + final sorted = parameters.sorted((a, b) => a.compareTo(b)); + final helperName = _presenceJsonHelperName(className); + final fromJsonArgs = sorted.map((e) { + final key = protectJsonKey(e.jsonKey ?? e.name); + return ' ${e.name}: data.${e.name},' + '${_needsPresenceFlag(e) ? "\n ${_presenceFlagName(e)}: json.containsKey('$key')," : ''}'; + }).join('\n'); + final toJsonArgs = + sorted.map((e) => ' ${e.name}: ${e.name},').join('\n'); + final explicitNulls = sorted.where(_needsPresenceFlag).map((e) { + final key = protectJsonKey(e.jsonKey ?? e.name); + return ''' + if (${e.name} == null) { + if (${_presenceFlagName(e)}) { + json['$key'] = null; + } else { + json.remove('$key'); + } + }'''; + }).join('\n'); + + return ''' + + factory $className.fromJson(Map json) { + final data = $helperName.fromJson(json); + return $className( +$fromJsonArgs + ); + } + + Map toJson() { + final json = $helperName( +$toJsonArgs + ).toJson(); +$explicitNulls + return json; + }'''; +} + +String _presenceJsonHelperClass(String className, Set parameters, + bool useMultipartFile, bool includeIfNull) { + final sorted = parameters.sorted((a, b) => a.compareTo(b)); + final helperName = _presenceJsonHelperName(className); + final generatedName = helperName.substring(1); + final constructorParams = sorted.map((e) { + final defaultValue = e.defaultValue == null + ? '' + : ' = ${e.wrappingCollections.isNotEmpty ? 'const ' : ''}${_defaultValue(e)}'; + return '\n ${_required(e)}this.${e.name}$defaultValue,'; + }).join(); + final fields = sorted.map((e) { + final jsonKeyParams = _jsonKeyParams(e, includeIfNull); + final jsonKey = jsonKeyParams.isEmpty + ? '' + : " @JsonKey(${jsonKeyParams.entries.map((p) => '${p.key}: ${p.value}').join(',')})\n"; + return '\n$jsonKey final ${e.toSuitableType(ProgrammingLanguage.dart, useMultipartFile: useMultipartFile)} ${e.name};'; + }).join(); + + return ''' + +@JsonSerializable() +class $helperName { + const $helperName(${sorted.isNotEmpty ? '{$constructorParams\n }' : ''}); + + factory $helperName.fromJson(Map json) => _\$${generatedName}FromJson(json); +$fields + + Map toJson() => _\$${generatedName}ToJson(this); +} +'''; +} + /// Generates top-level serialization functions for Flutter compute isolate support. /// These functions follow Retrofit's naming convention for Parser.FlutterCompute. String _generateFlutterComputeSerializer(String className) { diff --git a/swagger_parser/test/config/swp_config_test.dart b/swagger_parser/test/config/swp_config_test.dart index c02a04330..bb9c15e0b 100644 --- a/swagger_parser/test/config/swp_config_test.dart +++ b/swagger_parser/test/config/swp_config_test.dart @@ -36,6 +36,7 @@ void main() { expect(config.useXNullable, isFalse); expect(config.useFreezed3, isFalse); expect(config.freezedJsonSerialization, isTrue); + expect(config.freezedNullablePresence, isFalse); expect(config.useMultipartFile, isFalse); expect(config.fallbackUnion, isNull); expect(config.excludeTags, isEmpty); @@ -58,6 +59,18 @@ void main() { expect(config.toGeneratorConfig().freezedJsonSerialization, isFalse); }); + test('can enable Freezed nullable presence in YAML', () { + final config = SWPConfig.fromYaml(YamlMap.wrap({ + 'schema_path': 'schema.yaml', + 'output_directory': 'lib/api', + 'json_serializer': 'freezed', + 'freezed_nullable_presence': true, + })); + + expect(config.freezedNullablePresence, isTrue); + expect(config.toGeneratorConfig().freezedNullablePresence, isTrue); + }); + test('should create config with all parameters specified', () { final replacementRules = [ ReplacementRule(pattern: RegExp('Test'), replacement: 'Mock'), diff --git a/swagger_parser/test/e2e/e2e_test.dart b/swagger_parser/test/e2e/e2e_test.dart index 206439e33..4a3314ac9 100644 --- a/swagger_parser/test/e2e/e2e_test.dart +++ b/swagger_parser/test/e2e/e2e_test.dart @@ -1267,6 +1267,21 @@ void main() { ); }); + test('patch_optional_nullable', () async { + await e2eTest( + 'patch_optional_nullable', + (outputDirectory, schemaPath) => SWPConfig( + outputDirectory: outputDirectory, + schemaPath: schemaPath, + jsonSerializer: JsonSerializer.freezed, + putClientsInFolder: true, + includeIfNull: true, + freezedNullablePresence: true, + ), + schemaFileName: 'openapi.yaml', + ); + }); + test('flutter_compute', () async { await e2eTest( 'flutter_compute', diff --git a/swagger_parser/test/e2e/tests/patch_optional_nullable/expected_files/clients/fallback_client.dart b/swagger_parser/test/e2e/tests/patch_optional_nullable/expected_files/clients/fallback_client.dart new file mode 100644 index 000000000..8f34e656e --- /dev/null +++ b/swagger_parser/test/e2e/tests/patch_optional_nullable/expected_files/clients/fallback_client.dart @@ -0,0 +1,21 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, unused_import, invalid_annotation_target, unnecessary_import + +import 'package:dio/dio.dart'; +import 'package:retrofit/retrofit.dart'; + +import '../models/update_appointment_dto.dart'; + +part 'fallback_client.g.dart'; + +@RestApi() +abstract class FallbackClient { + factory FallbackClient(Dio dio, {String? baseUrl}) = _FallbackClient; + + @PATCH('/appointments/{appointmentId}') + Future updateAppointment({ + @Path('appointmentId') required String appointmentId, + @Body() required UpdateAppointmentDto body, + }); +} diff --git a/swagger_parser/test/e2e/tests/patch_optional_nullable/expected_files/export.dart b/swagger_parser/test/e2e/tests/patch_optional_nullable/expected_files/export.dart new file mode 100644 index 000000000..ad5dbc693 --- /dev/null +++ b/swagger_parser/test/e2e/tests/patch_optional_nullable/expected_files/export.dart @@ -0,0 +1,11 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, unused_import, invalid_annotation_target, unnecessary_import + +// Clients +export 'clients/fallback_client.dart'; +// Data classes +export 'models/update_recurring_rule_dto.dart'; +export 'models/update_appointment_dto.dart'; +// Root client +export 'rest_client.dart'; diff --git a/swagger_parser/test/e2e/tests/patch_optional_nullable/expected_files/models/update_appointment_dto.dart b/swagger_parser/test/e2e/tests/patch_optional_nullable/expected_files/models/update_appointment_dto.dart new file mode 100644 index 000000000..952dd6c3f --- /dev/null +++ b/swagger_parser/test/e2e/tests/patch_optional_nullable/expected_files/models/update_appointment_dto.dart @@ -0,0 +1,77 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, unused_import, invalid_annotation_target, unnecessary_import + +import 'package:freezed_annotation/freezed_annotation.dart'; + +import 'update_recurring_rule_dto.dart'; + +part 'update_appointment_dto.freezed.dart'; +part 'update_appointment_dto.g.dart'; + +@Freezed(fromJson: false, toJson: false) +class UpdateAppointmentDto with _$UpdateAppointmentDto { + const UpdateAppointmentDto._(); + + const factory UpdateAppointmentDto({ + @Default(false) bool reminderEnabled, + @JsonKey(includeIfNull: false) UpdateRecurringRuleDto? recurringRuleData, + @Default(false) bool recurringRuleDataPresent, + @JsonKey(includeIfNull: false) String? notes, + @Default(false) bool notesPresent, + }) = _UpdateAppointmentDto; + + factory UpdateAppointmentDto.fromJson(Map json) { + final data = _UpdateAppointmentDtoJson.fromJson(json); + return UpdateAppointmentDto( + reminderEnabled: data.reminderEnabled, + recurringRuleData: data.recurringRuleData, + recurringRuleDataPresent: json.containsKey('recurringRuleData'), + notes: data.notes, + notesPresent: json.containsKey('notes'), + ); + } + + Map toJson() { + final json = _UpdateAppointmentDtoJson( + reminderEnabled: reminderEnabled, + recurringRuleData: recurringRuleData, + notes: notes, + ).toJson(); + if (recurringRuleData == null) { + if (recurringRuleDataPresent) { + json['recurringRuleData'] = null; + } else { + json.remove('recurringRuleData'); + } + } + if (notes == null) { + if (notesPresent) { + json['notes'] = null; + } else { + json.remove('notes'); + } + } + return json; + } +} + +@JsonSerializable() +class _UpdateAppointmentDtoJson { + const _UpdateAppointmentDtoJson({ + this.reminderEnabled = false, + this.recurringRuleData, + this.notes, + }); + + factory _UpdateAppointmentDtoJson.fromJson(Map json) => + _$UpdateAppointmentDtoJsonFromJson(json); + + final bool reminderEnabled; + @JsonKey(includeIfNull: false) + final UpdateRecurringRuleDto? recurringRuleData; + @JsonKey(includeIfNull: false) + final String? notes; + + Map toJson() => _$UpdateAppointmentDtoJsonToJson(this); +} diff --git a/swagger_parser/test/e2e/tests/patch_optional_nullable/expected_files/models/update_recurring_rule_dto.dart b/swagger_parser/test/e2e/tests/patch_optional_nullable/expected_files/models/update_recurring_rule_dto.dart new file mode 100644 index 000000000..5527b03f9 --- /dev/null +++ b/swagger_parser/test/e2e/tests/patch_optional_nullable/expected_files/models/update_recurring_rule_dto.dart @@ -0,0 +1,60 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, unused_import, invalid_annotation_target, unnecessary_import + +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'update_recurring_rule_dto.freezed.dart'; +part 'update_recurring_rule_dto.g.dart'; + +@Freezed(fromJson: false, toJson: false) +class UpdateRecurringRuleDto with _$UpdateRecurringRuleDto { + const UpdateRecurringRuleDto._(); + + const factory UpdateRecurringRuleDto({ + required String frequency, + @JsonKey(includeIfNull: false) int? interval, + @Default(false) bool intervalPresent, + }) = _UpdateRecurringRuleDto; + + factory UpdateRecurringRuleDto.fromJson(Map json) { + final data = _UpdateRecurringRuleDtoJson.fromJson(json); + return UpdateRecurringRuleDto( + frequency: data.frequency, + interval: data.interval, + intervalPresent: json.containsKey('interval'), + ); + } + + Map toJson() { + final json = _UpdateRecurringRuleDtoJson( + frequency: frequency, + interval: interval, + ).toJson(); + if (interval == null) { + if (intervalPresent) { + json['interval'] = null; + } else { + json.remove('interval'); + } + } + return json; + } +} + +@JsonSerializable() +class _UpdateRecurringRuleDtoJson { + const _UpdateRecurringRuleDtoJson({ + required this.frequency, + this.interval, + }); + + factory _UpdateRecurringRuleDtoJson.fromJson(Map json) => + _$UpdateRecurringRuleDtoJsonFromJson(json); + + final String frequency; + @JsonKey(includeIfNull: false) + final int? interval; + + Map toJson() => _$UpdateRecurringRuleDtoJsonToJson(this); +} diff --git a/swagger_parser/test/e2e/tests/patch_optional_nullable/expected_files/rest_client.dart b/swagger_parser/test/e2e/tests/patch_optional_nullable/expected_files/rest_client.dart new file mode 100644 index 000000000..fb6f00928 --- /dev/null +++ b/swagger_parser/test/e2e/tests/patch_optional_nullable/expected_files/rest_client.dart @@ -0,0 +1,26 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, unused_import, invalid_annotation_target, unnecessary_import + +import 'package:dio/dio.dart'; + +import 'clients/fallback_client.dart'; + +/// Patch Optional Nullable `v1.0.0` +class RestClient { + RestClient( + Dio dio, { + String? baseUrl, + }) : _dio = dio, + _baseUrl = baseUrl; + + final Dio _dio; + final String? _baseUrl; + + static String get version => '1.0.0'; + + FallbackClient? _fallback; + + FallbackClient get fallback => + _fallback ??= FallbackClient(_dio, baseUrl: _baseUrl); +} diff --git a/swagger_parser/test/e2e/tests/patch_optional_nullable/generate_test.dart b/swagger_parser/test/e2e/tests/patch_optional_nullable/generate_test.dart new file mode 100644 index 000000000..8c5072429 --- /dev/null +++ b/swagger_parser/test/e2e/tests/patch_optional_nullable/generate_test.dart @@ -0,0 +1,16 @@ +import 'package:swagger_parser/swagger_parser.dart'; + +Future main() async { + final config = SWPConfig( + outputDirectory: 'test/e2e/tests/patch_optional_nullable/expected_files', + schemaPath: 'test/e2e/tests/patch_optional_nullable/openapi.yaml', + jsonSerializer: JsonSerializer.freezed, + putClientsInFolder: true, + includeIfNull: true, + freezedNullablePresence: true, + ); + + final processor = GenProcessor(config); + await processor.generateFiles(); + print('Files generated successfully!'); +} diff --git a/swagger_parser/test/e2e/tests/patch_optional_nullable/openapi.yaml b/swagger_parser/test/e2e/tests/patch_optional_nullable/openapi.yaml new file mode 100644 index 000000000..7ddb41a6d --- /dev/null +++ b/swagger_parser/test/e2e/tests/patch_optional_nullable/openapi.yaml @@ -0,0 +1,51 @@ +openapi: 3.0.0 +info: + title: Patch Optional Nullable + version: 1.0.0 +paths: + /appointments/{appointmentId}: + patch: + operationId: updateAppointment + parameters: + - in: path + name: appointmentId + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateAppointmentDto' + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateAppointmentDto' +components: + schemas: + UpdateRecurringRuleDto: + type: object + required: + - frequency + properties: + frequency: + type: string + interval: + type: integer + UpdateAppointmentDto: + type: object + properties: + recurringRuleData: + nullable: true + allOf: + - $ref: '#/components/schemas/UpdateRecurringRuleDto' + notes: + type: string + nullable: true + reminderEnabled: + type: boolean + default: false diff --git a/swagger_parser/test/generator/data_classes_test.dart b/swagger_parser/test/generator/data_classes_test.dart index 9eb927fbb..2fa6a7f6b 100644 --- a/swagger_parser/test/generator/data_classes_test.dart +++ b/swagger_parser/test/generator/data_classes_test.dart @@ -2639,6 +2639,165 @@ data class ClassName( }); }); + group('Presence aware serialization', () { + test('dart + freezed adds fieldPresent for optional nullable refs', + () async { + final dataClass = UniversalComponentClass( + name: 'UpdateAppointmentDto', + imports: const {'UpdateRecurringRuleDto'}, + parameters: { + const UniversalType( + type: 'string', + name: 'appointmentId', + isRequired: true, + ), + const UniversalType( + type: 'UpdateRecurringRuleDto', + name: 'recurringRuleData', + isRequired: false, + referencedNullable: true, + ), + const UniversalType( + type: 'string', + name: 'notes', + isRequired: false, + nullable: true, + ), + }, + ); + const fillController = FillController( + config: GeneratorConfig( + name: '', + outputDirectory: '', + jsonSerializer: JsonSerializer.freezed, + includeIfNull: true, + freezedNullablePresence: true, + ), + ); + final filledContent = fillController.fillDtoContent(dataClass); + const expectedContents = r''' +import 'package:freezed_annotation/freezed_annotation.dart'; + +import 'update_recurring_rule_dto.dart'; + +part 'update_appointment_dto.freezed.dart'; +part 'update_appointment_dto.g.dart'; + +@Freezed(fromJson: false, toJson: false) +class UpdateAppointmentDto with _$UpdateAppointmentDto { + const UpdateAppointmentDto._(); + + const factory UpdateAppointmentDto({ + required String appointmentId, + @JsonKey(includeIfNull: false) + UpdateRecurringRuleDto? recurringRuleData, + @Default(false) + bool recurringRuleDataPresent, + @JsonKey(includeIfNull: false) + String? notes, + @Default(false) + bool notesPresent, + }) = _UpdateAppointmentDto; + + factory UpdateAppointmentDto.fromJson(Map json) { + final data = _UpdateAppointmentDtoJson.fromJson(json); + return UpdateAppointmentDto( + appointmentId: data.appointmentId, + recurringRuleData: data.recurringRuleData, + recurringRuleDataPresent: json.containsKey('recurringRuleData'), + notes: data.notes, + notesPresent: json.containsKey('notes'), + ); + } + + Map toJson() { + final json = _UpdateAppointmentDtoJson( + appointmentId: appointmentId, + recurringRuleData: recurringRuleData, + notes: notes, + ).toJson(); + if (recurringRuleData == null) { + if (recurringRuleDataPresent) { + json['recurringRuleData'] = null; + } else { + json.remove('recurringRuleData'); + } + } + if (notes == null) { + if (notesPresent) { + json['notes'] = null; + } else { + json.remove('notes'); + } + } + return json; + } +} + +@JsonSerializable() +class _UpdateAppointmentDtoJson { + const _UpdateAppointmentDtoJson({ + required this.appointmentId, + this.recurringRuleData, + this.notes, + }); + + factory _UpdateAppointmentDtoJson.fromJson(Map json) => _$UpdateAppointmentDtoJsonFromJson(json); + + final String appointmentId; + @JsonKey(includeIfNull: false) + final UpdateRecurringRuleDto? recurringRuleData; + @JsonKey(includeIfNull: false) + final String? notes; + + Map toJson() => _$UpdateAppointmentDtoJsonToJson(this); +} +'''; + expect(filledContent.content, expectedContents); + }); + for (final (description, config) in [ + ( + 'is opt-in', + const GeneratorConfig( + name: '', + outputDirectory: '', + jsonSerializer: JsonSerializer.freezed, + includeIfNull: true, + ), + ), + ( + 'is skipped without JSON serialization', + const GeneratorConfig( + name: '', + outputDirectory: '', + jsonSerializer: JsonSerializer.freezed, + useFreezed3: true, + freezedJsonSerialization: false, + freezedNullablePresence: true, + ), + ), + ]) { + test('dart + freezed presence tracking $description', () { + final dataClass = UniversalComponentClass( + name: 'ClassName', + imports: const {}, + parameters: { + const UniversalType( + type: 'string', + name: 'notes', + isRequired: false, + nullable: true, + ), + }, + ); + final content = + FillController(config: config).fillDtoContent(dataClass).content; + expect(content, isNot(contains('notesPresent'))); + expect(content, isNot(contains('_ClassNameJson'))); + }); + } + }); + group('description', () { test('dart + json_serializable', () async { final dataClass = UniversalComponentClass(