| // 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.'; |
| } |
| } |