pa-json-0.1.0.0: src/Json/Enc.hs
{-# LANGUAGE AllowAmbiguousTypes #-}
{-# LANGUAGE QuasiQuotes #-}
module Json.Enc where
import Data.Aeson (Encoding, Value (..))
import Data.Aeson.Encoding qualified as AesonEnc
import Data.Aeson.Key qualified as Key
import Data.Aeson.KeyMap (KeyMap)
import Data.Aeson.KeyMap qualified as KeyMap
import Data.Int (Int64)
import Data.Map.Strict qualified as Map
import Data.Scientific
import Data.String (IsString (fromString))
import Data.Text.Lazy qualified as Lazy
import Data.Time qualified as Time
import Data.Time.Format.ISO8601 qualified as ISO8601
import GHC.TypeLits
import PossehlAnalyticsPrelude
-- | A JSON encoder.
--
-- It is faster than going through 'Value', because 'Encoding' is just a wrapper around a @Bytes.Builder@.
-- But the @aeson@ interface for 'Encoding' is extremely bad, so let’s build a better one.
newtype Enc = Enc {unEnc :: Encoding}
deriving (Num, Fractional) via (NumLiteralOnly "Enc" Enc)
-- | You can create an @Enc any@ that renders an 'Aeson.String' value with @OverloadedStrings@.
instance IsString Enc where
fromString = Enc . AesonEnc.string
-- | You can create an @Enc any@ that renders an 'Aeson.Number' value with an integer literal.
instance IntegerLiteral Enc where
integerLiteral = Enc . AesonEnc.integer
-- | You can create an @Enc any@ that renders an 'Aeson.Number' value with an floating point literal.
--
-- ATTN: Bear in mind that this will crash on repeating rationals, so only use for literals in code!
instance RationalLiteral Enc where
rationalLiteral r = Enc $ AesonEnc.scientific (r & fromRational @Scientific)
-- | Embed an 'Encoding' verbatim (it’s a valid JSON value)
encoding :: Encoding -> Enc
encoding = Enc
-- | Encode a 'Value' verbatim (it’s a valid JSON value)
value :: Value -> Enc
value = Enc . AesonEnc.value
-- | Encode an empty 'Array'
emptyArray :: Enc
emptyArray = Enc AesonEnc.emptyArray_
-- | Encode an empty 'Object'
emptyObject :: Enc
emptyObject = Enc AesonEnc.emptyObject_
-- | Encode a 'Text'
text :: Text -> Enc
text = Enc . AesonEnc.text
-- | Encode a lazy @Text@
lazyText :: Lazy.Text -> Enc
lazyText = Enc . AesonEnc.lazyText
-- | Encode a 'String'
string :: String -> Enc
string = Enc . AesonEnc.string
-- | Encode as 'Null' if 'Nothing', else use the given encoder for @Just a@
nullOr :: (a -> Enc) -> Maybe a -> Enc
nullOr inner = \case
Nothing -> Enc AesonEnc.null_
Just a -> inner a
-- | Encode a list as 'Array'
list :: (a -> Enc) -> [a] -> Enc
list f = Enc . AesonEnc.list (\a -> (f a).unEnc)
-- | Encode a 'NonEmpty' as an 'Array'.
nonEmpty :: (a -> Enc) -> NonEmpty a -> Enc
nonEmpty f = list f . toList
-- | Encode the given list of keys and their encoders as 'Object'.
--
-- Like with 'Map.fromList', if the list contains the same key multiple times, the last value in the list is retained:
--
-- @
-- (object [ ("foo", 42), ("foo", 23) ])
-- ~= "{\"foo\":23}"
-- @
object :: Foldable t => t (Text, Enc) -> Enc
object m =
Enc $
AesonEnc.dict
AesonEnc.text
(\recEnc -> recEnc.unEnc)
Map.foldrWithKey
(Map.fromList $ toList m)
-- | A tag/value encoder; See 'choice'
data Choice = Choice Text Enc
-- | Encode a sum type as a @Choice@, an object with a @tag@/@value@ pair,
-- which is the conventional json sum type representation in our codebase.
--
-- @
-- foo :: Maybe Text -> Enc
-- foo = choice $ \case
-- Nothing -> Choice "no" emptyObject ()
-- Just t -> Choice "yes" text t
--
-- ex = foo Nothing == "{\"tag\": \"no\", \"value\": {}}"
-- ex2 = foo (Just "hi") == "{\"tag\": \"yes\", \"value\": \"hi\"}"
-- @
choice :: (from -> Choice) -> from -> Enc
choice f from = case f from of
Choice key encA -> singleChoice key encA
-- | Like 'choice', but simply encode a single possibility into a @tag/value@ object.
-- This can be useful, but if you want to match on an enum, use 'choice' instead.
singleChoice :: Text -> Enc -> Enc
singleChoice key encA =
Enc $
AesonEnc.pairs $
mconcat
[ AesonEnc.pair "tag" (AesonEnc.text key),
AesonEnc.pair "value" encA.unEnc
]
-- | Encode a 'Map'.
--
-- We can’t really set the key to anything but text (We don’t keep the tag of 'Encoding')
-- so instead we allow anything that’s coercible from text as map key (i.e. newtypes).
map :: forall k v. (Coercible k Text) => (v -> Enc) -> Map k v -> Enc
map valEnc m =
Enc $
AesonEnc.dict
(AesonEnc.text . coerce @k @Text)
(\v -> (valEnc v).unEnc)
Map.foldrWithKey
m
-- | Encode a 'KeyMap'
keyMap :: (v -> Enc) -> KeyMap v -> Enc
keyMap valEnc m =
Enc $
AesonEnc.dict
(AesonEnc.text . Key.toText)
(\v -> (valEnc v).unEnc)
KeyMap.foldrWithKey
m
-- | Encode 'Null'
null :: Enc
null = Enc AesonEnc.null_
-- | Encode 'Bool'
bool :: Bool -> Enc
bool = Enc . AesonEnc.bool
-- | Encode an 'Integer' as 'Number'.
-- TODO: is it okay to just encode an arbitrarily-sized integer into json?
integer :: Integer -> Enc
integer = Enc . AesonEnc.integer
-- | Encode a 'Scientific' as 'Number'.
scientific :: Scientific -> Enc
scientific = Enc . AesonEnc.scientific
-- | Encode a 'Natural' as 'Number'.
natural :: Natural -> Enc
natural = integer . toInteger @Natural
-- | Encode an 'Int' as 'Aeson.Number'.
int :: Int -> Enc
int = Enc . AesonEnc.int
-- | Encode an 'Int64' as 'Number'.
int64 :: Int64 -> Enc
int64 = Enc . AesonEnc.int64
-- | Encode UTCTime as 'String', as an ISO8601 timestamp with timezone (@yyyy-mm-ddThh:mm:ss[.sss]Z@)
utcTime :: Time.UTCTime -> Enc
utcTime =
text . stringToText . ISO8601.iso8601Show @Time.UTCTime
-- | Implement this class if you want your type to only implement the part of 'Num'
-- that allows creating them from Integer-literals, then derive Num via 'NumLiteralOnly':
--
-- @
-- data Foo = Foo Integer
-- deriving (Num) via (NumLiteralOnly "Foo" Foo)
--
-- instance IntegerLiteral Foo where
-- integerLiteral i = Foo i
-- @
class IntegerLiteral a where
integerLiteral :: Integer -> a
-- | The same as 'IntegerLiteral' but for floating point literals.
class RationalLiteral a where
rationalLiteral :: Rational -> a
-- | Helper class for @deriving (Num) via …@, implements only literal syntax for integer and floating point numbers,
-- and throws descriptive runtime errors for any other methods in 'Num'.
--
-- See 'IntegerLiteral' and 'RationalLiteral' for examples.
newtype NumLiteralOnly (sym :: Symbol) num = NumLiteralOnly num
instance (IntegerLiteral num, KnownSymbol sym) => Num (NumLiteralOnly sym num) where
fromInteger = NumLiteralOnly . integerLiteral
(+) = error [fmt|Only use as numeric literal allowed for {symbolVal (Proxy @sym)}, you tried to add (+) (NumLiteralOnly)|]
(*) = error [fmt|Only use as numeric literal allowed for {symbolVal (Proxy @sym)}, you tried to multiply (*) (NumLiteralOnly)|]
(-) = error [fmt|Only use as numeric literal allowed for {symbolVal (Proxy @sym)}, you tried to subtract (-) (NumLiteralOnly)|]
abs = error [fmt|Only use as numeric literal allowed for {symbolVal (Proxy @sym)}, you tried to use `abs` (NumLiteralOnly)|]
signum = error [fmt|Only use as numeric literal allowed for {symbolVal (Proxy @sym)}, you tried to use `signum` (NumLiteralOnly)|]
instance (IntegerLiteral num, RationalLiteral num, KnownSymbol sym) => Fractional (NumLiteralOnly sym num) where
fromRational = NumLiteralOnly . rationalLiteral
recip = error [fmt|Only use as rational literal allowed for {symbolVal (Proxy @sym)}, you tried to use `recip` (NumLiteralOnly)|]
(/) = error [fmt|Only use as numeric literal allowed for {symbolVal (Proxy @sym)}, you tried to divide (/) (NumLiteralOnly)|]