Skip to content

Commit f6761f1

Browse files
committed
Add support for JsonKey.required
fixes #216
1 parent 727fe0d commit f6761f1

13 files changed

+131
-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?.toList());
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 (map != null && allowedKeys != null) {
24+
var invalidKeys =
25+
map.keys.cast<String>().where((k) => !allowedKeys.contains(k)).toList();
26+
if (invalidKeys.isNotEmpty) {
27+
throw new UnrecognizedKeysException(invalidKeys, map, allowedKeys);
28+
}
29+
}
30+
31+
if (requiredKeys != null) {
32+
var missingKeys = requiredKeys.where((k) => !map.keys.contains(k)).toList();
33+
if (missingKeys.isNotEmpty) {
34+
throw new MissingRequiredKeysException(missingKeys, map);
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: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,16 +18,32 @@ 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+
Matcher _isMissingKeyException(expectedMessage) => allOf(
31+
const isInstanceOf<MissingRequiredKeysException>(),
32+
new FeatureMatcher<MissingRequiredKeysException>(
33+
'message', (e) => e.message, expectedMessage));
2534

2635
void main() {
2736
test('valid values covers all keys', () {
2837
expect(_invalidValueTypes.keys, orderedEquals(_validValues.keys));
2938
});
3039

40+
test('required keys', () {
41+
expect(
42+
() => new StrictKeysObject.fromJson({}),
43+
throwsA(_isMissingKeyException(
44+
'Required keys are missing: value, custom_field.')));
45+
});
46+
3147
group('nullable', () {
3248
group('unwrapped', () {
3349
_nullableTests(nullable.testFactory, nullable.testFromJson);
@@ -241,8 +257,11 @@ Matcher _getMatcher(bool checked, String expectedKey, bool checkedAssignment) {
241257

242258
innerMatcher = _checkedMatcher(expectedKey);
243259
} else {
244-
innerMatcher =
245-
anyOf(isACastError, _isATypeError, _isAUnrecognizedKeysEexception);
260+
innerMatcher = anyOf(
261+
isACastError,
262+
_isATypeError,
263+
_isAUnrecognizedKeysEexception(
264+
'Unrecognized keys: [invalid_key]; supported keys: [value, custom_field]'));
246265

247266
if (checkedAssignment) {
248267
switch (expectedKey) {
@@ -253,7 +272,7 @@ Matcher _getMatcher(bool checked, String expectedKey, bool checkedAssignment) {
253272
innerMatcher = isArgumentError;
254273
break;
255274
case 'strictKeysObject':
256-
innerMatcher = _isAUnrecognizedKeysEexception;
275+
innerMatcher = _isAUnrecognizedKeysEexception('bob');
257276
break;
258277
case 'intIterable':
259278
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
}

json_serializable/test/yaml/build_config.dart

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ part 'build_config.g.dart';
1111

1212
@JsonSerializable()
1313
class Config extends Object with _$ConfigSerializerMixin {
14+
@JsonKey(required: true)
1415
final Map<String, Builder> builders;
1516

1617
Config({@required this.builders});

json_serializable/test/yaml/build_config.g.dart

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ part of 'build_config.dart';
1212

1313
Config _$ConfigFromJson(Map json) {
1414
return $checkedNew('Config', json, () {
15+
$checkKeys(json, requiredKeys: const ['builders']);
1516
var val = new Config(
1617
builders: $checkedConvert(
1718
json,

json_serializable/test/yaml/yaml_test.dart

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,13 @@ line 4, column 5 of file.yaml: Invalid key "baz"
121121
line 3, column 5 of file.yaml: Invalid key "foo"
122122
foo: bar
123123
^^^''',
124+
r'''
125+
bob: cool
126+
''': '''
127+
Could not create `Config`.
128+
line 1, column 3 of file.yaml: Required keys are missing: builders.
129+
bob: cool
130+
^^^^^^^^^^'''
124131
};
125132

126133
String _prettyPrintCheckedFromJsonException(CheckedFromJsonException err) {
@@ -131,15 +138,21 @@ String _prettyPrintCheckedFromJsonException(CheckedFromJsonException err) {
131138
orElse: () => null) as YamlScalar;
132139
}
133140

141+
var innerError = err.innerError;
142+
134143
var message = 'Could not create `${err.className}`.';
135-
if (err.innerError is UnrecognizedKeysException) {
136-
var innerError = err.innerError as UnrecognizedKeysException;
144+
if (innerError is UnrecognizedKeysException) {
145+
expect(err.message, innerError.message);
137146
message += '\n${innerError.message}\n';
138147
for (var key in innerError.unrecognizedKeys) {
139148
var yamlKey = _getYamlKey(key);
140149
assert(yamlKey != null);
141150
message += '\n${yamlKey.span.message('Invalid key "$key"')}';
142151
}
152+
} else if (innerError is MissingRequiredKeysException) {
153+
expect(err.message, innerError.message);
154+
expect(err.key, innerError.missingKeys.first);
155+
message += '\n${yamlMap.span.message(innerError.message)}';
143156
} else {
144157
var yamlValue = yamlMap.nodes[err.key];
145158

0 commit comments

Comments
 (0)