aeson-schemas
Easily consume JSON data on-demand with type-safety
https://github.com/brandonchinn178/aeson-schemas#readme
Version on this page: | 1.4.2.1 |
LTS Haskell 22.33: | 1.4.2.1@rev:1 |
Stackage Nightly 2024-09-07: | 1.4.2.1@rev:1 |
Latest on Hackage: | 1.4.2.1@rev:1 |
aeson-schemas-1.4.2.1@sha256:926a2bc941324259170bf0bcf3acee77356a2ffcbf36886519a36586a3a7e29e,5246
Module documentation for 1.4.2.1
- Data
aeson-schemas
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.2.1
- Fix benchmarks for GHC 9.8
v1.4.2.0
- Drop support for GHC 8.10 + 9.0 + 9.2
- Add support for GHC 9.8
v1.4.1.0
- Add support for GHC 9.6
v1.4.0.1
- Add support for GHC 9.4
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 inObject schema
now has kindSchema
instead ofSchemaType
, which prevents the possibility of a non-object schema stored in anObject
. This means that any schemas previously annotated with theSchemaType
kind should now be annotated asSchema
. - Instead of using
IsSchemaObject
is obviated because of this change, so it’s been removed. You may use the newIsSchema
instead, if you need it. SchemaResult
has been removed from the export list ofData.Aeson.Schema
. You probably won’t need this in typical usage of this library, but if you need it, you can always get it fromData.Aeson.Schema.Internal
.
New features:
- Add support for unwrapping into included schemas
- Add
toMap
- Re-export
showSchema
inData.Aeson.Schema
Bug fixes:
- Avoid requiring
TypeApplications
when usingget
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 beingO(n^2)
toO(n)
, wheren
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 withmkEnum
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 correspondingget
andunwrap
expressions.