diff --git a/src/Data/Aeson.hs b/src/Data/Aeson.hs index 60349ce71..a3b6fb41c 100644 --- a/src/Data/Aeson.hs +++ b/src/Data/Aeson.hs @@ -131,6 +131,7 @@ module Data.Aeson , fieldLabelModifier , constructorTagModifier , allNullaryToStringTag + , nullaryToObject , omitNothingFields , allowOmittedFields , sumEncoding diff --git a/src/Data/Aeson/TH.hs b/src/Data/Aeson/TH.hs index 088b1fbae..4d43a67c5 100644 --- a/src/Data/Aeson/TH.hs +++ b/src/Data/Aeson/TH.hs @@ -115,7 +115,7 @@ module Data.Aeson.TH import Data.Aeson.Internal.Prelude import Data.Char (ord) -import Data.Aeson (Object, (.:), FromJSON(..), FromJSON1(..), FromJSON2(..), ToJSON(..), ToJSON1(..), ToJSON2(..)) +import Data.Aeson (Object, (.:), FromJSON(..), FromJSON1(..), FromJSON2(..), ToJSON(..), ToJSON1(..), ToJSON2(..), object) import Data.Aeson.Types (Options(..), Parser, SumEncoding(..), Value(..), defaultOptions, defaultTaggedObject) import Data.Aeson.Types.Internal ((), JSONPathElement(Key)) import Data.Aeson.Types.ToJSON (fromPairs, pair) @@ -438,7 +438,9 @@ argsToValue letInsert target jc tvMap opts multiCons -- Single argument is directly converted. [e] -> e -- Zero and multiple arguments are converted to a JSON array. - es -> array target es + es + | nullaryToObject opts && null es -> objectE letInsert target [] + | otherwise -> array target es match (conP conName $ map varP args) (normalB $ opaqueSumToValue letInsert target opts multiCons (null argTys') conName js) @@ -873,11 +875,22 @@ consFromJSON jc tName opts instTys cons = do [] ] -parseNullaryMatches :: Name -> Name -> [Q Match] -parseNullaryMatches tName conName = - [ do arr <- newName "arr" - match (conP 'Array [varP arr]) - (guardedB +parseNullaryMatches :: Name -> Name -> Options -> [Q Match] +parseNullaryMatches tName conName opts + | nullaryToObject opts = + [ if rejectUnknownFields opts then matchEmptyObject else matchAnyObject + , matchFailed tName conName "Object" + ] + | otherwise = + [ matchEmptyArray + , matchFailed tName conName "Array" + ] + where + matchEmptyArray = do + arr <- newName "arr" + match + (conP 'Array [varP arr]) + (guardedB [ liftM2 (,) (normalG $ [|V.null|] `appE` varE arr) ([|pure|] `appE` conE conName) , liftM2 (,) (normalG [|otherwise|]) @@ -889,10 +902,31 @@ parseNullaryMatches tName conName = ) ) ] - ) - [] - , matchFailed tName conName "Array" - ] + ) + [] + matchAnyObject = do + match + (conP 'Object [wildP]) + (normalB $ [|pure|] `appE` conE conName) + [] + matchEmptyObject = do + obj <- newName "obj" + match + (conP 'Object [varP obj]) + (guardedB + [ liftM2 (,) (normalG $ [|KM.null|] `appE` varE obj) + ([|pure|] `appE` conE conName) + , liftM2 (,) (normalG [|otherwise|]) + (parseTypeMismatch tName conName + (litE $ stringL "an empty Object") + (infixApp (litE $ stringL "Object of size ") + [|(++)|] + ([|show . KM.size|] `appE` varE obj) + ) + ) + ] + ) + [] parseUnaryMatches :: JSONClass -> TyVarMap -> Type -> Name -> [Q Match] parseUnaryMatches jc tvMap argTy conName = @@ -986,12 +1020,12 @@ parseArgs _ _ _ _ , constructorFields = [] } (Left _) = [|pure|] `appE` conE conName -parseArgs _ _ tName _ +parseArgs _ _ tName opts ConstructorInfo { constructorName = conName , constructorVariant = NormalConstructor , constructorFields = [] } (Right valName) = - caseE (varE valName) $ parseNullaryMatches tName conName + caseE (varE valName) $ parseNullaryMatches tName conName opts -- Unary constructors. parseArgs jc tvMap _ _ diff --git a/src/Data/Aeson/Types.hs b/src/Data/Aeson/Types.hs index 5b5566165..ee603e6cc 100644 --- a/src/Data/Aeson/Types.hs +++ b/src/Data/Aeson/Types.hs @@ -142,6 +142,7 @@ module Data.Aeson.Types , fieldLabelModifier , constructorTagModifier , allNullaryToStringTag + , nullaryToObject , omitNothingFields , allowOmittedFields , sumEncoding diff --git a/src/Data/Aeson/Types/FromJSON.hs b/src/Data/Aeson/Types/FromJSON.hs index 916a89052..f01e0f2c7 100644 --- a/src/Data/Aeson/Types/FromJSON.hs +++ b/src/Data/Aeson/Types/FromJSON.hs @@ -1344,16 +1344,27 @@ instance RecordFromJSON arity f => ConsFromJSON' arity f True where instance {-# OVERLAPPING #-} ConsFromJSON' arity U1 False where - -- Empty constructors are expected to be encoded as an empty array: - consParseJSON' (cname :* tname :* _) v = - Tagged . contextCons cname tname $ case v of - Array a | V.null a -> pure U1 - | otherwise -> fail_ a - _ -> typeMismatch "Array" v + -- Empty constructors are expected to be encoded as an empty array or an object, + -- independent of nullaryToObject option. + -- With rejectUnknownFields an object must be empty. + consParseJSON' (cname :* tname :* opts :* _) v = + Tagged . contextCons cname tname $ + if nullaryToObject opts + then case v of + Object o | KM.null o || not (rejectUnknownFields opts) -> pure U1 + | otherwise -> failObj_ o + _ -> typeMismatch "Object" v + else case v of + Array a | V.null a -> pure U1 + | otherwise -> fail_ a + _ -> typeMismatch "Array" v where fail_ a = fail $ "expected an empty Array, but encountered an Array of length " ++ show (V.length a) + failObj_ o = fail $ + "expected an empty Object but encountered Object of size " ++ + show (KM.size o) {-# INLINE consParseJSON' #-} instance {-# OVERLAPPING #-} diff --git a/src/Data/Aeson/Types/Internal.hs b/src/Data/Aeson/Types/Internal.hs index 758f5fc4b..200e7fd1d 100644 --- a/src/Data/Aeson/Types/Internal.hs +++ b/src/Data/Aeson/Types/Internal.hs @@ -56,6 +56,7 @@ module Data.Aeson.Types.Internal fieldLabelModifier , constructorTagModifier , allNullaryToStringTag + , nullaryToObject , omitNothingFields , allowOmittedFields , sumEncoding @@ -714,6 +715,9 @@ data Options = Options -- nullary constructors, will be encoded to just a string with -- the constructor tag. If 'False' the encoding will always -- follow the `sumEncoding`. + , nullaryToObject :: Bool + -- ^ If 'True', the nullary constructors will be encoded + -- as empty objects (the default is to encode them as empty arrays). , omitNothingFields :: Bool -- ^ If 'True', record fields with a 'Nothing' value will be -- omitted from the resulting object. If 'False', the resulting @@ -744,12 +748,13 @@ data Options = Options } instance Show Options where - show (Options f c a o q s u t r) = + show (Options f c a n o q s u t r) = "Options {" ++ intercalate ", " [ "fieldLabelModifier =~ " ++ show (f "exampleField") , "constructorTagModifier =~ " ++ show (c "ExampleConstructor") , "allNullaryToStringTag = " ++ show a + , "nullaryToObject = " ++ show n , "omitNothingFields = " ++ show o , "allowOmittedFields = " ++ show q , "sumEncoding = " ++ show s @@ -846,6 +851,7 @@ defaultOptions = Options { fieldLabelModifier = id , constructorTagModifier = id , allNullaryToStringTag = True + , nullaryToObject = False , omitNothingFields = False , allowOmittedFields = True , sumEncoding = defaultTaggedObject diff --git a/src/Data/Aeson/Types/ToJSON.hs b/src/Data/Aeson/Types/ToJSON.hs index 7de887593..1fabc07c6 100644 --- a/src/Data/Aeson/Types/ToJSON.hs +++ b/src/Data/Aeson/Types/ToJSON.hs @@ -839,8 +839,12 @@ instance ToJSON1 f => GToJSON' Value One (Rec1 f) where {-# INLINE gToJSON #-} instance GToJSON' Value arity U1 where - -- Empty constructors are encoded to an empty array: - gToJSON _opts _ _ = emptyArray + -- Empty constructors are encoded to an empty array or an empty object, + -- depending on nullaryToObject option (default is array) + gToJSON opts _ _ + | nullaryToObject opts = emptyObject + | otherwise = emptyArray + {-# INLINE gToJSON #-} instance ( WriteProduct arity a, WriteProduct arity b @@ -893,8 +897,11 @@ instance ToJSON1 f => GToJSON' Encoding One (Rec1 f) where {-# INLINE gToJSON #-} instance GToJSON' Encoding arity U1 where - -- Empty constructors are encoded to an empty array: - gToJSON _opts _ _ = E.emptyArray_ + -- Empty constructors are encoded to an empty array or an empty object, + -- depending on nullaryToObject option (default is array) + gToJSON opts _ _ + | nullaryToObject opts = E.emptyObject_ + | otherwise = E.emptyArray_ {-# INLINE gToJSON #-} instance ( EncodeProduct arity a diff --git a/tests/Encoders.hs b/tests/Encoders.hs index d8099331f..783c8e5d6 100644 --- a/tests/Encoders.hs +++ b/tests/Encoders.hs @@ -60,6 +60,37 @@ thNullaryToEncodingObjectWithSingleField = thNullaryParseJSONObjectWithSingleField :: Value -> Parser Nullary thNullaryParseJSONObjectWithSingleField = $(mkParseJSON optsObjectWithSingleField ''Nullary) + +thNullaryToJSONOWSFRejectUnknown :: Nullary -> Value +thNullaryToJSONOWSFRejectUnknown = $(mkToJSON optsOWSFRejectUnknown ''Nullary) + +thNullaryToEncodingOWSFRejectUnknown :: Nullary -> Encoding +thNullaryToEncodingOWSFRejectUnknown = $(mkToEncoding optsOWSFRejectUnknown ''Nullary) + +thNullaryParseJSONOWSFRejectUnknown :: Value -> Parser Nullary +thNullaryParseJSONOWSFRejectUnknown = $(mkParseJSON optsOWSFRejectUnknown ''Nullary) + + +thNullaryToJSONOWSFNullaryToObject :: Nullary -> Value +thNullaryToJSONOWSFNullaryToObject = $(mkToJSON optsOWSFNullaryToObject ''Nullary) + +thNullaryToEncodingOWSFNullaryToObject :: Nullary -> Encoding +thNullaryToEncodingOWSFNullaryToObject = $(mkToEncoding optsOWSFNullaryToObject ''Nullary) + +thNullaryParseJSONOWSFNullaryToObject :: Value -> Parser Nullary +thNullaryParseJSONOWSFNullaryToObject = $(mkParseJSON optsOWSFNullaryToObject ''Nullary) + + +thNullaryToJSONOWSFNullaryToObjectRejectUnknown :: Nullary -> Value +thNullaryToJSONOWSFNullaryToObjectRejectUnknown = $(mkToJSON optsOWSFNullaryToObjectRejectUnknown ''Nullary) + +thNullaryToEncodingOWSFNullaryToObjectRejectUnknown :: Nullary -> Encoding +thNullaryToEncodingOWSFNullaryToObjectRejectUnknown = $(mkToEncoding optsOWSFNullaryToObjectRejectUnknown ''Nullary) + +thNullaryParseJSONOWSFNullaryToObjectRejectUnknown :: Value -> Parser Nullary +thNullaryParseJSONOWSFNullaryToObjectRejectUnknown = $(mkParseJSON optsOWSFNullaryToObjectRejectUnknown ''Nullary) + + gNullaryToJSONString :: Nullary -> Value gNullaryToJSONString = genericToJSON defaultOptions @@ -99,6 +130,37 @@ gNullaryToEncodingObjectWithSingleField = genericToEncoding optsObjectWithSingle gNullaryParseJSONObjectWithSingleField :: Value -> Parser Nullary gNullaryParseJSONObjectWithSingleField = genericParseJSON optsObjectWithSingleField + +gNullaryToJSONOWSFRejectUnknown :: Nullary -> Value +gNullaryToJSONOWSFRejectUnknown = genericToJSON optsOWSFRejectUnknown + +gNullaryToEncodingOWSFRejectUnknown :: Nullary -> Encoding +gNullaryToEncodingOWSFRejectUnknown = genericToEncoding optsOWSFRejectUnknown + +gNullaryParseJSONOWSFRejectUnknown :: Value -> Parser Nullary +gNullaryParseJSONOWSFRejectUnknown = genericParseJSON optsOWSFRejectUnknown + + +gNullaryToJSONOWSFNullaryToObject :: Nullary -> Value +gNullaryToJSONOWSFNullaryToObject = genericToJSON optsOWSFNullaryToObject + +gNullaryToEncodingOWSFNullaryToObject :: Nullary -> Encoding +gNullaryToEncodingOWSFNullaryToObject = genericToEncoding optsOWSFNullaryToObject + +gNullaryParseJSONOWSFNullaryToObject :: Value -> Parser Nullary +gNullaryParseJSONOWSFNullaryToObject = genericParseJSON optsOWSFNullaryToObject + + +gNullaryToJSONOWSFNullaryToObjectRejectUnknown :: Nullary -> Value +gNullaryToJSONOWSFNullaryToObjectRejectUnknown = genericToJSON optsOWSFNullaryToObjectRejectUnknown + +gNullaryToEncodingOWSFNullaryToObjectRejectUnknown :: Nullary -> Encoding +gNullaryToEncodingOWSFNullaryToObjectRejectUnknown = genericToEncoding optsOWSFNullaryToObjectRejectUnknown + +gNullaryParseJSONOWSFNullaryToObjectRejectUnknown :: Value -> Parser Nullary +gNullaryParseJSONOWSFNullaryToObjectRejectUnknown = genericParseJSON optsOWSFNullaryToObjectRejectUnknown + + keyOptions :: JSONKeyOptions keyOptions = defaultJSONKeyOptions { keyModifier = ('k' :) } diff --git a/tests/Options.hs b/tests/Options.hs index 8618750e7..2f636dad7 100644 --- a/tests/Options.hs +++ b/tests/Options.hs @@ -34,6 +34,28 @@ optsObjectWithSingleField = optsDefault , sumEncoding = ObjectWithSingleField } +optsOWSFRejectUnknown :: Options +optsOWSFRejectUnknown = optsDefault + { allNullaryToStringTag = False + , rejectUnknownFields = True + , sumEncoding = ObjectWithSingleField + } + +optsOWSFNullaryToObject :: Options +optsOWSFNullaryToObject = optsDefault + { allNullaryToStringTag = False + , sumEncoding = ObjectWithSingleField + , nullaryToObject = True + } + +optsOWSFNullaryToObjectRejectUnknown :: Options +optsOWSFNullaryToObjectRejectUnknown = optsDefault + { allNullaryToStringTag = False + , rejectUnknownFields = True + , sumEncoding = ObjectWithSingleField + , nullaryToObject = True + } + optsOmitNothingFields :: Options optsOmitNothingFields = optsDefault { omitNothingFields = True diff --git a/tests/UnitTests.hs b/tests/UnitTests.hs index 227504bb2..278d2ee1e 100644 --- a/tests/UnitTests.hs +++ b/tests/UnitTests.hs @@ -277,6 +277,7 @@ showOptions = ++ "fieldLabelModifier =~ \"exampleField\"" ++ ", constructorTagModifier =~ \"ExampleConstructor\"" ++ ", allNullaryToStringTag = True" + ++ ", nullaryToObject = False" ++ ", omitNothingFields = False" ++ ", allowOmittedFields = True" ++ ", sumEncoding = TaggedObject {tagFieldName = \"tag\", contentsFieldName = \"contents\"}" diff --git a/tests/UnitTests/NullaryConstructors.hs b/tests/UnitTests/NullaryConstructors.hs index 820f3c82c..4d3c051be 100644 --- a/tests/UnitTests/NullaryConstructors.hs +++ b/tests/UnitTests/NullaryConstructors.hs @@ -11,7 +11,7 @@ module UnitTests.NullaryConstructors import Prelude.Compat import Data.Aeson (decode, eitherDecode, fromEncoding, Value) -import Data.Aeson.Types (Parser, IResult (..), iparse) +import Data.Aeson.Types (Parser, IResult (..), JSONPathElement (..), iparse) import Data.ByteString.Builder (toLazyByteString) import Data.Foldable (for_) import Data.Maybe (fromJust) @@ -26,6 +26,8 @@ nullaryConstructors = , dec "\"C1\"" @=? gNullaryToJSONString C1 , dec "{\"c1\":[]}" @=? thNullaryToJSONObjectWithSingleField C1 , dec "{\"c1\":[]}" @=? gNullaryToJSONObjectWithSingleField C1 + , dec "{\"c1\":{}}" @=? gNullaryToJSONOWSFNullaryToObject C1 + , dec "{\"c1\":{}}" @=? thNullaryToJSONOWSFNullaryToObject C1 , dec "[\"c1\",[]]" @=? gNullaryToJSON2ElemArray C1 , dec "[\"c1\",[]]" @=? thNullaryToJSON2ElemArray C1 , dec "{\"tag\":\"c1\"}" @=? thNullaryToJSONTaggedObject C1 @@ -37,6 +39,8 @@ nullaryConstructors = , decE "[\"c1\",[]]" @=? enc (thNullaryToEncoding2ElemArray C1) , decE "{\"c1\":[]}" @=? enc (thNullaryToEncodingObjectWithSingleField C1) , decE "{\"c1\":[]}" @=? enc (gNullaryToEncodingObjectWithSingleField C1) + , decE "{\"c1\":{}}" @=? enc (gNullaryToEncodingOWSFNullaryToObject C1) + , decE "{\"c1\":{}}" @=? enc (thNullaryToEncodingOWSFNullaryToObject C1) , decE "{\"tag\":\"c1\"}" @=? enc (thNullaryToEncodingTaggedObject C1) , decE "{\"tag\":\"c1\"}" @=? enc (gNullaryToEncodingTaggedObject C1) @@ -47,11 +51,33 @@ nullaryConstructors = , ISuccess C1 @=? parse gNullaryParseJSONString (dec "\"C1\"") , ISuccess C1 @=? parse thNullaryParseJSON2ElemArray (dec "[\"c1\",[]]") , ISuccess C1 @=? parse gNullaryParseJSON2ElemArray (dec "[\"c1\",[]]") + -- both object and empty array are accepted irrespective of the nullaryToObject flag option , ISuccess C1 @=? parse thNullaryParseJSONObjectWithSingleField (dec "{\"c1\":[]}") , ISuccess C1 @=? parse gNullaryParseJSONObjectWithSingleField (dec "{\"c1\":[]}") - -- Make sure that the old `"contents" : []' is still allowed + , thErrObject @=? parse thNullaryParseJSONObjectWithSingleField (dec "{\"c1\":{}}") + , gErrObject @=? parse gNullaryParseJSONObjectWithSingleField (dec "{\"c1\":{}}") + , thErrArray @=? parse thNullaryParseJSONOWSFNullaryToObject (dec "{\"c1\":[]}") + , gErrArray @=? parse gNullaryParseJSONOWSFNullaryToObject (dec "{\"c1\":[]}") + , ISuccess C1 @=? parse thNullaryParseJSONOWSFNullaryToObject (dec "{\"c1\":{}}") + , ISuccess C1 @=? parse gNullaryParseJSONOWSFNullaryToObject (dec "{\"c1\":{}}") + , ISuccess C1 @=? parse thNullaryParseJSONOWSFNullaryToObject (dec "{\"c1\":{\"extra\":1}}") + , ISuccess C1 @=? parse gNullaryParseJSONOWSFNullaryToObject (dec "{\"c1\":{\"extra\":1}}") + -- Make sure that the old `"contents" : []` is still allowed (and also `"contents" : {}`) , ISuccess C1 @=? parse thNullaryParseJSONTaggedObject (dec "{\"tag\":\"c1\",\"contents\":[]}") , ISuccess C1 @=? parse gNullaryParseJSONTaggedObject (dec "{\"tag\":\"c1\",\"contents\":[]}") + , ISuccess C1 @=? parse thNullaryParseJSONTaggedObject (dec "{\"tag\":\"c1\",\"contents\":{}}") + , ISuccess C1 @=? parse gNullaryParseJSONTaggedObject (dec "{\"tag\":\"c1\",\"contents\":{}}") + -- with rejectUnknownFields object must be empty + , ISuccess C1 @=? parse thNullaryParseJSONOWSFRejectUnknown (dec "{\"c1\":[]}") + , ISuccess C1 @=? parse gNullaryParseJSONOWSFRejectUnknown (dec "{\"c1\":[]}") + , thErrObject @=? parse thNullaryParseJSONOWSFRejectUnknown (dec "{\"c1\":{}}") + , gErrObject @=? parse gNullaryParseJSONOWSFRejectUnknown (dec "{\"c1\":{}}") + , thErrArray @=? parse thNullaryParseJSONOWSFNullaryToObjectRejectUnknown (dec "{\"c1\":[]}") + , gErrArray @=? parse gNullaryParseJSONOWSFNullaryToObjectRejectUnknown (dec "{\"c1\":[]}") + , ISuccess C1 @=? parse thNullaryParseJSONOWSFNullaryToObjectRejectUnknown (dec "{\"c1\":{}}") + , ISuccess C1 @=? parse gNullaryParseJSONOWSFNullaryToObjectRejectUnknown (dec "{\"c1\":{}}") + , thErrUnknown @=? parse thNullaryParseJSONOWSFNullaryToObjectRejectUnknown (dec "{\"c1\":{\"extra\":1}}") + , gErrUnknown @=? parse gNullaryParseJSONOWSFNullaryToObjectRejectUnknown (dec "{\"c1\":{\"extra\":1}}") , for_ [("kC1", C1), ("kC2", C2), ("kC3", C3)] $ \(jkey, key) -> do Right jkey @=? gNullaryToJSONKey key @@ -65,3 +91,9 @@ nullaryConstructors = decE = eitherDecode parse :: (a -> Parser b) -> a -> IResult b parse parsejson v = iparse parsejson v + thErrObject = IError [] "When parsing the constructor C1 of type Types.Nullary expected Array but got Object." + gErrObject = IError [Key "c1"] "parsing Types.Nullary(C1) failed, expected Array, but encountered Object" + thErrArray = IError [] "When parsing the constructor C1 of type Types.Nullary expected Object but got Array." + gErrArray = IError [Key "c1"] "parsing Types.Nullary(C1) failed, expected Object, but encountered Array" + thErrUnknown = IError [] "When parsing the constructor C1 of type Types.Nullary expected an empty Object but got Object of size 1." + gErrUnknown = IError [Key "c1"] "parsing Types.Nullary(C1) failed, expected an empty Object but encountered Object of size 1"