BSD-3-Clause licensed and maintained by Brandon Chinn
This version can be pinned in stack with:aeson-schemas-1.4.0.0@sha256:ac25fb571f497e78d67377fb4c543a38af5ff68cebd922acd43f6e3a8e57399b,5777

aeson-schemas

GitHub Actions codecov Hackage

A library that extracts information from JSON input using type-level schemas and quasiquoters, consuming JSON data in a type-safe manner. Better than aeson for decoding nested JSON data that would be cumbersome to represent as Haskell ADTs.

Quickstart

{-# LANGUAGE DataKinds #-}
{-# LANGUAGE QuasiQuotes #-}

import Data.Aeson (eitherDecodeFileStrict)
import Data.Aeson.Schema
import qualified Data.Text as T

-- First, define the schema of the JSON data
type MySchema = [schema|
  {
    users: List {
      id: Int,
      name: Text,
      age: Maybe Int,
      enabled: Bool,
      groups: Maybe List {
        id: Int,
        name: Text,
      },
    },
  }
|]

main :: IO ()
main = do
  -- Then, load data from a file
  obj <- either fail return =<<
    eitherDecodeFileStrict "examples/input.json" :: IO (Object MySchema)

  -- print all the users' ids
  print [get| obj.users[].id |]

  flip mapM_ [get| obj.users |] $ \user -> do
    -- for each user, print out some information
    putStrLn $ "Details for user #" ++ show [get| user.id |] ++ ":"
    putStrLn $ "* Name: " ++ T.unpack [get| user.name |]
    putStrLn $ "* Age: " ++ maybe "N/A" show [get| user.age |]
    case [get| user.groups |] of
      Nothing -> putStrLn "* No groups"
      Just groups -> putStrLn $ "* Groups: " ++ show groups

Features

Type safe

Since schemas are defined at the type level, extracting data from JSON objects is checked at compile-time, meaning that using the get quasiquoter should never throw an error at runtime.

-- using schema from above
>>> [get| obj.users[].isEnabled |]

<interactive>:1:6: error:
    • Key 'isEnabled' does not exist in the following schema:
      '[ '("id", 'Data.Aeson.Schema.SchemaInt),
         '("name", 'Data.Aeson.Schema.SchemaText),
         '("age",
           'Data.Aeson.Schema.SchemaMaybe 'Data.Aeson.Schema.SchemaInt),
         '("enabled", 'Data.Aeson.Schema.SchemaBool),
         '("groups",
           'Data.Aeson.Schema.SchemaMaybe
             ('Data.Aeson.Schema.SchemaList
                ('Data.Aeson.Schema.SchemaObject
                   '[ '("id", 'Data.Aeson.Schema.SchemaInt),
                      '("name", 'Data.Aeson.Schema.SchemaText)])))]
    • In the second argument of ‘(.)’, namely ‘getKey (Proxy :: Proxy "isEnabled")’
      In the first argument of ‘(<$:>)’, namely
        ‘(id . getKey (Proxy :: Proxy "isEnabled"))’
      In the first argument of ‘(.)’, namely
        ‘((id . getKey (Proxy :: Proxy "isEnabled")) <$:>)’

Point-free definitions

You can also use the get quasiquoter to define a pointfree function:

getNames :: Object MySchema -> [Text]
getNames = [get| .users[].name |]

If you’d like to extract intermediate schemas, you can use the unwrap quasiquoter:

type User = [unwrap| MySchema.users[] |]

getUsers :: Object MySchema -> [User]
getUsers = [get| .users[] |]

groupNames :: User -> Maybe [Text]
groupNames = [get| .groups?[].name |]

Advantages over aeson

JSON keys that are invalid Haskell field names

aeson does a really good job of encoding and decoding JSON data into Haskell values. Most of the time, however, you don’t deal with encoding/decoding data types manually, you would derive Generic and automatically derive FromJSON. In this case, you would match the constructor field names with the keys in the JSON data. The problem is that sometimes, JSON data just isn’t suited for being defined as Haskell ADTs. For example, take the following JSON data:

{
    "id": 1,
    "type": "admin",
    "DOB": "5/23/90"
}

The FromJSON instance for this data is not able to be automatically generated from Generic because the keys are not valid/ideal field names in Haskell:

data Result = Result
  { id :: Int
    -- ^ `id` shadows `Prelude.id`
  , type :: String
    -- ^ `type` is a reserved keyword
  , DOB :: String
    -- ^ fields can't start with an uppercase letter
  } deriving (Generic, FromJSON)

The only option is to manually define FromJSON – not a bad option, but less than ideal.

With this library, you don’t have these limitations:

type Result = [schema|
  {
    id: Int,
    type: Text,
    DOB: Text,
  }
|]

Nested data

What about nested data? If we wanted to represent nested JSON data as Haskell data types, you would need to define a Haskell data type for each level.

{
    "permissions": [
        {
            "resource": {
                "name": "secretdata.txt",
                "owner": {
                    "username": "[email protected]"
                }
            },
            "access": "READ"
        }
    ]
}
data Result = Result
  { permissions :: [Permission]
  } deriving (Show, Generic, FromJSON)

data Permission = Permission
  { resource :: Resource
  , access :: String
  } deriving (Show, Generic, FromJSON)

