blob: ea749fd68776e9d400c8c58b874707d2e853a0c7 [file] [log] [blame]
// Copyright 2013 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'dart:convert' show JsonEncoder, jsonDecode;
import 'package:meta/meta.dart';
/// A JSON object that contains key-value pairs of other JSON values.
///
/// This extension type provides type-safe access to the values in the
/// underlying JSON object without the need for manual type casting and with
/// helpful APIs for common operations.
///
/// ## Examples
///
/// ### Basic Usage
///
/// ```dart
/// const json = JsonObject({
/// 'name': 'John Doe',
/// 'age': 30,
/// 'isEmployed': true,
/// 'pets': ['Fido', 'Whiskers'],
/// });
///
/// final name = json.string('name');
/// final age = json.integer('age');
/// final isEmployed = json.boolean('isEmployed');
/// final pets = json.stringList('pets');
/// ```
///
/// ### Null Safety
///
/// In the above example, we assumed that all keys exist in the JSON object. To
/// gracefully handle cases where a key may not exist, you can use the `OrNull`
/// variants of the methods:
///
/// ```dart
/// final name = json.stringOrNull('name');
/// ```
///
/// This works nicely with null-aware operators:
///
/// ```dart
/// final name = json.stringOrNull('name') ?? 'Unknown';
/// ```
///
/// ### Bulk Operations
///
/// By default, a [JsonReadException] is thrown if a key is not found or if the
/// value cannot be cast to the expected type, which can be handled using a
/// `try-catch` block:
///
/// ```dart
/// try {
/// final name = json.string('name');
/// } on FormatException catch (e) {
/// print('Error: $e');
/// }
/// ```
///
/// However, it is cumbersome to wrap every access in a `try-catch` block, and
/// sometimes you will want to collect all errors at once. For this, you can use
/// [map] to read multiple values at once and convert them to a custom object:
///
/// ```dart
/// try {
/// final person = json.map(
/// (json) => Person(
/// name: json.string('name'),
/// age: json.integer('age'),
/// isEmployed: json.boolean('isEmployed'),
/// pets: json.stringList('pets'),
/// ),
/// );
/// } on JsonMapException catch (e) {
/// print('Errors: ${e.exceptions}');
/// }
/// ```
///
/// Or, provide an `onError` handler to handle the exception (either to log and
/// provide a default value, or to rethrow a different exception):
///
/// ```dart
/// final person = json.map(
/// (json) => Person(name: json.string('name')),
/// onError: (json, e) => throw FormatException('Failed to read person: $e'),
/// );
/// ```
extension type const JsonObject(Map<String, Object?> _object) {
/// Parses a JSON string into a [JsonObject].
static JsonObject parse(String jsonString) {
return JsonObject(jsonDecode(jsonString) as Map<String, Object?>);
}
// If non-null, errors caught during [map] are stored here.
static List<JsonReadException>? _mapErrors;
// If in the context of a [map] operation, collects exceptions.
// Otherwise, throws the exception.
static void _error(JsonReadException e) {
if (_mapErrors case final List<JsonReadException> list) {
list.add(e);
} else {
throw e;
}
}
T _get<T extends Object>(String key, T defaultTo) {
final T? result = _getOrNull<T>(key);
if (result == null) {
if (!_object.containsKey(key)) {
_error(MissingKeyJsonReadException(this, key));
}
return defaultTo;
}
return result;
}
/// Returns the value at the given [key] as a [String].
///
/// Throws a [JsonReadException] if the value is not found or cannot be cast.
String string(String key) => _get<String>(key, '');
/// Returns the value at the given [key] as an [int].
///
/// Throws a [JsonReadException] if the value is not found or cannot be cast.
int integer(String key) => _get<int>(key, 0);
/// Returns the value at the given [key] as a [bool].
///
/// Throws a [JsonReadException] if the value is not found or cannot be cast.
bool boolean(String key) => _get<bool>(key, false);
List<T> _getList<T extends Object>(String key) {
final List<T>? result = _getListOrNull<T>(key);
if (result == null) {
_error(MissingKeyJsonReadException(this, key));
return <T>[];
}
return result;
}
/// Returns the value at the given [key] as a [List] of [String]s.
///
/// Throws a [JsonReadException] if the value is not found or cannot be cast.
List<String> stringList(String key) => _getList<String>(key);
T? _getOrNull<T extends Object>(String key) {
final Object? value = _object[key];
if (value == null && !_object.containsKey(key)) {
return null;
} else if (value is! T) {
_error(InvalidTypeJsonReadException(
this,
key,
expected: T,
actual: value.runtimeType,
));
return null;
} else {
return value;
}
}
/// Returns the value at the given [key] as a [String].
///
/// If the value is not found, returns `null`.
///
/// Throws a [JsonReadException] if the value cannot be cast.
String? stringOrNull(String key) => _getOrNull<String>(key);
/// Returns the value at the given [key] as an [int].
///
/// If the value is not found, returns `null`.
///
/// Throws a [JsonReadException] if the value cannot be cast.
int? integerOrNull(String key) => _getOrNull<int>(key);
/// Returns the value at the given [key] as a [bool].
///
/// If the value is not found, returns `null`.
///
/// Throws a [JsonReadException] if the value cannot be cast.
bool? booleanOrNull(String key) => _getOrNull<bool>(key);
List<T>? _getListOrNull<T extends Object>(String key) {
final Object? value = _object[key];
if (value == null && !_object.containsKey(key)) {
return null;
} else if (value is! List<Object?>) {
_error(InvalidTypeJsonReadException(
this,
key,
expected: List<T>,
actual: value.runtimeType,
));
return <T>[];
} else {
final List<T> result = <T>[];
for (final Object? element in value) {
if (element is! T) {
_error(InvalidTypeJsonReadException(
this,
key,
expected: T,
actual: element.runtimeType,
));
return <T>[];
}
result.add(element);
}
return result;
}
}
/// Returns the value at the given [key] as a [List] of [String]s.
///
/// If the value is not found, returns `null`.
///
/// Throws a [JsonReadException] if the value cannot be cast.
List<String>? stringListOrNull(String key) => _getListOrNull<String>(key);
static Never _onErrorThrow(JsonObject object, JsonMapException e) {
throw e;
}
/// Returns the result of applying the given [mapper] to this JSON object.
///
/// Any exception that otherwise would be thrown by the mapper is caught
/// internally and rethrown as a [JsonMapException] with all of the original
/// exceptions added to its [exceptions] property.
///
/// If [onError] is provided, it is called with this object and the caught
/// [JsonMapException] to handle the error. If not provided, the exception is
/// rethrown.
///
/// **Note:** The [mapper] function _must_ be synchronous.
///
/// ## Example
///
/// ```dart
/// final person = json.map(
/// (json) => Person(
/// name: json.string('name'),
/// age: json.integer('age'),
/// isEmployed: json.boolean('isEmployed'),
/// ), onError: (json, e) {
/// throw FormatException('Failed to read person: $e');
/// },
/// );
/// ```
T map<T extends Object>(
T Function(JsonObject) mapper, {
T Function(JsonObject, JsonMapException) onError = _onErrorThrow,
}) {
// Refuse asynchronous mappers to avoid reentrancy issues.
//
// Could be replaced with a static check in the future:
// https://github.com/dart-lang/sdk/issues/35024
if (mapper is Future<void> Function(JsonObject)) {
throw ArgumentError.value(
mapper,
'mapper',
'must be synchronous',
);
}
// Store the previous errors, if any.
final List<JsonReadException>? previousErrors = _mapErrors;
// Start collecting errors for this map operation.
final List<JsonReadException> currentErrors = <JsonReadException>[];
_mapErrors = currentErrors;
try {
final T result = mapper(this);
if (currentErrors.isEmpty) {
return result;
} else {
return onError(this, JsonMapException(this, currentErrors));
}
} finally {
// Restore the previous errors.
_mapErrors = previousErrors;
}
}
/// Returns the underlying JSON object as a [Map].
///
/// This method is useful for passing the JSON object to APIs that expect a
/// [Map] type, but it is generally recommended to use the provided methods
/// for type-safe access to the JSON values.
Map<String, Object?> asMap() => _object;
/// Returns a "pretty" JSON representation of this object.
String toPrettyString() {
return const JsonEncoder.withIndent(' ').convert(_object);
}
}
/// An exception thrown when a JSON value cannot be read as the expected type.
sealed class JsonReadException implements Exception {
const JsonReadException(this.object);
/// The JSON object that was being read.
final JsonObject object;
@override
@mustBeOverridden
String toString();
}
/// An exception thrown when read exceptions occur during [JsonObject.map].
final class JsonMapException extends JsonReadException {
/// Creates an exception for multiple JSON read exceptions.
///
/// If [exceptions] is empty, a [StateError] is thrown.
JsonMapException(super.object, Iterable<JsonReadException> exceptions)
: exceptions = List<JsonReadException>.unmodifiable(exceptions) {
if (exceptions.isEmpty) {
throw StateError('Must provide at least one exception.');
}
}
/// The list of exceptions that occurred while mapping the JSON object.
final List<JsonReadException> exceptions;
@override
String toString() {
return 'Failed to read object:\n\n${exceptions.join('\n')}';
}
}
/// An exception thrown when a JSON [key] is not found in the JSON object.
final class MissingKeyJsonReadException extends JsonReadException {
/// Creates an exception for a missing key in a JSON object.
const MissingKeyJsonReadException(super.object, this.key);
/// The key that was being read.
final String key;
@override
String toString() => 'Key "$key" not found.';
}
/// An exception thrown when a JSON [key] is found but the value cannot be cast.
final class InvalidTypeJsonReadException extends JsonReadException {
/// Creates an exception for an invalid type in a JSON object.
const InvalidTypeJsonReadException(
super.object,
this.key, {
required this.expected,
required this.actual,
});
/// The key that was being read.
final String key;
/// The expected type of the value.
final Type expected;
/// The actual type of the value.
final Type actual;
@override
String toString() {
return 'Key "$key" expected type: $expected, actual type: $actual.';
}
}