packages feed

vinyl-0.10.0: tests/Aeson.hs

{-# LANGUAGE CPP, DataKinds, DeriveGeneric, FlexibleContexts,
             FlexibleInstances, GADTs, OverloadedStrings, PolyKinds,
             ScopedTypeVariables, TypeApplications, TypeOperators,
             ViewPatterns #-}
{-# OPTIONS_GHC -fno-warn-orphans #-}
-- | Demonstrate encoding a 'Rec' to JSON. Three approaches are shown:
-- the first utilizes 'ToJSON' instances for the record's
-- interpretation type constructor applied to each of its fields. This
-- has the advantage of being concise by virtue of re-using a lot of
-- existing pieces. The downside to relying on existing 'ToJSON'
-- instances is that they encode self-contained JSON values, when what
-- we want to do is construct a single JSON object encompassing each
-- record field as a named field of that JSON object. We can do this
-- by inspecting the JSON serialization of each field, and extracting
-- it as a key-value pair if it was serialized as a JSON object with a
-- single named field. This works, but means that the types do not
-- guarantee correctness (i.e. if a record field is serialized as a
-- 'Number', we won't be able to include it in the serialization of
-- the 'Rec').
--
-- The second approach uses a bit of @aeson@ internals to instead
-- serialize each 'Rec' field as a key-value pair with no additional
-- decoration. This should be faster as well as more tightly typed
-- since we do not need to undo any 'Value' wrapping of the individual
-- record fields.
--
-- The third approach uses aeson's built-in functions for working with
-- 'Generic'. This requires some post-processing to address precisely
-- the above problem: the function based on 'Generic' ends up
-- producing a self-contained 'Value' for each field of the
-- record. Specifically, each field becomes an 'Array' that is either
-- empty or contains an 'Object' with a single field as well as
-- another, nested, 'Array' for the rest of the record. We include
-- here a function to flatten that recursive structure into the
-- 'Object' shape we want.
import Control.Lens (view, deep)
import Control.Monad.State.Strict
import qualified Data.HashMap.Strict as H
#if __GLASGOW_HASKELL__ < 804
import Data.Semigroup ((<>))
#endif
import Data.Text (Text)
import qualified Data.Text as T
import qualified Data.Vector as V
import Data.Vinyl
import Data.Vinyl.Class.Method (RecMapMethod1(..))
import Data.Vinyl.Functor (Compose(..), (:.), Identity(..), Const(..))
import Data.Aeson
import Data.Aeson.Encoding.Internal (wrapObject, pair)
import Data.Aeson.Lens (_Object)
import GHC.Generics (Generic, Rep)
import GHC.TypeLits (KnownSymbol)
import Test.Hspec

-- * Implementing 'ToJSON' for 'Rec'

-- | An 'Identity' functor is not reflected in a value's JSON
-- serialization.
instance ToJSON a => ToJSON (Identity a) where
  toJSON (Identity x) = toJSON x

-- | A named field serializes to a JSON object with a single named
-- field.
instance ToJSON a => ToJSON (ElField '(s,a)) where
  toJSON x = object [(T.pack (getLabel x), toJSON (getField x))]

-- | A @((Text,) :. f) a@ value maps to a JSON field whose name is the
-- 'Text' value, and whose value has type @f a@.
instance ToJSON (f a) => ToJSON ((((,) Text) :. f) a) where
  toJSON (Compose (name, x)) = object [(name, toJSON x)]

-- | Replace each field of a record with the result of serializing it
-- to a JSON 'Value', and then extracting that 'Value''s single named
-- field. If the serialization is not in the form of an object with a
-- single field, the conversion fails with a 'Nothing'.
fieldsToJSON :: (RecMapMethod1 ToJSON f rs)
             => Rec f rs -> Rec (Maybe :. Const (Text,Value)) rs
fieldsToJSON = rmapMethod1 @ToJSON (Compose . aux)
  where aux x = case toJSON x of
                  Object (H.toList -> [field]) -> Just (Const field)
                  _ -> Nothing

-- | Convert a homogeneous record to a list factored through an outer
-- functor. A useful specialization is when the outer functor is
-- 'Maybe': if any field is 'Nothing', then the result of this
-- function is 'Nothing'.
recToListF :: (Applicative f, RFoldMap rs) => Rec (f :. Const a) rs -> f [a]
recToListF = fmap (rfoldMap (pure . getConst)) . rtraverse getCompose

instance (RFoldMap rs, RecMapMethod1 ToJSON f rs)
  => ToJSON (Rec f rs) where
  toJSON = maybe err object . recToListF . fieldsToJSON
    where err = error (unlines [ "The interpretation functor of this "
                               , "record did not produce a named field "
                               , "for at least one of its fields." ])

-- * Naming anonymous fields

-- | Pair each record field with its position.
recIndexed :: Rec f rs -> Rec ((,) Int :. f) rs
recIndexed = flip evalState 1 . rtraverse aux
  where aux x = do i <- get
                   Compose (i,x) <$ put (i+1)

-- | A helper to pair each field of a record with a name derived from
-- its position in the record. This reflects the implicit ordering of
-- the type-level list of the record's fields.
nameFields :: RMap rs => Rec f rs -> Rec ((,) Text :. f) rs
nameFields = rmap aux . recIndexed
  where aux (Compose (i,x)) = Compose ("field"<>T.pack (show i), x)

-- * Test Cases

r1 :: Rec ElField '[("age" ::: Int), ("iscool" ::: Bool), ("yearbook" ::: Text)]
r1 = xrec (23, True, "You spin me right round")

r1JSON :: Value
r1JSON = object [ "age" .= (23 :: Int)
                , "iscool" .= True
                , "yearbook" .= ("You spin me right round" :: Text) ]

r2 :: Rec Identity '[Int,Bool,Text]
r2 = xrec (23, True, "You spin me right round")

r2JSON :: Value
r2JSON = object [ "field1" .= (23 :: Int)
                , "field2" .= True
                , "field3" .= ("You spin me right round" :: Text) ]

-- | A type with its own JSON Object encoding
data MyType = MyType { bike :: Bool, skateboard :: Bool } deriving Generic
instance ToJSON MyType

r3 :: Rec ElField '[ "age" ::: Int
                   , "iscool" ::: Bool
                   , "yearbook" ::: Text
                   , "hobbies" ::: MyType ]
r3 = xrec (23, True, "You spin me right round", MyType True True)

r3JSON :: Value
r3JSON = object [ "age" .= (23 :: Int)
                , "iscool" .= True
                , "yearbook" .= ("You spin me right round" :: Text)
                , "hobbies" .= object ["bike" .= True, "skateboard" .= True] ]

main :: IO ()
main = hspec $ do
  describe "Simple Rec to JSON" $ do
    it "Named fields" $
      toJSON r1 `shouldBe` r1JSON
    it "Anonymous fields" $
      toJSON (nameFields r2) `shouldBe` r2JSON
    it "Nested objects" $
      toJSON r3 `shouldBe` r3JSON
  describe "Type-safe Rec to JSON" $ do
    it "Named fields" $
      recToJSON r1 `shouldBe` r1JSON
    it "Anonymous fields" $
      recToJSON (nameFields r2) `shouldBe` r2JSON
    it "Nested objects" $
      recToJSON r3 `shouldBe` r3JSON
  describe "Via Generics" $ do
    it "Named fields" $
      grecToJSON r1 `shouldBe` r1JSON
    it "Anonymous fields" $
      grecToJSON (nameFields r2) `shouldBe` r2JSON
    it "Nested objects" $
      grecToJSON r3 `shouldBe` r3JSON

-- * More type safe and efficient

-- | Produce a JSON key-value pair from a Haskell value. This is what
-- we want from each field of our records. The simple encoding above
-- that treats each record field as a self-contained JSON 'Value'
-- loses precision in the type.
class ToJSONField a where
  encodeJSONField :: a -> Series
  toJSONField :: a -> (Text,Value)

-- | An @ElField '(s,a)@ value maps to a JSON field with name @s@ and
-- value @a@.
instance (ToJSON a, KnownSymbol s) => ToJSONField (ElField '(s,a)) where
  encodeJSONField x = pair (T.pack (getLabel x)) (toEncoding (getField x))
  toJSONField x = (T.pack (getLabel x), toJSON (getField x))

-- | A @((Text,) :. f) a@ value maps to a JSON field whose name is the
-- 'Text' value, and whose value has type @f a@.
instance ToJSON (f a) => ToJSONField (((,) Text :. f) a) where
  encodeJSONField (Compose (name,val)) = pair name (toEncoding val)
  toJSONField (Compose (name,val)) = (name, toJSON val)

encodeRec :: (RFoldMap rs, RecMapMethod1 ToJSONField f rs)
          => Rec f rs -> Encoding
encodeRec = wrapObject
          . pairs
          . rfoldMap getConst
          . rmapMethod1 @ToJSONField (Const . encodeJSONField)

recToJSON :: (RFoldMap rs, RecMapMethod1 ToJSONField f rs)
          => Rec f rs -> Value
recToJSON = object
          . rfoldMap ((:[]) . getConst)
          . rmapMethod1 @ToJSONField (Const . toJSONField)

-- * Generically

-- | If a 'Value' is a nested 'Array' of 'Object's, extract the
-- collection of key-value pairs from the entire recursive structure.
allAesonFields :: Value -> Maybe (H.HashMap Text Value)
allAesonFields (Array arr) =
  case V.toList arr of
    [] -> Just mempty
    [Object field, objTail] -> fmap (field <>) (allAesonFields objTail)
    _ -> Nothing
allAesonFields _ = Nothing

-- | Try un-nesting a recursive 'Array' of fields. That is, if a
-- 'Value' is laid out as @Array [Object [(key1,value1)], Array
-- [Object [(key2, value2)], ...]]@ we extract all the key-value
-- pairs, @[(key1,value1), (key2, value2), ...]@.
unnestFields :: Value -> Value
unnestFields v = maybe v Object (allAesonFields v)

-- | A lens implementation of something a bit looser than
-- 'unnestFields'.
allFields :: Value -> H.HashMap Text Value
allFields = view (deep _Object)

-- | The generic 'ToJSON' instance is not quite right since we use the
-- record's interpretation type constructor to define serialization,
-- resulting in each record field being treated as a self-contained
-- JSON object. What we want is for each record field to become a
-- named field of a single JSON object, so we must post-process the
-- result of the function defined on 'Generic'.
grecToJSON :: (Generic (Rec f rs), GToJSON Zero (Rep (Rec f rs)))
           => Rec f rs -> Value
grecToJSON = Object . allFields . genericToJSON defaultOptions