Skip to content

Commit c873833

Browse files
committed
Add support for JsonKey.required
fixes #216
1 parent bd47123 commit c873833

13 files changed

+127
-25
lines changed

json_annotation/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@
44
Upgrading to the latest `json_serializable` and re-running your build will
55
eliminate any `@deprecated` hints you see.
66

7+
* Added `JsonKey.required` field and an associated
8+
`MissingRequiredKeysException` that is thrown when `required` fields don't
9+
have corresponding keys in a source JSON map.
10+
711
## 0.2.7+1
812

913
* Small improvement to `UnrecognizedKeysException.message` output and

json_annotation/lib/src/allowed_keys_helpers.dart

Lines changed: 34 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,16 +13,26 @@ void $checkAllowedKeys(Map map, Iterable<String> allowedKeys) {
1313
$checkKeys(map, allowedKeys: allowedKeys);
1414
}
1515

16-
/// Helper function used in generated code when
17-
/// `JsonSerializable.disallowUnrecognizedKeys` is `true`.
16+
/// Helper function used in generated `fromJson` code when
17+
/// `JsonSerializable.disallowUnrecognizedKeys` is true for an annotated type or
18+
/// `JsonKey.required` is `true` for any annotated fields.
1819
///
1920
/// Should not be used directly.
20-
void $checkKeys(Map map, {Iterable<String> allowedKeys}) {
21-
if (map == null) return;
22-
var invalidKeys = map.keys.where((k) => !allowedKeys.contains(k));
23-
if (invalidKeys.isNotEmpty) {
24-
throw new UnrecognizedKeysException(
25-
new List<String>.from(invalidKeys), map, allowedKeys.toList());
21+
void $checkKeys(Map map,
22+
{List<String> allowedKeys, List<String> requiredKeys}) {
23+
if (requiredKeys != null) {
24+
var missingKeys = requiredKeys.where((k) => !map.keys.contains(k)).toList();
25+
if (missingKeys.isNotEmpty) {
26+
throw new MissingRequiredKeysException(missingKeys, map);
27+
}
28+
}
29+
30+
if (map != null && allowedKeys != null) {
31+
var invalidKeys =
32+
map.keys.cast<String>().where((k) => !allowedKeys.contains(k)).toList();
33+
if (invalidKeys.isNotEmpty) {
34+
throw new UnrecognizedKeysException(invalidKeys, map, allowedKeys);
35+
}
2636
}
2737
}
2838

@@ -45,3 +55,19 @@ class UnrecognizedKeysException implements Exception {
4555

4656
UnrecognizedKeysException(this.unrecognizedKeys, this.map, this.allowedKeys);
4757
}
58+
59+
/// Exception thrown if there are missing required keys in a JSON map that was
60+
/// provided during deserialization.
61+
class MissingRequiredKeysException implements Exception {
62+
/// The keys that [map] is missing.
63+
final List<String> missingKeys;
64+
65+
/// The source [Map] that the required keys were missing in.
66+
final Map map;
67+
68+
/// A human-readable message corresponding to the error.
69+
String get message => 'Required keys are missing: ${missingKeys.join(', ')}.';
70+
71+
MissingRequiredKeysException(this.missingKeys, this.map)
72+
: assert(missingKeys.isNotEmpty);
73+
}

json_annotation/lib/src/checked_helpers.dart

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ T $checkedNew<T>(String className, Map map, T constructor(),
2323
String key;
2424
if (error is ArgumentError) {
2525
key = fieldKeyMap[error.name] ?? error.name;
26+
} else if (error is MissingRequiredKeysException) {
27+
key = error.missingKeys.first;
2628
}
2729
throw new CheckedFromJsonException._(error, stack, map, key,
2830
className: className);
@@ -92,6 +94,8 @@ class CheckedFromJsonException implements Exception {
9294
return error.message?.toString();
9395
} else if (error is UnrecognizedKeysException) {
9496
return error.message;
97+
} else if (error is MissingRequiredKeysException) {
98+
return error.message;
9599
}
96100
return null;
97101
}

json_annotation/lib/src/json_serializable.dart

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,16 @@ class JsonKey {
129129
/// value is `null`.
130130
final Object defaultValue;
131131

132+
/// When `true`, generated code for `fromJson` will verify that the source
133+
/// JSON map contains the associated key.
134+
///
135+
/// If the key does not exist, a `MissingRequiredKeysException` exception is
136+
/// thrown.
137+
///
138+
/// Note: only the existence of the key is checked. A key with a `null` value
139+
/// is considered valid.
140+
final bool required;
141+
132142
/// Creates a new [JsonKey] instance.
133143
///
134144
/// Only required when the default behavior is not desired.
@@ -139,7 +149,8 @@ class JsonKey {
139149
this.ignore,
140150
this.fromJson,
141151
this.toJson,
142-
this.defaultValue});
152+
this.defaultValue,
153+
this.required});
143154
}
144155

