A collection of things I've found useful when developing against third-party/vendor/non-haskell JSON APIs.
To demonstrate how this can be useful we'll work through a real-world example: consuming the PokéAPI, cos Pokémon is life. Specifically we want to be able to query information about a Pokémon by name.
But first, the obligatory preamble:
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE DerivingVia #-}
{-# LANGUAGE TypeApplications #-}
import GHC.Generics (Generic)
import Data.Proxy (Proxy(..))
import Data.Text (Text)
import Data.ByteString.Lazy (ByteString)
import qualified Network.HTTP.Client as Client
import qualified Network.HTTP.Client.TLS as Client
import qualified Data.Aeson as Aeson
And here are the imports that we're interested in:
import Data.Aeson.ObjectLike (ObjectLike(..), Prop(..))
import Data.Aeson.EnumLike (EnumLike(..))
import Data.Aeson.SomethingLike (SomethingLike)
We need to model the Pokémon
object returned by the API.
To get a working FromJSON
instance you would typically do one of the following:
- Write the instance by hand.
- Align your record selector names with the keys of the expected object, so the default generic instance Just Works.
- Pass some custom options to
genericParseJSON
to map the expected object keys to nice haskell record selectors.
But for any non-trivial project these can all become annoying. Having lots of hand-written
instances is noisy and unwieldy. Using object keys (e.g. id
, type
...) as
record selectors quickly leads to name clashes and the dreaded
-XDuplicateRecordFields
. And fiddling with genericParseJSON
options can
be obscure and hard to debug.
I would rather have the logic for mapping selectors to object keys defined
alongside the type. Introducing ObjectLike
...
data Pokemon
= Pokemon
{ pokemonName :: Prop "name" Text
, pokemonId :: Prop "id" Int
, pokemonTypes :: Prop "types" [PokemonType]
}
deriving (Generic, Show)
deriving (Aeson.ToJSON, Aeson.FromJSON) via (ObjectLike Pokemon)
ObjectLike a
has ToJSON
and FromJSON
instances if a
is essentially
a product of Prop
types.
Prop
is a newtype that carries a type-level string, where that string
corresponds to a key in the expected JSON object.
data PokemonType
= PokemonType (Prop "slot" Int) (Prop "type" (NamedAPIResource Type))
deriving (Generic, Show)
deriving (Aeson.ToJSON, Aeson.FromJSON) via (ObjectLike PokemonType)
data NamedAPIResource a
= NamedAPIResource
{ resourceName :: Prop "name" a
, resourceUrl :: Prop "url" Text
}
deriving (Generic, Show)
deriving (Aeson.ToJSON, Aeson.FromJSON) via (ObjectLike (NamedAPIResource a))
Often when working with JSON APIs we'll want to decode a string into a nice sum
type. The issue here is that the logic for mapping strings to constructors is
split across two separate ToJSON
and FromJSON
instances. And writing those
instances is mechanical and tedious.
We can instead associate each constructor with a type-level string and use that
informating to generically derive ToJSON
and FromJSON
instances.
data Type
= FireType (Proxy "fire")
| PsychicType (Proxy "psychic")
| GroundType (Proxy "ground")
-- ...etc
deriving (Generic, Show)
deriving (Aeson.ToJSON, Aeson.FromJSON) via (EnumLike Type)
If you're only decoding part of the expected data (intentionally or unintentionally) you might want to keep the original data around.
decodePokemonResponse
:: Client.Response ByteString -> Either String (SomethingLike Pokemon)
decodePokemonResponse = Aeson.eitherDecode . Client.responseBody
main :: IO ()
main = do
manager <- Client.newManager Client.tlsManagerSettings
request <- Client.parseRequest "GET https://pokeapi.co/api/v2/pokemon/ditto"
response <- Client.httpLbs request manager
print $ decodePokemonResponse response