If you're an Elm developer writing Haskell and you like elm/json, you might like dwayne/hs-json-codec.
hs-json-codec provides JSON decoders and encoders for Haskell in the style of elm/json. It's built on dwayne/hs-json-parser, a general-purpose JSON parser and printer that I also wrote.
I built both libraries because I wanted them for dwayne/elm2nix, but they can be used in any Haskell project. In this post, I'll introduce their APIs and show how I use them in elm2nix.
Decoding JSON
Let's start with a small example. Given this Haskell type:
data User
= User
{ name :: Text
, age :: Int
, email :: Maybe Text
}
deriving (Eq, Show)
You can build a decoder by combining smaller decoders:
import qualified Json.Decode as JD
import Json.Decode (Decoder)
userDecoder :: Decoder User
userDecoder =
User
<$> JD.field "name" JD.text
<*> JD.field "age" JD.integral
<*> JD.field "email" (JD.nullable JD.text)
If you've used Json.Decode in Elm, this should look familiar. A Decoder User knows how to convert a JSON value into a User, or describe why it could not.
You can run the decoder against Text:
JD.decodeText userDecoder "{\"name\": \"Douglas\", \"age\": 71, \"email\": null}"
-- Right (User {name = "Douglas", age = 71, email = Nothing})
You can also run a decoder against an already-parsed JSON value or use one to decode a file:
decodeValue :: Decoder a -> Json -> Either DecodeError a
decodeFile :: Decoder a -> FilePath -> IO (Either Error a)
The two error types are deliberate: decodeValue starts from an already-parsed JSON value, so only decoding can fail (DecodeError), while decodeFile has to parse first, so its Error also covers parse failures.
Decoding JSON in elm2nix
Decoding elm.json
elm2nix accepts one or more elm.json files and uses their dependencies to produce an elm.lock file. Here is the decoder it uses for an Elm application:
elmJsonDecoder :: JD.Decoder ElmJson
elmJsonDecoder =
JD.field "type" (literal "application") >>
((\a b c d -> fromList $ a ++ b ++ c ++ d)
<$> pathToDependenciesDecoder [ "dependencies", "direct" ]
<*> pathToDependenciesDecoder [ "dependencies", "indirect" ]
<*> pathToDependenciesDecoder [ "test-dependencies", "direct" ]
<*> pathToDependenciesDecoder [ "test-dependencies", "indirect" ])
The structure is very similar to the decoders I would write in Elm. It first checks that type is "application", then decodes the four collections of dependencies and combines them.
pathToDependenciesDecoder handles paths that may be absent:
pathToDependenciesDecoder :: [Text] -> JD.Decoder [Dependency]
pathToDependenciesDecoder path =
fmap (fromMaybe []) (JD.optionalAt path dependenciesDecoder)
Finally, dependenciesDecoder converts the keys of a JSON object into package names and its values into versions:
dependenciesDecoder :: JD.Decoder [Dependency]
dependenciesDecoder =
JD.keyValuePairsWithKeyTransformer toName JD.decoder >>= JD.succeed . map (uncurry Dependency)
(JD.decoder here is Version's canonical decoder — more on FromJson in the next section.)
See the complete ElmJson decoder.
The decoder is used by fromFile:
fromFile :: FilePath -> IO (Either JD.Error ElmJson)
fromFile = JD.decodeFile elmJsonDecoder
That function is then used by ElmJson.fromFiles, which decodes every supplied elm.json file and combines their dependencies.
Decoding elm.lock
elm2nix uses the same approach to decode its generated lock file. elmLockDecoder combines list, field, text, and custom version decoders:
elmLockDecoder :: JD.Decoder ElmLock
elmLockDecoder = fromList <$> JD.list dependencyDecoder
dependencyDecoder :: JD.Decoder Dependency
dependencyDecoder = Dependency <$> nameDecoder <*> versionDecoder
One thing I wanted was the ability to give a type its canonical decoder. FromJson provides that:
class FromJson a where
decoder :: Decoder a
For example, Version implements FromJson, so other decoders can use JD.decoder without needing to know how a version is represented in JSON. Its custom decoder still has the familiar shape of decoding a primitive value and then validating it with succeed or fail.
Encoding JSON
Encoders are functions from Haskell values to JSON values:
encodeUser :: User -> Json
encodeUser (User name age email) =
JE.object
[ ( "name", JE.text name )
, ( "age", JE.int age )
, ( "email", JE.nullable JE.text email )
]
Again, this follows the way Json.Encode works in Elm.
ToJson lets a type provide its canonical encoding:
class ToJson a where
encode :: a -> Json
Encoding JSON in elm2nix
elm2nix uses ToJson to encode each fixed-output derivation written to elm.lock:
instance ToJson FixedOutputDerivation where
encode (FixedOutputDerivation (Dependency name version) hash _) =
JE.object
[ ( "author", encode $ Name.toAuthor name )
, ( "package", encode $ Name.toPackage name )
, ( "version", encode $ T.show version )
, ( "sha256", encode $ T.pack hash )
]
See the source.
The list of derivations is then encoded and written in either compact or pretty-printed form by encodeCompact and encodeExpanded.
elm2nix also uses both libraries to convert Elm's binary registry.dat format into compact or pretty-printed JSON. See viewRegistryDatFile.
Parsing and printing JSON
You can also use hs-json-parser directly when you only need to parse or print JSON.
To make sure hs-json-parser handles JSON correctly, I tested it against nst/JSONTestSuite. It is compliant with RFC 8259.
Json.parse "[true, false, true]"
-- Right (Array [ Boolean True, Boolean False, Boolean True ])
Once you have a JSON value, you can print it in either compact or pretty-printed form:
import qualified Data.Text.IO as TIO
value = Array [ Boolean True, Boolean False, Boolean True ]
TIO.putStrLn $ Json.compact value
-- [true,false,true]
TIO.putStrLn $ Json.pretty 4 value
-- [
-- true,
-- false,
-- true
-- ]
What about performance?
I focused on the API, correctness, and user experience rather than optimizing performance. I haven't benchmarked these libraries against the established Haskell JSON libraries.
However, I haven't encountered any performance problems while using them in elm2nix, so they are at least good enough for that project.
If performance is critical to your application, you should benchmark them against your workload before choosing them.
Try them
Both libraries are currently distributed through their Git repositories rather than Hackage. Their READMEs explain how to add them to a Cabal project:
If you're an Elm developer writing Haskell, consider giving hs-json-codec a try. I hope it makes working with JSON in Haskell feel a little more familiar.
Enjoy!
Top comments (0)