145156
// Until enum supports parse: github.com/dart-lang/sdk/issues/33244

json_serializable/CHANGELOG.md

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,19 @@
11
## 0.5.7
22

3+
* Added support for `JsonKey.required`.
4+
* When `true`, generated code throws a `MissingRequiredKeysException` if
5+
the key does not exist in the JSON map used to populate the annotated field.
6+
* Will be captured and wrapped in a `CheckedFromJsonException` if
7+
`checked` is enabled in `json_serializable`.
8+
39
* Added support for `Uri` conversion.
410

511
## 0.5.6
612

713
* Added support for `JsonSerializable.disallowUnrecognizedKeys`.
814
* Throws an `UnrecognizedKeysException` if it finds unrecognized keys in the
9-
JSON map used to create the annotated object.
10-
* Will be captured captured and wrapped in a `CheckedFromJsonException` if
15+
JSON map used to populate the annotated field.
16+
* Will be captured and wrapped in a `CheckedFromJsonException` if
1117
`checked` is enabled in `json_serializable`.
1218
* All `fromJson` constructors now use block syntax instead of fat arrows.
1319

json_serializable/lib/src/generator_helper.dart

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,15 @@ class _GeneratorHelper {
249249
args.add('allowedKeys: $allowKeysLiteral');
250250
}
251251

252+
var requiredKeys =
253+
accessibleFields.values.where((fe) => jsonKeyFor(fe).required).toList();
254+
if (requiredKeys.isNotEmpty) {
255+
var requiredKeyLiteral =
256+
jsonLiteralAsDart(requiredKeys.map(_nameAccess).toList(), true);
257+
258+
args.add('requiredKeys: $requiredKeyLiteral');
259+
}
260+
252261
if (args.isNotEmpty) {
253262
_buffer.writeln('${' ' * indent}\$checkKeys(json, ${args.join(', ')});');
254263
}

json_serializable/lib/src/json_key_helpers.dart

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,7 @@ JsonKeyWithConversion _from(FieldElement element) {
119119
obj.getField('includeIfNull').toBoolValue(),
120120
obj.getField('ignore').toBoolValue(),
121121
defaultValueLiteral,
122+
obj.getField('required').toBoolValue(),
122123
fromJsonName,
123124
toJsonName);
124125
}
@@ -137,16 +138,24 @@ class JsonKeyWithConversion extends JsonKey {
137138
const JsonKeyWithConversion._empty()
138139
: fromJsonData = null,
139140
toJsonData = null,
140-
super();
141-
142-
JsonKeyWithConversion._(String name, bool nullable, bool includeIfNull,
143-
bool ignore, Object defaultValue, this.fromJsonData, this.toJsonData)
141+
super(required: false);
142+
143+
JsonKeyWithConversion._(
144+
String name,
145+
bool nullable,
146+
bool includeIfNull,
147+
bool ignore,
148+
Object defaultValue,
149+
bool required,
150+
this.fromJsonData,
151+
this.toJsonData)
144152
: super(
145153
name: name,
146154
nullable: nullable,
147155
includeIfNull: includeIfNull,
148156
ignore: ignore,
149-
defaultValue: defaultValue);
157+
defaultValue: defaultValue,
158+
required: required ?? false);
150159
}
151160