data Resource = Resource
  { name :: String
  , owner :: Owner
  } deriving (Show, Generic, FromJSON)

data Owner = Owner
  { username :: String
  } deriving (Show, Generic, FromJSON)

It might be fine for a single example like this, but if you have to parse this kind of data often, it’ll quickly become cumbersome defining multiple data types for each JSON schema. Additionally, the namespace becomes more polluted with each data type. For example, if you imported all four of these data types, you wouldn’t be able to use name, username, resource, etc. as variable names, which can become a pain.

Compared with this library:

type Result = [schema|
  {
    permissions: List {
      resource: {
        name: Text,
        owner: {
          username: Text,
        },
      },
      access: Text,
    }
  }
|]

The only identifier added to the namespace is Result, and extracting data is easier and more readable:

-- without aeson-schemas
map (username . owner . resource) . permissions

-- with aeson-schemas
[get| result.permissions[].resource.owner.username |]

Duplicate JSON keys

Maybe you have nested data with JSON keys reused:

{
    "_type": "user",
    "node": {
        "name": "John",
        "groups": [
            {
                "_type": "group",
                "node": {
                    "name": "Admin",
                    "writeAccess": true
                }
            }
        ]
    }
}

This might be represented as:

data UserNode = UserNode
  { _type :: String
  , node :: User
  }

data User = User
  { name :: String
  , groups :: [GroupNode]
  }

data GroupNode = GroupNode
  { _type :: String
  , node :: Group
  }

data Group = Group
  { name :: String
  , writeAccess :: Bool
  }

Here, _type, name, and node are repeated. This works with {-# LANGUAGE DuplicateRecordFields #-}, but you wouldn’t be able to use the accessor function anymore:

>>> node userNode

<interactive>:1:1: error:
    Ambiguous occurrence 'node'
    It could refer to either the field 'node',
                             defined at MyModule.hs:3:5
                          or the field 'node', defined at MyModule.hs:13:5

So you’d have to pattern match out the data you want:

let UserNode{node = User{groups = userGroups}} = userNode
    groupNames = map (\GroupNode{node = Group{name = name}} -> name) userGroups

With this library, extraction is much more straightforward

let groupNames = [get| userNode.node.groups[].node.name |]

Changes

v1.4.0.0

  • Drop support for GHC < 8.10
  • Drop support for megaparsec < 7

v1.3.5.1

  • Fix benchmarks for aeson-2

v1.3.5

  • Support aeson-2.0.0.0

v1.3.4

  • Support template-haskell-2.17.0.0 for GHC 9

v1.3.3

  • Fix test failure in newer Stack snapshots

v1.3.2

Performance:

  • Optimized including other schemas in a schema, which previously caused a huge slowdown, and possibly even out-of-memory errors.

v1.3.1

Bug fixes:

  • Update extra-source-files with files needed for testing

v1.3.0

Breaking changes:

  • Refactored types to be correct by construction. Namely, the schema parameter in Object schema now has kind Schema instead of SchemaType, which prevents the possibility of a non-object schema stored in an Object. This means that any schemas previously annotated with the SchemaType kind should now be annotated as Schema.
  • Instead of using IsSchemaObject is obviated because of this change, so it’s been removed. You may use the new IsSchema instead, if you need it.
  • SchemaResult has been removed from the export list of Data.Aeson.Schema. You probably won’t need this in typical usage of this library, but if you need it, you can always get it from Data.Aeson.Schema.Internal.

New features:

  • Add support for unwrapping into included schemas
  • Add toMap
  • Re-export showSchema in Data.Aeson.Schema

Bug fixes:

  • Avoid requiring TypeApplications when using get quasiquoter (#16)
  • Allow optional quotes around keys, both in getter-expressions and in schema definitions
  • Allow // at the beginning of phantom keys (were previously parsed as comments)

Performance:

  • We’ve added benchmarks! To view performance metrics, you can clone the repo and run stack bench. You may also view the benchmark statistics in CI, but due to Circle CI’s memory limitations, we’re forced to run them with --fast, so it’ll be a factor slower than it would actually be at runtime.

  • Fixed the Show instance from being O(n^2) to O(n), where n is the depth of the object.

  • In order to fix some bugs and implement new features, the schema quasiquoter took a performance hit. The biggest slowdown occurs if you’re including other schemas like:

    {
        user: #UserSchema
    }
    

    If this causes your build to be noticeably slower, please open an issue. Thanks!

Miscellaneous changes:

  • The Show instance for objects added some whitespace, from {"foo": 0} to { "foo": 0 }

v1.2.0

New features:

  • Add support for phantom keys
  • Add support for Try schemas

v1.1.0

New features:

  • Added support for unions
  • Added ToJSON instance for enums generated with mkEnum

v1.0.3

Support GHC 8.8

v1.0.2

Bundle test data files in release tarball

v1.0.1

Add support with first-class-families-0.6.0.0

v1.0.0

Initial release:

  • Defining JSON schemas with the schema quasiquoter
  • Extract JSON data using the get quasiquoter
  • Extracting intermediate schemas with the unwrap quasiquoter
  • Include mkGetter helper function for generating corresponding get and unwrap expressions.