wireform-derive-0.1.0.0: src/Wireform/Derive/Aeson.hs
{-# LANGUAGE TemplateHaskell #-}
-- | Annotation-driven Aeson 'ToJSON' / 'FromJSON' deriver.
--
-- Adapted from riz0id's @aeson-th@ but reimplemented on top of the
-- 'Wireform.Derive' core so a single set of @ANN@ pragmas can drive
-- every wireform-supported format simultaneously. Riz0id's original
-- left sum-of-products unfinished; this deriver implements them as
-- aeson's @TaggedObject@ shape (the proto3-JSON-friendly default).
--
-- The deriver covers four type shapes:
--
-- * 'TypeShapeNewtype' — the inner field's instance is reused.
-- * 'TypeShapeRecord' — encoded as @{ "k1": v1, ... }@.
-- * 'TypeShapeEnum' — encoded as a JSON string of the constructor
-- name (with rename modifiers applied).
-- * 'TypeShapeSum' — encoded as @{ "tag": "Ctor", "contents": ... }@.
-- When a constructor has zero fields, @contents@ is 'Aeson.Null';
-- one field, the field's JSON; multiple fields, a JSON array.
--
-- == Quick start
--
-- @
-- data Person = Person
-- { personName :: !Text
-- , personAge :: !Int
-- } deriving (Eq, Show)
--
-- {-\# ANN type Person (rename "person") \#-}
-- {-\# ANN personName (rename "name") \#-}
-- {-\# ANN personAge (renameStyle SnakeCase) \#-}
--
-- 'deriveJSON' ''Person
-- @
module Wireform.Derive.Aeson
( deriveJSON
, deriveToJSON
, deriveFromJSON
) where
import Data.Aeson (FromJSON (..), ToJSON (..), (.:), (.:?), (.=))
import qualified Data.Aeson as Aeson
import qualified Data.Aeson.Key as Aeson.Key
import Data.Coerce (coerce)
import Data.Foldable (foldlM)
import qualified Data.Text as T
import Language.Haskell.TH
import Wireform.Derive.Backend
import Wireform.Derive.ModifierInfo
import Wireform.Derive.TypeInfo
-- ---------------------------------------------------------------------------
-- Public entry points
-- ---------------------------------------------------------------------------
-- | Derive both 'ToJSON' and 'FromJSON' for the given type.
deriveJSON :: Name -> Q [Dec]
deriveJSON nm = (++) <$> deriveToJSON nm <*> deriveFromJSON nm
-- | Derive only 'ToJSON'.
deriveToJSON :: Name -> Q [Dec]
deriveToJSON nm = do
ti <- reifyTypeInfo nm
body <- toJSONBody ti
let typ = applyTypeArgs (ConT (typeInfoName ti)) (typeInfoVarTypes ti)
decl = InstanceD Nothing []
(AppT (ConT ''ToJSON) typ)
[FunD 'toJSON [Clause [] (NormalB body) []]]
pure [decl]
-- | Derive only 'FromJSON'.
deriveFromJSON :: Name -> Q [Dec]
deriveFromJSON nm = do
ti <- reifyTypeInfo nm
body <- fromJSONBody ti
let typ = applyTypeArgs (ConT (typeInfoName ti)) (typeInfoVarTypes ti)
decl = InstanceD Nothing []
(AppT (ConT ''FromJSON) typ)
[FunD 'parseJSON [Clause [] (NormalB body) []]]
pure [decl]
-- ---------------------------------------------------------------------------
-- ToJSON dispatch
-- ---------------------------------------------------------------------------
toJSONBody :: TypeInfo -> Q Exp
toJSONBody ti = case typeInfoShape ti of
TypeShapeNewtype c -> toJSONNewtype c
TypeShapeRecord c -> toJSONRecord c
TypeShapeEnum cs -> toJSONEnum cs
TypeShapeSum cs -> toJSONSum cs
toJSONNewtype :: ConInfo -> Q Exp
toJSONNewtype c = case conInfoFields c of
[FieldInfo (Just sel) _] -> do
x <- newName "x"
lamE [varP x] [| toJSON ($(varE sel) $(varE x)) |]
[FieldInfo Nothing _] -> do
x <- newName "x"
lamE [conP (conInfoName c) [varP x]] [| toJSON $(varE x) |]
_ -> fail "Wireform.Derive.Aeson: newtype must have exactly one field"
toJSONRecord :: ConInfo -> Q Exp
toJSONRecord c = do
x <- newName "x"
pairsExp <- recordToJSONPairs (varE x) c
lamE [varP x] [| Aeson.object $(pure pairsExp) |]
recordToJSONPairs :: Q Exp -> ConInfo -> Q Exp
recordToJSONPairs varExp c = do
pairExpss <- mapM (toJSONField varExp) (conInfoFields c)
pure (ListE (concat pairExpss))
toJSONField :: Q Exp -> FieldInfo -> Q [Exp]
toJSONField varExp (FieldInfo mSel _) = do
selName <- requireSelector mSel
mi <- reifyModifierInfoFor backendJSON selName
if miSkip mi
then pure []
else do
let selBase = T.pack (nameBase selName)
keyExp <- renderWireKey mi selBase
let getter = appE (varE selName) varExp
encoded = case miCoerce mi of
Nothing -> [| toJSON $getter |]
Just _ -> [| toJSON (coerce $getter) |]
pair <- [| (Aeson.Key.fromText $(pure keyExp) .= ($encoded :: Aeson.Value)) |]
pure [pair]
-- | Enum: encode as a JSON string. Each constructor's wire key is
-- baked at splice time.
toJSONEnum :: [ConInfo] -> Q Exp
toJSONEnum cs = do
v <- newName "v"
matches <- mapM enumToJSONMatch cs
body <- caseE (varE v) (map pure matches)
lamE [varP v] (pure body)
enumToJSONMatch :: ConInfo -> Q Match
enumToJSONMatch c = do
mi <- reifyModifierInfoFor backendJSON (conInfoName c)
keyExp <- renderWireKey mi (T.pack (nameBase (conInfoName c)))
body <- [| toJSON ($(pure keyExp) :: T.Text) |]
pure (Match (ConP (conInfoName c) [] []) (NormalB body) [])
-- | Sum: tagged-object encoding with @tag@ + @contents@ fields. The
-- @contents@ payload is 'Aeson.Null' for nullary constructors, the
-- single field for unary constructors, and a JSON array for n-ary
-- constructors.
toJSONSum :: [ConInfo] -> Q Exp
toJSONSum cs = do
v <- newName "v"
matches <- mapM sumCtorToJSON cs
body <- caseE (varE v) (map pure matches)
lamE [varP v] (pure body)
sumCtorToJSON :: ConInfo -> Q Match
sumCtorToJSON c = do
mi <- reifyModifierInfoFor backendJSON (conInfoName c)
keyExp <- renderWireKey mi (T.pack (nameBase (conInfoName c)))
fieldNames <- mapM (\_ -> newName "f") (conInfoFields c)
let pat = ConP (conInfoName c) [] (map VarP fieldNames)
contentsE <- case fieldNames of
[] -> [| Aeson.Null |]
[n] -> [| toJSON $(varE n) |]
ns -> [| toJSON
($(pure (ListE (map (AppE (VarE 'toJSON) . VarE) ns)))
:: [Aeson.Value]) |]
body <- [| Aeson.object
[ Aeson.Key.fromText (T.pack "tag") .= ($(pure keyExp) :: T.Text)
, Aeson.Key.fromText (T.pack "contents") .= ($(pure contentsE) :: Aeson.Value)
] |]
pure (Match pat (NormalB body) [])
-- ---------------------------------------------------------------------------
-- FromJSON dispatch
-- ---------------------------------------------------------------------------
fromJSONBody :: TypeInfo -> Q Exp
fromJSONBody ti = case typeInfoShape ti of
TypeShapeNewtype c -> fromJSONNewtype c
TypeShapeRecord c -> fromJSONRecord (nameBase (typeInfoName ti)) c
TypeShapeEnum cs -> fromJSONEnum cs
TypeShapeSum cs -> fromJSONSum cs
fromJSONNewtype :: ConInfo -> Q Exp
fromJSONNewtype c = case conInfoFields c of
[FieldInfo _ _] -> [| fmap $(conE (conInfoName c)) . parseJSON |]
_ -> fail "Wireform.Derive.Aeson: newtype must have exactly one field"
fromJSONRecord :: String -> ConInfo -> Q Exp
fromJSONRecord typeName c = do
obj <- newName "o"
parserExp <- recordParser obj c
[| Aeson.withObject typeName (\ $(varP obj) -> $(pure parserExp)) |]
recordParser :: Name -> ConInfo -> Q Exp
recordParser obj c = do
let conName = conInfoName c
fields = conInfoFields c
case fields of
[] -> [| pure $(conE conName) |]
(f0 : fs) -> do
e0 <- fieldParser obj f0
hd <- [| $(conE conName) <$> $(pure e0) |]
foldlM
(\acc f -> do
ef <- fieldParser obj f
[| $(pure acc) <*> $(pure ef) |])
hd
fs
fieldParser :: Name -> FieldInfo -> Q Exp
fieldParser obj (FieldInfo mSel _) = do
selName <- requireSelector mSel
mi <- reifyModifierInfoFor backendJSON selName
if miSkip mi
then case miDefaults mi of
Just defNm -> [| pure $(varE defNm) |]
Nothing -> [| pure (error "Wireform.Derive.Aeson: missing 'defaults' for skipped field") |]
else do
let selBase = T.pack (nameBase selName)
keyExp <- renderWireKey mi selBase
let isOptional = miRequired mi == Just False
base <-
if isOptional
then [| $(varE obj) .:? Aeson.Key.fromText $(pure keyExp) |]
else [| $(varE obj) .: Aeson.Key.fromText $(pure keyExp) |]
case miCoerce mi of
Nothing -> pure base
Just _ -> [| fmap coerce $(pure base) |]
-- | Enum: dispatch on the JSON string against each constructor's
-- rendered key. Built as a `MultiWayIf` so that runtime-rendered keys
-- (from 'renameWith') participate naturally.
fromJSONEnum :: [ConInfo] -> Q Exp
fromJSONEnum cs = do
s <- newName "s"
branches <- mapM (enumDispatch s) cs
let fallback =
( NormalG (ConE 'True)
, AppE (VarE 'fail)
(InfixE
(Just (LitE (StringL "Wireform.Derive.Aeson: unknown enum value ")))
(VarE '(++))
(Just (AppE (VarE 'show) (VarE s))))
)
let multi = MultiIfE (branches ++ [fallback])
[| Aeson.withText "enum" (\ $(varP s) -> $(pure multi)) |]
enumDispatch :: Name -> ConInfo -> Q (Guard, Exp)
enumDispatch sVar c = do
mi <- reifyModifierInfoFor backendJSON (conInfoName c)
keyExp <- renderWireKey mi (T.pack (nameBase (conInfoName c)))
let guardExp = InfixE (Just (VarE sVar)) (VarE '(==)) (Just keyExp)
bodyExp = AppE (VarE 'pure) (ConE (conInfoName c))
pure (NormalG guardExp, bodyExp)
-- | Sum: read @tag@ + @contents@ from the object, then dispatch via
-- a `MultiWayIf` on the rendered tag key for each constructor.
fromJSONSum :: [ConInfo] -> Q Exp
fromJSONSum cs = do
obj <- newName "o"
tagVar <- newName "tag"
cVar <- newName "c"
branches <- mapM (sumDispatch tagVar cVar) cs
let fallback =
( NormalG (ConE 'True)
, AppE (VarE 'fail)
(InfixE
(Just (LitE (StringL "Wireform.Derive.Aeson: unknown sum tag ")))
(VarE '(++))
(Just (AppE (VarE 'show) (VarE tagVar))))
)
multi = MultiIfE (branches ++ [fallback])
[| Aeson.withObject "sum" (\ $(varP obj) -> do
($(varP tagVar) :: T.Text) <-
$(varE obj) .: Aeson.Key.fromText (T.pack "tag")
($(varP cVar) :: Aeson.Value) <-
$(varE obj) .: Aeson.Key.fromText (T.pack "contents")
$(pure multi)) |]
sumDispatch :: Name -> Name -> ConInfo -> Q (Guard, Exp)
sumDispatch tagVar cVar c = do
mi <- reifyModifierInfoFor backendJSON (conInfoName c)
keyExp <- renderWireKey mi (T.pack (nameBase (conInfoName c)))
let guardExp = InfixE (Just (VarE tagVar)) (VarE '(==)) (Just keyExp)
bodyExp <- case conInfoFields c of
[] -> [| pure $(conE (conInfoName c)) |]
[_one] -> [| fmap $(conE (conInfoName c)) (parseJSON $(varE cVar)) |]
many -> sumNAry cVar (conInfoName c) (length many)
pure (NormalG guardExp, bodyExp)
-- | Build a parser for an n-ary sum constructor: parse @contents@ as
-- a JSON array, then apply each element through 'parseJSON' and
-- combine via @ConE c \<$> parse e0 \<*> parse e1 \<*> ...@.
sumNAry :: Name -> Name -> Int -> Q Exp
sumNAry cVar conName arity = do
arr <- newName "arr"
let parseI :: Int -> Q Exp
parseI i = [| parseJSON ($(varE arr) !! $(litE (integerL (fromIntegral i)))) |]
hd <- do
e0 <- parseI 0
[| $(conE conName) <$> $(pure e0) |]
tail' <- foldlM
(\acc i -> do
ei <- parseI i
[| $(pure acc) <*> $(pure ei) |])
hd
[1 .. arity - 1]
let conNameStr = nameBase conName
[| do
($(varP arr) :: [Aeson.Value]) <- parseJSON $(varE cVar)
if length $(varE arr) /= $(litE (integerL (fromIntegral arity)))
then fail ("Wireform.Derive.Aeson: " ++ conNameStr
++ " expected "
++ show ($(litE (integerL (fromIntegral arity))) :: Int)
++ " contents, got " ++ show (length $(varE arr)))
else $(pure tail') |]
-- ---------------------------------------------------------------------------
-- Helpers
-- ---------------------------------------------------------------------------
requireSelector :: Maybe Name -> Q Name
requireSelector (Just n) = pure n
requireSelector Nothing =
fail "Wireform.Derive.Aeson: cannot derive JSON for non-record positional field"
applyTypeArgs :: Type -> [Type] -> Type
applyTypeArgs = foldl AppT