152161
ConvertData _getFunctionName(

json_serializable/test/kitchen_sink/kitchen_sink_test.dart

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,16 +18,28 @@ import 'kitchen_sink.non_nullable.wrapped.dart' as nnwrapped
1818
import 'kitchen_sink.wrapped.dart' as wrapped show testFactory, testFromJson;
1919

2020
import 'kitchen_sink_interface.dart';
21+
import 'strict_keys_object.dart';
2122

2223
final _isATypeError = const isInstanceOf<TypeError>();
23-
final _isAUnrecognizedKeysEexception =
24-
const isInstanceOf<UnrecognizedKeysException>();
24+
25+
Matcher _isAUnrecognizedKeysEexception(expectedMessage) => allOf(
26+
const isInstanceOf<UnrecognizedKeysException>(),
27+
new FeatureMatcher<UnrecognizedKeysException>(
28+
'message', (e) => e.message, expectedMessage));
29+
30+
final _isMissingKeyException =
31+
const isInstanceOf<MissingRequiredKeysException>();
2532

2633
void main() {
2734
test('valid values covers all keys', () {
2835
expect(_invalidValueTypes.keys, orderedEquals(_validValues.keys));
2936
});
3037

38+
test('required keys', () {
39+
expect(() => new StrictKeysObject.fromJson({}),
40+
throwsA(_isMissingKeyException));
41+
});
42+
3143
group('nullable', () {
3244
group('unwrapped', () {
3345
_nullableTests(nullable.testFactory, nullable.testFromJson);
@@ -241,8 +253,11 @@ Matcher _getMatcher(bool checked, String expectedKey, bool checkedAssignment) {
241253

242254
innerMatcher = _checkedMatcher(expectedKey);
243255
} else {
244-
innerMatcher =
245-
anyOf(isACastError, _isATypeError, _isAUnrecognizedKeysEexception);
256+
innerMatcher = anyOf(
257+
isACastError,
258+
_isATypeError,
259+
_isAUnrecognizedKeysEexception(
260+
'Unrecognized keys: [invalid_key]; supported keys: [value, custom_field]'));
246261

247262
if (checkedAssignment) {
248263
switch (expectedKey) {
@@ -253,7 +268,7 @@ Matcher _getMatcher(bool checked, String expectedKey, bool checkedAssignment) {
253268
innerMatcher = isArgumentError;
254269
break;
255270
case 'strictKeysObject':
256-
innerMatcher = _isAUnrecognizedKeysEexception;
271+
innerMatcher = _isAUnrecognizedKeysEexception('bob');
257272
break;
258273
case 'intIterable':
259274
case 'datetime-iterable':

json_serializable/test/kitchen_sink/strict_keys_object.dart

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,11 @@ part 'strict_keys_object.g.dart';
99
@JsonSerializable(disallowUnrecognizedKeys: true)
1010
class StrictKeysObject extends Object with _$StrictKeysObjectSerializerMixin {
1111
@override
12+
@JsonKey(required: true)
1213
final int value;
1314

1415
@override
15-
@JsonKey(name: 'custom_field')
16+
@JsonKey(name: 'custom_field', required: true)
1617
final String customField;
1718

1819
StrictKeysObject(this.value, this.customField);

json_serializable/test/kitchen_sink/strict_keys_object.g.dart

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,9 @@ part of 'strict_keys_object.dart';
1111
// **************************************************************************
1212

1313
StrictKeysObject _$StrictKeysObjectFromJson(Map json) {
14-
$checkKeys(json, allowedKeys: const ['value', 'custom_field']);
14+
$checkKeys(json,
15+
allowedKeys: const ['value', 'custom_field'],
16+
requiredKeys: const ['value', 'custom_field']);
1517
return new StrictKeysObject(
1618
json['value'] as int, json['custom_field'] as String);
1719
}

0 commit comments

Comments
 (0)