schultek / dart_mappable

Improved json serialization and data classes with full support for generics, inheritance, customization and more.
https://pub.dev/packages/dart_mappable
MIT License
164 stars 23 forks source link

Conversion "Strictness" #241

Open bgintzairspace opened 2 weeks ago

bgintzairspace commented 2 weeks ago

Finding dart_mappable has been great. I am trying to move away from json_serializable/json_annotation to just dart_mappable and I ran into something that I am not sure if I can do.

Basically I want to have a flag that either allows strict or lenient parsing of data between the API (which we control) and the app (which we also control). The way it would work is:

Possible Error Cases 1) There is a key that exists in the api payload that doesn't exist in the object that payload should be mapped to 2) There is a key that exists in the object that doesn't exist in the api payload. 3) There is a value of a different type for a key that exists in both (i.e. object expects bool but payload gives string)

Lenient: Allow for the data to be mapped to the object as much as possible for all 3 cases, but have information available to report on those. So for this we would want to be able to have the development team know that there is an issue where the data expected and sent were not in sync, but give the app an object that is as close to ideal as possible and write the app in such a way to handle that optimistically

Strict: Allow for the data to be me mapped to the object only if it does not contain any of the possible error cases

Configurable: Give the app developer the ability to configure to be able to match any/all of the 3 as they see fit.

It appears that I am not able to do either Lenient or Strict as the code is now because:

Lenient: I could not find a way to let the mapper just ignore the missing field in the api payload, it throws a MapperException and the rest of the object is no longer accessible

Strict: The way mapper_base:decodeValue() works is to allow dart to try to cast the the decoded value (as it is sent) to the object value (as it should be) and only throw an error if dart doesn't like that.

Am I missing something available to me to make the above work? And/Or is this functionality that might be welcome if we were able to do a PR for it?

Thanks!

schultek commented 18 hours ago

Hi @bgintzairspace . Let me try to address the three points separately:

1. Unused keys

Afai understand, this is about having e.g an object Person(String name) and a payload of {"name": "Tom", "age": 24}. In that case the additional keys are just ignored.

If you do want to get an error, you could use the UnmappedPropertiesHook to first get a map of all remaining properties, and then e.g. do an assert(unmappedProps.isEmpty); in your models constructor body.

2. Missing keys

If a key is missing, but the field on the model is required, an error is thrown. This can't be circumvented, since the package has to call your constructor with some value.

Easy way to fix this is make the field nullable, or provide a default value in the constructor, e.g. Person({required this.name, this.age = 18}). Here name would be required and throw when missing, and age would default to 18 when missing.

If you need to do some more logic, you can do that in the constructor body like:

class Person with PersonMappable {
  Person({required this.name, int? age}) {
    if (age == null) log.warn("Age is missing");
    this.age = age ?? 18;
  }

  final String name;
  late final int age;
}

3. Wrong type

This is a bit more tricky. There are two ways to tackle this:

3.1 Use hooks

You can use hooks to validate, transform or do anything to your raw data before decoding a model.

Use hook: on @MappableField for a specific field, or on @MappableClass for the whole model:

class Person with PersonMappable {
  ...

  @MappableField(hook: StringToBoolHook())
  final bool isHuman;
}

class StringToBoolHook extends MappingHook {

  @override
  Object? beforeDecode(Object? value) {
    if (value is String) {
     return value == 'human' || value == 'yes' || value == 'true';
    }
    return value;
  }
}

3.2 Override global mappers

You can also globally override the mappers for any type, even primitive types like bool or String. But this then affects all fields of all models.

void withCustomBoolMapper() {
  // save for later
  final defaultBoolMapper = MapperContainer.globals.get<bool>()!;

  MapperContainer.globals.use<bool>(
    PrimitiveMapper<bool>((Object? v) {
      // decode v to bool
      return v == 'yes';
   }),
  );

  /* here do something, decode models etc. this will use your custom mapper */

  // reset to default
  MapperContainer.globals.use<bool>(defaultBoolMapper);
}