wireform-derive-0.2.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 Data.Aeson qualified as Aeson
import Data.Aeson.Key qualified as Aeson.Key
import Data.Coerce (coerce)
import Data.Foldable (foldlM)
import Data.Text qualified 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