keiki-codec-json-0.2.0.0: src/Keiki/Codec/JSON/TH.hs
{-# LANGUAGE TemplateHaskell #-}
-- |
-- Module : Keiki.Codec.JSON.TH
-- Description : Template Haskell helpers that emit @RegFile@-routed JSON
-- codec functions for plain Haskell record types.
--
-- A user with a record type
--
-- @
-- data MySnapshot = MySnapshot
-- { retryCount :: Int
-- , correlationId :: Text
-- , dispatchedAt :: UTCTime
-- }
-- deriving stock (Eq, Show, Generic)
-- @
--
-- invokes
--
-- @
-- \$('deriveRegFileCodec' \'\'MySnapshot)
--
-- mySnapshotToJSON :: MySnapshot -> Aeson.Value
-- mySnapshotToEncoding :: MySnapshot -> Aeson.Encoding
-- mySnapshotFromJSON :: Aeson.Value -> Either String MySnapshot
-- @
--
-- to emit those three top-level functions. Each function routes through the
-- existing 'Keiki.Codec.JSON.RegFileToJSON' class against the slot list
-- @'Keiki.Generics.RegFieldsOf' MySnapshot@. The record's field names become
-- the JSON object's keys; the record's field types must each carry
-- 'Aeson.ToJSON' and 'Aeson.FromJSON' or compilation fails with a precise
-- per-field error.
--
-- This splice lives in @keiki-codec-json@ (not in @keiki@'s
-- @Keiki.Generics.TH@). The class @RegFileToJSON@ is defined here, and moving
-- the splice to @keiki@ would force an @aeson@ dependency on @keiki@ core —
-- violating the load-bearing /keiki MUST NOT gain @aeson@/ requirement (EP-36
-- §3 R8; MP-11 Decision Log 2026-05-10). The splice does reuse the structural
-- machinery in @keiki@'s @Keiki.Generics@ ('Keiki.Generics.RegFieldsOf',
-- 'Keiki.Generics.gToRegFile', 'Keiki.Generics.gFromRegFile') so composition
-- with the existing 'Keiki.Generics.TH' ergonomics is preserved.
module Keiki.Codec.JSON.TH
( deriveRegFileCodec,
deriveRegFileCodecAs,
)
where
import Data.Aeson qualified as Aeson
import Data.Char (toLower)
import GHC.Generics qualified as Generics
import Keiki.Codec.JSON (regFileFromJSON, regFileToEncoding, regFileToJSON)
import Keiki.Generics (RegFieldsOf, gFromRegFile, gToRegFile)
import Language.Haskell.TH
( Con (NormalC, RecC),
Dec (DataD, NewtypeD, TySynD),
Info (TyConI),
Name,
Q,
clause,
conT,
funD,
mkName,
nameBase,
normalB,
reify,
sigD,
)
-- | Emit three top-level JSON-codec functions for the record type
-- @t@, with names derived from the type's name by lower-casing the
-- first character.
--
-- For @t = MySnapshot@ the splice emits
--
-- * @mySnapshotToJSON :: MySnapshot -> Aeson.Value@
-- * @mySnapshotToEncoding :: MySnapshot -> Aeson.Encoding@
-- * @mySnapshotFromJSON :: Aeson.Value -> Either String MySnapshot@
--
-- The record must have @deriving (Generic)@ (the splice does not
-- emit a @Generic@ instance). Every field type must have
-- @Aeson.ToJSON@ and @Aeson.FromJSON@ in scope, or compilation fails
-- at the use site of the emitted function with a missing-instance
-- error naming the field's type.
--
-- The splice rejects:
--
-- * Type synonyms, classes, value bindings, primitive types — only
-- @data@ and @newtype@ declarations are accepted.
-- * Multi-constructor types (@data Foo = A | B@) — a single slot list
-- cannot represent a sum.
-- * Positional (non-record-syntax) constructors — there are no field
-- names to use as slot symbols.
--
-- Singleton constructors with no fields (@data Empty = Empty@) are
-- accepted; the slot list is @'[]@ and the emitted functions codec
-- the empty JSON object.
deriveRegFileCodec :: Name -> Q [Dec]
deriveRegFileCodec n = deriveRegFileCodecAs (defaultPrefix n) n
-- | Variant of 'deriveRegFileCodec' that takes the function-name
-- prefix explicitly. The three emitted functions are named
-- @\<prefix\>ToJSON@, @\<prefix\>ToEncoding@, @\<prefix\>FromJSON@.
deriveRegFileCodecAs :: String -> Name -> Q [Dec]
deriveRegFileCodecAs prefix tyName = do
validateRecord tyName
let recTy = conT tyName
toJSONN = mkName (prefix <> "ToJSON")
toEncN = mkName (prefix <> "ToEncoding")
fromJSONN = mkName (prefix <> "FromJSON")
toJSONSig <-
sigD
toJSONN
[t|$recTy -> Aeson.Value|]
toJSONDef <-
funD
toJSONN
[ clause
[]
( normalB
[|
regFileToJSON
@(RegFieldsOf $recTy)
. gToRegFile
. Generics.from
|]
)
[]
]
toEncSig <-
sigD
toEncN
[t|$recTy -> Aeson.Encoding|]
toEncDef <-
funD
toEncN
[ clause
[]
( normalB
[|
regFileToEncoding
@(RegFieldsOf $recTy)
. gToRegFile
. Generics.from
|]
)
[]
]
fromJSONSig <-
sigD
fromJSONN
[t|Aeson.Value -> Either String $recTy|]
fromJSONDef <-
funD
fromJSONN
[ clause
[]
( normalB
[|
fmap (Generics.to . gFromRegFile)
. regFileFromJSON
@(RegFieldsOf $recTy)
|]
)
[]
]
pure
[ toJSONSig,
toJSONDef,
toEncSig,
toEncDef,
fromJSONSig,
fromJSONDef
]
-- * Internal helpers ---------------------------------------------------------
-- | Lower-case the first character of the type name to produce the
-- conventional function-name prefix.
defaultPrefix :: Name -> String
defaultPrefix n = case nameBase n of
[] -> error "deriveRegFileCodec: empty type name"
(c : cs) -> toLower c : cs
-- | Validate that @tyName@ refers to a single-constructor record-syntax
-- @data@ or @newtype@ declaration. Reject every other shape with a
-- precise error message.
validateRecord :: Name -> Q ()
validateRecord tyName = do
info <- reify tyName
case info of
TyConI dec -> case dec of
DataD _ _ _ _ ctors _ ->
validateCtors tyName ctors
NewtypeD _ _ _ _ ctor _ ->
validateCtors tyName [ctor]
TySynD {} ->
failure "a type synonym; only `data` and `newtype` are supported"
_ ->
failure
( "an unsupported declaration shape; only `data` and "
<> "`newtype` are supported"
)
_ ->
failure
( "not a type constructor; pass a record type name like "
<> "''MyRecord"
)
where
failure :: String -> Q a
failure detail =
fail $ "deriveRegFileCodec: " <> show tyName <> " is " <> detail
-- | Inspect the constructor list. Accept iff there is exactly one
-- constructor, and that constructor is either @RecC@ (record syntax
-- with named fields) or @NormalC@ with zero positional arguments
-- (the no-field singleton case).
validateCtors :: Name -> [Con] -> Q ()
validateCtors tyName ctors = case ctors of
[RecC _ _] -> pure ()
[NormalC _ []] -> pure ()
[NormalC _ _] ->
fail $
"deriveRegFileCodec: "
<> show tyName
<> " has a positional (non-record-syntax) "
<> "constructor; switch to record syntax so "
<> "field names are available as slot symbols, "
<> "e.g. `data Foo = Foo { x :: Int }`."
[] ->
fail $
"deriveRegFileCodec: "
<> show tyName
<> " has no constructors; pass a record type."
(_ : _ : _) ->
fail $
"deriveRegFileCodec: "
<> show tyName
<> " has multiple constructors; a single slot "
<> "list cannot represent a sum type."
_ ->
fail $
"deriveRegFileCodec: "
<> show tyName
<> " has an unsupported constructor shape "
<> "(infix or GADT); pass a plain record type."