typeable/schematic

Name: schematic

Owner: TypeableIO

Description: type-safe JSON spec and validation tool

Created: 2017-05-22 13:26:10.0

Updated: 2017-12-11 03:02:18.0

Pushed: 2018-01-11 10:50:13.0

Homepage:

Size: 131

Language: Haskell

GitHub Committers

UserMost Recent Commit# Commits

Other Committers

UserEmailMost Recent Commit# Commits

README

schematic

Build Status

Goal

The goal of the library is to provide a type-safe transport layer for serializing and validating JSON. It can be thought of as a subset of json-schema, which is basically a specification of a JSON document. The other goal is getting as much as possible from this specification for free. Right now the following bits are prototyped:

Be aware that library is experimental and subject to change a lot. The current state can be viewed as a prototype.

Installation
ack install schematic
GHC Extensions

To use this library without any hassle, you should add a few GHC extension either to a module or a cabal file:

Kinds
loadedLists
loadedStrings
Applications

Overloaded-extensions are being used only by field combinator, so it you don't use it - feel free to disable it.

GHC Options

I recommend using {-# OPTIONS_GHC -fno-warn-unticked-promoted-constructors #-} pragma in modules which declare schemas and schema migrations, because schematic heavily uses promoted types which should be prefixed with “`” signs and they can be dropped with this option, making it less noisy visually. It's entirely optional and totally opinionated, so feel free to ignore this. Through the documentation, the code is written without any explicit ticks for the same reason. Notice that they're still needed for type-level lists and tuples in schematic code.

Basic Examples
 SchemaExample
SchemaObject
'[ '("foo", SchemaArray '[AEq 1] (SchemaNumber '[NGt 10]))
 , '("bar", SchemaOptional (SchemaText '[TEnum '["foo", "bar"]]))]

This one is valid for example

maJson :: ByteString
maJson = "{\"foo\": [13], \"bar\": null}"

Also valid

maJson :: ByteString
maJson = "{\"foo\": [13], \"bar\": \"bar\"}"

it can be parsed and validated like this:

deAndValidateJson schemaJson :: ParseResult (JsonRepr SchemaExample)

ParseResult type encodes three possible situations:

It implies a transport layer representation of that data that can be traversed and transformed to whatever internal types user has. It's called JsonRepr. Type parameter is the schema itself.

Example :: JsonRepr SchemaExample
Example = withRepr @SchemaExample
  field @"foo" [12]
& field @"bar" (Just "bar")
& RNil

The most basic and sugar-free way of constructing the same object will look like this:

Example' :: JsonRepr SchemaExample
Example' = ReprObject $
eldRepr (ReprArray [ReprNumber 12])
:& FieldRepr (ReprOptional (Just (ReprText "bar")))
:& RNil
Lens-compatibility

It's possible to use flens to construct a field lens for a typed object in schematic. Let's suppose you have a schema like this:

 ArraySchema = SchemaArray '[AEq 1] (SchemaNumber '[NGt 10])

 ArrayField = '("foo", ArraySchema)

 FieldsSchema =
 ArrayField, '("bar", SchemaOptional (SchemaText '[TEnum '["foo", "bar"]]))]

 SchemaExample = 'SchemaObject FieldsSchema

There are two ways of working with named fields in the objects:

Migrations

Usually migrations are implicit and hard to deal with, schematic deals with it by giving tools to deal with it to a programmer, being as explicit as possible, notifying the developer of a missing transition with a compile-time error. It allows to build versioned HTTP APIs or migrate data from the older versions when reading from the JSON storages.

It's possible to represent schema changes as a series of migrations, which describes a series of json-path/change pairs. Migrations can be applied in succession.

This piece of code will apply a migration to the schema, decode and validate the latest version.

 SchemaExample
SchemaObject
'[ '("foo", SchemaArray '[AEq 1] (SchemaNumber '[NGt 10]))
 , '("bar", SchemaOptional (SchemaText '[TEnum '["foo", "bar"]]))]

 TestMigration =
igration "test_revision"
'[ Diff '[ PKey "bar" ] (Update (SchemaText '[]))
 , Diff '[ PKey "foo" ] (Update (SchemaNumber '[])) ]

 VS = 'Versioned SchemaExample '[ TestMigration ]

maJsonTopVersion :: ByteString
maJsonTopVersion = "{ \"foo\": 42, \"bar\": \"bar\" }"

It's possible to decode the latest version like this:

deAndValidateJson schemaJsonTopVersion :: ParseResult (JsonRepr (TopVersion (AllVersions VS)))

It's important to differentiate between schema migrations and data migrations. Schema migration is a change of a schema acceptable by a JSON consumer. It transforms one schema to another. Data migrations on the other hand are functions on the data itself. They transform values acceptable by one schema to values acceptable by another. Let's look at the situation when we have to add a field to a JSON Object of a current schema:

 SchemaExample = 'SchemaObject
 '("foo", 'SchemaArray '[ 'AEq 1] ('SchemaNumber '[ 'NGt 10]))
 '("bar", 'SchemaOptional ('SchemaText '[ 'TEnum '["foo", "bar"]]))]

Example :: JsonRepr SchemaExample
Example = withRepr @SchemaExample
 field @"bar" (Just "bar")
 field @"foo" [12]
 RNil

 AddQuuz =
igration "add_field_quuz"
[ 'Diff '[] ('AddKey "quuz" (SchemaNumber '[])) ]

 DeleteQuuz =
igration "remove_field_quuz"
'[ 'Diff '[] ( 'DeleteKey "quuz") ]

 Migrations = '[ AddQuuz
               , DeleteQuuz ]

 VersionedJson = 'Versioned SchemaExample Migrations

ationList :: MigrationList Identity VersionedJson
ationList
  (migrateObject (\r -> Identity $ field @"quuz" 42 :& r))
& shrinkObject
& MNil

In this instance Migrations is a list of schema migrations and MigrationList Identity VersionedJson is a list of data migrations corresponding to the list of schema migrations: migrateObject (\r -> Identity $ field @"quuz" 42 :& r)) will be used at runtime to transform JsonRepr SchemaExample to JsonRepr (SchemaByRevision "add_quuz" VersionedJson).Identityin the type of MigrationListis a monad we choose to run our migrations in. If there's need to do database queries or something like that, it can be changed to something more appropriate by a user. migrateObjecttakes a function transforming old fields into new ones,shrinkObject` allows to shrink the JSON object in case migration only removed fields.

How do I construct a value of JsonRepr schema?

JsonRepr schema is a primary representation of a value serializable/deserializable to JSON, but with a twist. It's guaranteed to correspond a type-level schema, it's a type parameter of a same name. It makes constructing it a little bit more involved, but luckily schematic provides a DSL for doing so in a more straightforward fashion. An example would look like this:

 SchemaExample = 'SchemaObject
 '("foo", SchemaArray '[ AEq 1] (SchemaNumber '[NGt 10]))
 '("bar", SchemaOptional (SchemaText '[TEnum '["foo", "bar"]]))]

Example = withRepr @SchemaExample
  field @"bar" (Just "bar")
& field @"foo" [12]
& RNil

@foo syntax is an explicit type application, which is a feature of GHC 8+, it makes type inference possible without relying on a bunch of Proxys, which makes it syntactically more terse.

GHC Type Applications

schematic provides instances for OverloadedLists and OverloadedStrings GHC extensions to make use of string and list literals for values in a previous example.

Export to json-schema (draft 4)

This work is supported by the National Institutes of Health's National Center for Advancing Translational Sciences, Grant Number U24TR002306. This work is solely the responsibility of the creators and does not necessarily represent the official views of the National Institutes of Health.