Schemake

Schemake CI pub package

Schemake (schema make) is a package for describing schemas from a declarative specification. Specifications can then be used for validating data, generating code or other schemas like JSON Schema and more.

A Schemake specification is easy to write and is just plain Dart code:

import 'package:schemake/schemake.dart';

const person = Objects('Person', {
  'name': Property(Strings()),
  'age': Property(Nullable(Ints())),
});

With such a schema specification, you can now validate data you receive, for example, from JSON or YAML:

import 'package:schemake/schemake.dart';
import 'dart:convert';

void main() {
  // joe has type Map<String, Object?>, but its "properties", or keys,
  // are guaranteed to follow the "person" schema above.
  final joe = person.convert(jsonDecode('{"name": "Joe"}'));
  print(joe['name']); // prints "Joe"
  print(joe['age']); // prints "null"

  // this will throw (because age has type int, not String):
  // PropertyTypeException{propertyPath: [age], 
  //   cannot cast foo (type String) to int, 
  //   objectType: schemake.Objects ...
  person.convert(jsonDecode('{"name": "Joe", "age": "foo"}'));
}

Data types

Supported data types:

Schemake type Dart type
Ints int
Floats double
Bools bool
Strings String
Arrays(T) List<S>
ObjectsBase<C> C
Objects Map<String, Object?>
Maps(T) Map<String, S>
Nullable(T) S?
Validatable(T) S

In the table above, T stands for some other Schemake type, and S for the Dart type associated with T. ObjectsBase<C> is convertable to some Dart class C, as explained below.

enums are also supported as a special-case of Validatable(Strings) (see the Validatable section).

Maps from String to some type T (for a given Dart type S) are supported with the Maps Schemake type, which is a subtype of ObjectsBase<Map<String, S>>.

All Schemake types implement Dart's Converter<Object?, S> for some Dart type S.

Schemake types are normally declared with const, as they are, semantically, types.

Objects and ObjectsBase

Objects are data structures with a known schema, similar to Dart classes. We've seen an example of Objects earlier, the Person schema.

Calling convert on a Map will convert it to a "pure" data Map<String, Object?> which is guaranteed to match the schema described by the Objects instance.

By "pure data", we mean values whose Dart types are present in the types table shown earlier.

Objects extends ObjectsBase, which can also be used to create schema types that convert to arbitrary Dart data classes (which is normally done by code generation, as explained in the next section), which means that their convert method returns instances of a specific Dart class instead of Map.

As an example, given a simple Dart class:

class MyType {
  final String example;

  const MyType({required this.example});
}

We could specify a Schemake type for it using Objects:

const myTypes = Objects('MyTypes', {
  'example': Property(Strings()),
});

We could also write a ObjectsBase subtype that converts directly to MyType:

class MyTypes extends ObjectsBase<MyType> {
  const MyTypes() : super('MyType');

  @override
  MyType convert(Object? input) {
    final map = input as Map<String, Object?>;
    checkRequiredProperties(map.keys);
    return MyType(
      example: convertProperty(const Strings(), 'example', map),
    );
  }

  @override
  Converter<Object?, Object?>? getPropertyConverter(String property) =>
      switch (property) {
        'example' => const Strings(),
        _ => null,
      };

  @override
  Iterable<String> getRequiredProperties() => const {'example'};
}

The above class can convert a Map<String, Object?> to an instance of MyType while validating its schema.

The dart_gen library (see next section) would produce something similar, but with more complete error handling and functionality (i.e. toString, ==, hashCode, toString, fromString, toJson, fromJson etc.).

To summarize:

Schema type     converts to
ObjectsBase<S>      --->         S
Maps<T>             --->         Map<String, T>
Objects             --->         Map<String, Object?>
   +---- can be used to generate some class S
         and a subclass of ObjectsBase<S> that converts between S and other Map

Semi structured objects

In a Schemake schema, you can represent any Map<String, Object?> (unstructured data) using the following Objects schema:

const maps = Objects('Map', {},
    unknownPropertiesStrategy: UnknownPropertiesStrategy.keep);

With the above schema, the values of the unknown properties are not validated at all, but are kept in the converted object.

UnknownPropertiesStrategy allows the following values:

  • forbid - the default: forbids unknown properties (used for data whose structure is fully known).
  • keep - allow any properties besides the known ones, and keep them in the converted object.
  • ignore - allow any properties besides the known ones, but discard them.

For cases where some properties are known, but not all (which is common in RFCs as this allows extensions to provide meaning to additional properties), it's simple to define a partial schema:

const maps = Objects('PartialSchemaa', {
  'knownProperty': Property(String()),
}, unknownPropertiesStrategy: UnknownPropertiesStrategy.keep);

This schema determines that the knownProperty property must have type String, but allows any other properties with any type to exist. So, the following would be both valid instances of this schema:

  • {'knownProperty': 'good'}
  • {'knownProperty': 'good', 'whatever': true}

But this value would NOT be valid:

  • {'knownProperty': false} --> error: cannot cast false to String

