api-tools-0.8.0.1: src/Data/API/JSONToCBOR.hs
{-# LANGUAGE BangPatterns #-}
module Data.API.JSONToCBOR
( serialiseJSONWithSchema
, jsonToCBORWithSchema
, deserialiseJSONWithSchema
, postprocessJSON
) where
import Data.API.Changes
import Data.API.JSON
import Data.API.Types
import Data.API.Utils
import Control.Applicative
import Data.Aeson hiding (encode)
import qualified Data.ByteString.Base64 as B64
import qualified Data.ByteString.Lazy as LBS
import qualified Data.HashMap.Strict as HMap
import qualified Data.Map as Map
import Data.Traversable
import qualified Data.Vector as Vec
import Codec.Serialise as CBOR
import Data.Binary.Serialise.CBOR.JSON (cborToJson, jsonToCbor)
import Codec.CBOR.Term
import Data.Fixed (Pico)
import Data.Maybe (fromMaybe)
import Data.Scientific
import qualified Data.Text as T
import qualified Data.Text.Encoding as TE
import Data.Time.Clock.POSIX
import Data.Time (UTCTime(UTCTime))
import Prelude
-- | Serialise a JSON value as a CBOR term in a generic but
-- schema-dependent fashion. This is necessary because the JSON
-- representation carries less information than we need in CBOR
-- (e.g. it lacks a distinction between bytestrings and text).
serialiseJSONWithSchema :: API -> TypeName -> Value -> LBS.ByteString
serialiseJSONWithSchema api tn v = serialise $ jsonToCBORWithSchema api tn v
-- | Convert a JSON value into a CBOR term in a generic but
-- schema-dependent fashion.
jsonToCBORWithSchema :: API -> TypeName -> Value -> Term
jsonToCBORWithSchema = jsonToCBORTypeName . apiNormalForm
jsonToCBORTypeName :: NormAPI -> TypeName -> Value -> Term
jsonToCBORTypeName napi tn v =
case Map.lookup tn napi of
Just (NRecordType nrt) -> jsonToCBORRecord napi nrt v
Just (NUnionType nut) -> jsonToCBORUnion napi nut v
Just (NEnumType net) -> jsonToCBOREnum napi net v
Just (NTypeSynonym ty) -> jsonToCBORType napi ty v
Just (NNewtype bt) -> jsonToCBORBasic bt v
Nothing -> error $ "serialiseJSONWithSchema: missing definition for type "
++ T.unpack (_TypeName tn)
jsonToCBORType :: NormAPI -> APIType -> Value -> Term
jsonToCBORType napi ty0 v = case (ty0, v) of
(TyList ty, Array arr) | Vec.null arr -> TList []
| otherwise -> TListI $ map (jsonToCBORType napi ty) (Vec.toList arr)
(TyList _ , _) -> error "serialiseJSONWithSchema: expected array"
(TyMaybe _ , Null) -> TList []
(TyMaybe ty, _) -> TList [jsonToCBORType napi ty v]
(TyName tn, _) -> jsonToCBORTypeName napi tn v
(TyBasic bt, _) -> jsonToCBORBasic bt v
(TyJSON , _) -> jsonToCbor v
-- | Encode a record as a map from field names to values. Crucially,
-- the fields are in ascending order by field name.
jsonToCBORRecord :: NormAPI -> NormRecordType -> Value -> Term
jsonToCBORRecord napi nrt v = case v of
Object hm -> TMap $ map (f hm) $ Map.toAscList nrt
_ -> error "serialiseJSONWithSchema: expected object"
where
f hm (fn, ty) = case HMap.lookup (_FieldName fn) hm of
Nothing -> error $ "serialiseJSONWithSchema: missing field " ++ T.unpack (_FieldName fn)
Just v' -> (TString (_FieldName fn), jsonToCBORType napi ty v')
-- | Encode a union as a single-element map from the field name to the value.
jsonToCBORUnion :: NormAPI -> NormUnionType -> Value -> Term
jsonToCBORUnion napi nut v = case v of
Object hm | [(k, r)] <- HMap.toList hm -> case Map.lookup (FieldName k) nut of
Just ty -> TMap [(TString k, jsonToCBORType napi ty r)]
Nothing -> error "serialiseJSONWithSchema: unexpected alternative in union"
_ -> error "serialiseJSONWithSchema: expected single-field object"
-- | Encode an enumerated value as its name; we do not check that it
-- actually belongs to the type here.
jsonToCBOREnum :: NormAPI -> NormEnumType -> Value -> Term
jsonToCBOREnum _ _ v = case v of
String t -> TString t
_ -> error "serialiseJSONWithSchema: expected string"
jsonToCBORBasic :: BasicType -> Value -> Term
jsonToCBORBasic bt v = case (bt, v) of
(BTstring, String t) -> TString t
(BTstring, _) -> error "serialiseJSONWithSchema: expected string"
(BTbinary, String t) -> case B64.decode $ TE.encodeUtf8 t of
Left err-> error $ "serialiseJSONWithSchema: base64-decoding failed: " ++ err
Right bs -> TBytes bs
(BTbinary, _) -> error "serialiseJSONWithSchema: expected string"
(BTbool , Bool b) -> TBool b
(BTbool , _) -> error "serialiseJSONWithSchema: expected bool"
(BTint , Number n) | Right i <- (floatingOrInteger n :: Either Double Int) -> TInt i
(BTint , _) -> error "serialiseJSONWithSchema: expected integer"
(BTutc , String t) ->
TTagged 1000 (TMap [ (TInt 1, TInt secs)
, (TInt (-12), TInt psecs) ])
where -- taken from @Codec.Serialise.Class@:
(secs, frac) = case properFraction $ utcTimeToPOSIXSeconds utc of
-- fractional part must be positive
(secs', frac')
| frac' < 0 -> (secs' - 1, frac' + 1)
| otherwise -> (secs', frac')
psecs = round $ frac * 1000000000000
utc = fromMaybe (error $ "jsonToCBORBasic: " ++ T.unpack t) $
parseUTC' t
(BTutc , _) -> error "serialiseJSONWithSchema: expected string"
-- | When a JSON value has been deserialised from CBOR, the
-- representation may need some modifications in order to match the
-- result of 'toJSON' on a Haskell datatype. In particular, Aeson's
-- representation of 'Maybe' does not round-trip (because 'Nothing' is
-- encoded as 'Null' and @'Just' x@ as @'toJSON' x@), so CBOR uses a
-- different representation (as an empty or 1-element list).
deserialiseJSONWithSchema :: API -> TypeName -> LBS.ByteString -> Value
deserialiseJSONWithSchema api tn bs = case postprocessJSON api tn (cborToJson (deserialise bs)) of
Right v -> v
Left err -> error $ "deserialiseJSONWithSchema could not post-process: " ++ prettyValueError err
postprocessJSON :: API -> TypeName -> Value -> Either ValueError Value
postprocessJSON api = postprocessJSONTypeName (apiNormalForm api)
postprocessJSONTypeName :: NormAPI -> TypeName -> Value -> Either ValueError Value
postprocessJSONTypeName napi tn v = do
t <- Map.lookup tn napi ?! InvalidAPI (TypeDoesNotExist tn)
case t of
NRecordType nrt -> postprocessJSONRecord napi nrt v
NUnionType nut -> postprocessJSONUnion napi nut v
NEnumType _ -> pure v
NTypeSynonym ty -> postprocessJSONType napi ty v
NNewtype bt -> postprocessJSONType napi (TyBasic bt) v
postprocessJSONType :: NormAPI -> APIType -> Value -> Either ValueError Value
postprocessJSONType napi ty0 v = case ty0 of
TyList ty -> case v of
Array arr -> Array <$> traverse (postprocessJSONType napi ty) arr
_ -> Left $ JSONError $ expectedArray v
TyMaybe ty -> case v of
Array arr -> case Vec.toList arr of
[] -> pure Null
[v1] -> postprocessJSONType napi ty v1
_:_:_ -> Left $ JSONError $ SyntaxError "over-long array when converting Maybe value"
_ -> Left $ JSONError $ expectedArray v
TyName tn -> postprocessJSONTypeName napi tn v
TyBasic BTutc -> case v of
Object obj -> case HMap.toList obj of
[(k1, Number v0), (km12, Number v1)]
| T.unpack k1 == "1" && T.unpack km12 == "-12" ->
-- Taken from @Codec.Serialise.Class@:
let psecs :: Pico
psecs = realToFrac v1 / 1000000000000
dt :: POSIXTime
dt = realToFrac v0 + realToFrac psecs
in pure $! String $! mkUTC' $! forceUTCTime (posixSecondsToUTCTime dt)
_ -> Left $ JSONError UnexpectedField
String t -> case parseUTC' t of
Nothing -> Left $ JSONError $ SyntaxError $
"UTC time in wrong format: " ++ T.unpack t
Just utcTime -> pure $! String $! mkUTC' $! forceUTCTime utcTime
_ -> Left $ JSONError $ expectedObject v
TyBasic _ -> pure v
TyJSON -> pure v
-- | Force the unnecessarily lazy @'UTCTime'@ representation.
forceUTCTime :: UTCTime -> UTCTime
forceUTCTime t@(UTCTime !_day !_daytime) = t
postprocessJSONRecord :: NormAPI -> NormRecordType -> Value -> Either ValueError Value
postprocessJSONRecord napi nrt v = case v of
Object hm -> Object <$> HMap.traverseWithKey f hm
_ -> Left $ JSONError $ expectedObject v
where
f t v' = do ty <- Map.lookup (FieldName t) nrt ?! JSONError UnexpectedField
postprocessJSONType napi ty v'
postprocessJSONUnion :: NormAPI -> NormUnionType -> Value -> Either ValueError Value
postprocessJSONUnion napi nut v = case v of
Object hm | [(k, r)] <- HMap.toList hm
, Just ty <- Map.lookup (FieldName k) nut
-> Object . HMap.singleton k <$> postprocessJSONType napi ty r
_ -> Left $ JSONError $ expectedObject v