Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions swagger_parser/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
## 1.46.0

* Adds `freezed_nullable_presence` (default `false`) to generate a `<name>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`
Expand Down
7 changes: 7 additions & 0 deletions swagger_parser/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<name>Present`
# flag, and setting it with a null value sends `"<name>": 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: <value> to the @Freezed annotation.
# For dart_mappable: creates additional fallback variant for unknown discriminator values.
Expand Down
15 changes: 15 additions & 0 deletions swagger_parser/lib/src/config/swp_config.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -740,6 +754,7 @@ class SWPConfig {
generateValidator: generateValidator,
useFreezed3: useFreezed3,
freezedJsonSerialization: freezedJsonSerialization,
freezedNullablePresence: freezedNullablePresence,
useMultipartFile: useMultipartFile,
fallbackUnion: fallbackUnion,
dartMappableConvenientWhen: dartMappableConvenientWhen,
Expand Down
7 changes: 7 additions & 0 deletions swagger_parser/lib/src/generator/config/generator_config.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -80,6 +81,7 @@ enum ProgrammingLanguage {
generateValidator: generateValidator,
isV3: useFreezed3,
jsonSerialization: freezedJsonSerialization,
nullablePresence: freezedNullablePresence,
useMultipartFile: useMultipartFile,
includeIfNull: includeIfNull,
useFlutterCompute: useFlutterCompute,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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" : '';
Expand All @@ -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}) {
Expand Down Expand Up @@ -183,10 +189,12 @@ String _validateMethod(String className, Set<UniversalType> 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?
Expand Down Expand Up @@ -324,7 +332,8 @@ String? _validationString(UniversalType type) {
}

String _parametersToString(
Set<UniversalType> parameters, bool useMultipartFile, bool includeIfNull) {
Set<UniversalType> parameters, bool useMultipartFile, bool includeIfNull,
{bool includePresenceFlags = false}) {
final sortedByRequired = Set<UniversalType>.from(
parameters.sorted((a, b) => a.compareTo(b)),
);
Expand All @@ -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 = <String, String?>{};

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(
Expand All @@ -370,6 +368,24 @@ String _jsonKey(UniversalType t, bool includeIfNull) {
return sb.toString();
}

Map<String, String?> _jsonKeyParams(UniversalType t, bool includeIfNull) {
final jsonKeyParams = <String, String?>{};

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 ' : '';
Expand All @@ -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 `<name>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<UniversalType> 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<String, Object?> json) {
final data = $helperName.fromJson(json);
return $className(
$fromJsonArgs
);
}

Map<String, Object?> toJson() {
final json = $helperName(
$toJsonArgs
).toJson();
$explicitNulls
return json;
}''';
}

String _presenceJsonHelperClass(String className, Set<UniversalType> 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<String, Object?> json) => _\$${generatedName}FromJson(json);
$fields

Map<String, Object?> 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) {
Expand Down
13 changes: 13 additions & 0 deletions swagger_parser/test/config/swp_config_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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'),
Expand Down
15 changes: 15 additions & 0 deletions swagger_parser/test/e2e/e2e_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Loading
Loading