Validatable

Validatable wraps another Schemake type, adding further constraints to values that may be accepted.

For example:

import 'package:schemake/schemake.dart';

const nonBlankStrings = Validatable(Strings(), NonBlankStringValidator());

void main() {
  // OK! Prints "foo"
  print(nonBlankStrings.convert('foo'));

  // ValidationException{errors: [blank string]}
  print(nonBlankStrings.convert('  '));
}

You could now modify the Person schema mentioned earlier to only accept non-blank names:

import 'package:schemake/schemake.dart';

const nonBlankStrings = Validatable(Strings(), NonBlankStringValidator());

const person = Objects('Person', {
  'name': Property<String>(nonBlankStrings),
  'age': Property<int?>(Nullable(Ints())),
});

Other built-in validators are IntRangeValidator and FloatRangeValidator. For example, to only accept integers in the range from 1 to 10:

const range = Property<int>(Validatable(Ints(), IntRangeValidator(1, 10)));

enums

A special case of Strings where the allowed values are all known at compile-time can be modelled as a Validatable(Strings(), EnumValidator(...)), which ensures only certain String values are allowed.

Because this is such a common use case, Schemake provides the Enums type, which makes declaring enums easier:

// the someEnum field is a String whose value must be one of
// "one", "two" or "three"
const typeWithEnumField = Objects('EnumExample', {
  'someEnum': Property(Enums(EnumValidator('SmallInt', {'one', 'two', 'three'}))),
});

The Dart code generator creates an actual Dart enum to represent enum properties, but their serialized type is still String.

Dart code generation

You can generate data classes automatically from Objects schemas using the dart_gen library:

import 'package:schemake/dart_gen.dart' as dg;
import 'package:schemake/schemake.dart';

const person = Objects('Person', {
  'name': Property(Strings()),
  'age': Property(Nullable(Ints())),
});

void main() {
  print(dg.generateDartClasses([person]));
}

The following basic Dart class is generated:

class Person {
  final String name;
  final int? age;

  const Person({
    required this.name,
    this.age,
  });
  @override
  String toString() =>
      'Person{'
          'name: "$name",'
          'age: $age'
          '}';
  @override
  bool operator ==(Object other) =>
      identical(this, other) ||
          other is Person &&
              runtimeType == other.runtimeType &&
              name == other.name &&
              age == other.age;
  @override
  int get hashCode =>
      name.hashCode ^ age.hashCode;
  
  Person copyWith({
    String? name = null,
    int? age = null,
    bool unsetAge = false,
  }) {
    return Person(
      name: name ?? this.name,
      age: unsetAge ? null : age ?? this.age,
    );
  }
}

Nearly everything can be customized. For example, to make the class' fields non-final and the constructor non-const, and only generate the toString method, use the following options:

void main() {
  print(dg.generateDartClasses([someSchema],
      options: const dg.DartGeneratorOptions(
          methodGenerators: [dg.DartToStringMethodGenerator()],
          insertBeforeField: null,
          insertBeforeConstructor: null)));
}

You can also write your own implementations of DartMethodGenerator to generate any other methods.

JSON and YAML

To include toJson, fromJson and other methods, you need to configure the Dart generator with the appropriate DartMethodGenerators:

void main() {
  print(dg.generateDartClasses([person],
      options: const dg.DartGeneratorOptions(methodGenerators: [
        ...dg.DartGeneratorOptions.defaultMethodGenerators,
        dg.DartToJsonMethodGenerator(),
        dg.DartFromJsonMethodGenerator(),
      ])));
}

Now, in addition to toString, hashCode and the == operator, the Person class will also have the JSON methods:

class Person {
  ...;
  
  static Person fromJson(Object? value) => ...;

  Map<String, Object?> toJson() => ...;
}

The toJson method returns a Map in the tradition of Dart, as the dart:core's jsonEncode function automatically calls toJson on any type implementing it... which means serializing Person is as easy as:

void main() {
  // prints {"name": "Joe", "age": 42}
  print(jsonEncode(Person(name: 'Joe', age: 42)));
}

The fromJson method accepts either a:

  • JSON String (in which case jsonEncode is invoked first).
  • List<int> (treated as UTF-8 byte array).
  • Map

The Map can be produced by, for example, jsonDecode or loadYaml:

import 'package:yaml/yaml.dart';

void main() {
  Person aPerson = Person.fromJson(loadYaml('name: Mary'));
  
  // Prints: Person{name: "Mary",age: null}
  print(aPerson);
}

Custom generators

Schemake was designed to make it easy to add more generators.

Currently, only Dart code generation is supported, but JSON Schema will be added soon. I might add a Java generator as well, and hopefully others can contribute more!

TODO show how to create a generator.

Libraries

dart_gen
Dart generation based on Schemake schemas.
schemake
Schemake is a library to describe schemas for data. These schemas are useful on their own as they allow validating JSON/YAML objects and then using them safely dynamically or by manually writing types to convert them without having to perform further validation.