on master help sign in
Merge pull request #711 from Lysxia/generic-key

Generic implementations for FromJSONKey and ToJSONKey

commit57cddd031849d995f3470d022562f892a9265a62 authorAdam Bergmark <adam@bergmark.nl> dateThu 20 Jun 2019, 17:14 (7 years ago) parent72e9b16cb3d8ae
8 files changed+195−19
Data/Aeson.hs+9 Data/Aeson/Types.hs+11 Data/Aeson/Types/Class.hs+5 Data/Aeson/Types/FromJSON.hs+70−16 Data/Aeson/Types/Internal.hs+39 Data/Aeson/Types/ToJSON.hs+43−3 tests/Encoders.hs+13 tests/UnitTests/NullaryConstructors.hs+5
Data/Aeson.hs+9
@@ -70,6 +70,11 @@ module Data.Aeson
7070 , ToJSONKeyFunction(..)
7171 , FromJSONKey(..)
7272 , FromJSONKeyFunction(..)
73+ -- *** Generic keys
74+ , GToJSONKey()
75+ , genericToJSONKey
76+ , GFromJSONKey()
77+ , genericFromJSONKey
7378 -- ** Liftings to unary and binary type constructors
7479 , FromJSON1(..)
7580 , parseJSON1
@@ -111,6 +116,10 @@ module Data.Aeson
111116 , SumEncoding(..)
112117 , camelTo2
113118 , defaultTaggedObject
119+ -- ** Options for object keys
120+ , JSONKeyOptions
121+ , keyModifier
122+ , defaultJSONKeyOptions
114123
115124 -- * Inspecting @'Value's@
116125 , withObject
Data/Aeson/Types.hs+11
@@ -53,6 +53,12 @@ module Data.Aeson.Types
5353 , coerceFromJSONKeyFunction
5454 , mapFromJSONKeyFunction
5555
56+ -- *** Generic keys
57+ , GToJSONKey()
58+ , genericToJSONKey
59+ , GFromJSONKey()
60+ , genericFromJSONKey
61+
5662 -- ** Liftings to unary and binary type constructors
5763 , FromJSON1(..)
5864 , parseJSON1
@@ -125,6 +131,11 @@ module Data.Aeson.Types
125131 , camelTo2
126132 , defaultOptions
127133 , defaultTaggedObject
134+
135+ -- ** Options for object keys
136+ , JSONKeyOptions
137+ , keyModifier
138+ , defaultJSONKeyOptions
128139 ) where
129140
130141import Prelude.Compat
Data/Aeson/Types/Class.hs+5
@@ -58,6 +58,11 @@ module Data.Aeson.Types.Class
5858 , fromJSONKeyCoerce
5959 , coerceFromJSONKeyFunction
6060 , mapFromJSONKeyFunction
61+ -- ** Generic keys
62+ , GToJSONKey()
63+ , genericToJSONKey
64+ , GFromJSONKey()
65+ , genericFromJSONKey
6166 -- * Object key-value pairs
6267 , KeyValue(..)
6368
Data/Aeson/Types/FromJSON.hs+70−16
@@ -45,6 +45,9 @@ module Data.Aeson.Types.FromJSON
4545 , coerceFromJSONKeyFunction
4646 , mapFromJSONKeyFunction
4747
48+ , GFromJSONKey()
49+ , genericFromJSONKey
50+
4851 -- * List functions
4952 , listParser
5053
@@ -406,6 +409,16 @@ class FromJSON a where
406409-- > newtype SomeId = SomeId { getSomeId :: Text }
407410-- > deriving (Eq,Ord,Hashable,FromJSONKey)
408411--
412+-- If you have a sum of nullary constructors, you may use the generic
413+-- implementation:
414+--
415+-- @
416+-- data Color = Red | Green | Blue
417+-- deriving Generic
418+--
419+-- instance 'FromJSONKey' Color where
420+-- 'fromJSONKey' = 'genericFromJSONKey' 'defaultJSONKeyOptions'
421+-- @
409422class FromJSONKey a where
410423 -- | Strategy for parsing the key of a map-like container.
411424 fromJSONKey :: FromJSONKeyFunction a
@@ -504,6 +517,35 @@ coerceFromJSONKeyFunction (FromJSONKeyValue f) = FromJSONKeyValue (fma
504517mapFromJSONKeyFunction :: (a -> b) -> FromJSONKeyFunction a -> FromJSONKeyFunction b
505518mapFromJSONKeyFunction = fmap
506519
520+-- | 'fromJSONKey' for 'Generic' types.
521+-- These types must be sums of nullary constructors, whose names will be used
522+-- as keys for JSON objects.
523+--
524+-- See also 'genericToJSONKey'.
525+--
526+-- === __Example__
527+--
528+-- @
529+-- data Color = Red | Green | Blue
530+-- deriving 'Generic'
531+--
532+-- instance 'FromJSONKey' Color where
533+-- 'fromJSONKey' = 'genericFromJSONKey' 'defaultJSONKeyOptions'
534+-- @
535+genericFromJSONKey :: forall a. (Generic a, GFromJSONKey (Rep a))
536+ => JSONKeyOptions
537+ -> FromJSONKeyFunction a
538+genericFromJSONKey opts = FromJSONKeyTextParser $ \t ->
539+ case parseSumFromString (keyModifier opts) t of
540+ Nothing -> fail $
541+ "invalid key " ++ show t ++ ", expected one of " ++ show cnames
542+ Just k -> pure (to k)
543+ where
544+ cnames = unTagged2 (constructorTags (keyModifier opts) :: Tagged2 (Rep a) [String])
545+
546+class (ConstructorNames f, SumFromString f) => GFromJSONKey f where
547+instance (ConstructorNames f, SumFromString f) => GFromJSONKey f where
548+
507549-------------------------------------------------------------------------------
508550-- Functions needed for documentation
509551-------------------------------------------------------------------------------
@@ -1020,41 +1062,46 @@ parseAllNullarySum :: (SumFromString f, ConstructorNames f)
10201062parseAllNullarySum tname opts =
10211063 withText tname $ \tag ->
10221064 maybe (badTag tag) return $
1023− parseSumFromString opts tag
1065+ parseSumFromString modifier tag
10241066 where
1025− badTag tag = failWithCTags tname opts $ \cnames ->
1067+ badTag tag = failWithCTags tname modifier $ \cnames ->
10261068 "expected one of the tags " ++ show cnames ++
10271069 ", but found tag " ++ show tag
1070+ modifier = constructorTagModifier opts
10281071
10291072-- | Fail with an informative error message about a mismatched tag.
10301073-- The error message is parameterized by the list of expected tags,
10311074-- to be inferred from the result type of the parser.
10321075failWithCTags
1033− :: forall f a. ConstructorNames f
1034− => TypeName -> Options -> ([String] -> String) -> Parser (f a)
1035−failWithCTags tname opts f =
1076+ :: forall f a t. ConstructorNames f
1077+ => TypeName -> (String -> t) -> ([t] -> String) -> Parser (f a)
1078+failWithCTags tname modifier f =
10361079 contextType tname . fail $ f cnames
10371080 where
1038− cnames = unTagged2 (constructorTags opts :: Tagged2 f [String])
1081+ cnames = unTagged2 (constructorTags modifier :: Tagged2 f [t])
10391082
10401083class SumFromString f where
1041− parseSumFromString :: Options -> Text -> Maybe (f a)
1084+ parseSumFromString :: (String -> String) -> Text -> Maybe (f a)
10421085
10431086instance (SumFromString a, SumFromString b) => SumFromString (a :+: b) where
10441087 parseSumFromString opts key = (L1 <$> parseSumFromString opts key) <|>
10451088 (R1 <$> parseSumFromString opts key)
10461089
10471090instance (Constructor c) => SumFromString (C1 c U1) where
1048− parseSumFromString opts key | key == name = Just $ M1 U1
1049− | otherwise = Nothing
1050− where
1051− name = pack $ constructorTagModifier opts $
1052− conName (undefined :: M1 _i c _f _p)
1091+ parseSumFromString modifier key
1092+ | key == name = Just $ M1 U1
1093+ | otherwise = Nothing
1094+ where
1095+ name = pack $ modifier $ conName (undefined :: M1 _i c _f _p)
1096+
1097+-- For genericFromJSONKey
1098+instance SumFromString a => SumFromString (D1 d a) where
1099+ parseSumFromString modifier key = M1 <$> parseSumFromString modifier key
10531100
10541101-- | List of all constructor tags.
1055−constructorTags :: ConstructorNames a => Options -> Tagged2 a [String]
1056−constructorTags opts =
1057− fmap DList.toList (constructorNames' (constructorTagModifier opts))
1102+constructorTags :: ConstructorNames a => (String -> t) -> Tagged2 a [t]
1103+constructorTags modifier =
1104+ fmap DList.toList (constructorNames' modifier)
10581105
10591106-- | List of all constructor names of an ADT, after a given conversion
10601107-- function. (Better inlining.)
@@ -1075,6 +1122,13 @@ instance Constructor c => ConstructorNames (C1 c a) where
10751122 where
10761123 cname = conName (undefined :: M1 _i c _f _p)
10771124
1125+-- For genericFromJSONKey
1126+instance ConstructorNames a => ConstructorNames (D1 d a) where
1127+ constructorNames' = retag . constructorNames'
1128+ where
1129+ retag :: Tagged2 a u -> Tagged2 (D1 d a) u
1130+ retag (Tagged2 x) = Tagged2 x
1131+
10781132--------------------------------------------------------------------------------
10791133
10801134parseNonAllNullarySum :: ( FromPair arity f
@@ -1126,7 +1180,7 @@ parseNonAllNullarySum p@(tname :* opts :* _) =
11261180
11271181 UntaggedValue -> parseUntaggedValue p
11281182 where
1129− failWith_ = failWithCTags tname opts
1183+ failWith_ = failWithCTags tname (constructorTagModifier opts)
11301184
11311185--------------------------------------------------------------------------------
11321186
Data/Aeson/Types/Internal.hs+39
@@ -65,8 +65,10 @@ module Data.Aeson.Types.Internal
6565 )
6666
6767 , SumEncoding(..)
68+ , JSONKeyOptions(keyModifier)
6869 , defaultOptions
6970 , defaultTaggedObject
71+ , defaultJSONKeyOptions
7072
7173 -- * Used for changing CamelCase names into something else.
7274 , camelTo
@@ -665,6 +667,33 @@ data SumEncoding =
665667 -- contents of the constructor.
666668 deriving (Eq, Show)
667669
670+-- | Options for encoding keys with 'Data.Aeson.Types.genericFromJSONKey' and
671+-- 'Data.Aeson.Types.genericToJSONKey'.
672+data JSONKeyOptions = JSONKeyOptions
673+ { keyModifier :: String -> String
674+ -- ^ Function applied to keys. Its result is what goes into the encoded
675+ -- 'Value'.
676+ --
677+ -- === __Example__
678+ --
679+ -- The following instances encode the constructor @Bar@ to lower-case keys
680+ -- @\"bar\"@.
681+ --
682+ -- @
683+ -- data Foo = Bar
684+ -- deriving 'Generic'
685+ --
686+ -- opts :: 'JSONKeyOptions'
687+ -- opts = 'defaultJSONKeyOptions' { 'keyModifier' = 'toLower' }
688+ --
689+ -- instance 'ToJSONKey' Foo where
690+ -- 'toJSONKey' = 'genericToJSONKey' opts
691+ --
692+ -- instance 'FromJSONKey' Foo where
693+ -- 'fromJSONKey' = 'genericFromJSONKey' opts
694+ -- @
695+ }
696+
668697-- | Default encoding 'Options':
669698--
670699-- @
@@ -703,6 +732,16 @@ defaultTaggedObject = TaggedObject
703732 , contentsFieldName = "contents"
704733 }
705734
735+-- | Default 'JSONKeyOptions':
736+--
737+-- @
738+-- defaultJSONKeyOptions = 'JSONKeyOptions'
739+-- { 'keyModifier' = 'id'
740+-- }
741+-- @
742+defaultJSONKeyOptions :: JSONKeyOptions
743+defaultJSONKeyOptions = JSONKeyOptions id
744+
706745-- | Converts from CamelCase to another lower case, interspersing
707746-- the character between all capital letters and their previous
708747-- entries, except those capital letters that appear together,
Data/Aeson/Types/ToJSON.hs+43−3
@@ -47,6 +47,10 @@ module Data.Aeson.Types.ToJSON
4747 , ToJSONKeyFunction(..)
4848 , toJSONKeyText
4949 , contramapToJSONKeyFunction
50+
51+ , GToJSONKey()
52+ , genericToJSONKey
53+
5054 -- * Object key-value pairs
5155 , KeyValue(..)
5256 , KeyValuePair(..)
@@ -411,7 +415,7 @@ instance KeyValue Object where
411415-- newtype wrapper around 'Text'. The recommended approach is to use
412416-- generalized newtype deriving:
413417--
414−-- > newtype RecordId = RecordId { getRecordId :: Text}
418+-- > newtype RecordId = RecordId { getRecordId :: Text }
415419-- > deriving (Eq,Ord,ToJSONKey)
416420--
417421-- Then we may write:
@@ -426,8 +430,18 @@ instance KeyValue Object where
426430--
427431-- It is possible to get the 'ToJSONKey' instance for free as we did
428432-- with 'Foo'. However, in this case, we have a natural way to go to
429−-- and from 'Text' that does not require any escape sequences. So, in
430−-- this example, 'ToJSONKeyText' will be used instead of 'ToJSONKeyValue'.
433+-- and from 'Text' that does not require any escape sequences. So
434+-- 'ToJSONKeyText' can be used instead of 'ToJSONKeyValue' to encode maps
435+-- as objects instead of arrays of pairs. This instance may be
436+-- implemented using generics as follows:
437+--
438+-- @
439+-- instance 'ToJSONKey' Color where
440+-- 'toJSONKey' = 'genericToJSONKey' 'defaultJSONKeyOptions'
441+-- @
442+--
443+-- === __Low-level implementations__
444+--
431445-- The 'Show' instance can be used to help write 'ToJSONKey':
432446--
433447-- > instance ToJSONKey Color where
@@ -515,6 +529,29 @@ contramapToJSONKeyFunction h x = case x of
515529 ToJSONKeyText f g -> ToJSONKeyText (f . h) (g . h)
516530 ToJSONKeyValue f g -> ToJSONKeyValue (f . h) (g . h)
517531
532+-- 'toJSONKey' for 'Generic' types.
533+-- Deriving is supported for enumeration types, i.e. the sums of nullary
534+-- constructors. The names of constructors will be used as keys for JSON
535+-- objects.
536+--
537+-- See also 'genericFromJSONKey'.
538+--
539+-- === __Example__
540+--
541+-- @
542+-- data Color = Red | Green | Blue
543+-- deriving 'Generic'
544+--
545+-- instance 'ToJSONKey' Color where
546+-- 'toJSONKey' = 'genericToJSONKey' 'defaultJSONKeyOptions'
547+-- @
548+genericToJSONKey :: (Generic a, GToJSONKey (Rep a))
549+ => JSONKeyOptions -> ToJSONKeyFunction a
550+genericToJSONKey opts = toJSONKeyText (pack . keyModifier opts . getConName . from)
551+
552+class GetConName f => GToJSONKey f
553+instance GetConName f => GToJSONKey f
554+
518555-------------------------------------------------------------------------------
519556-- Lifings of FromJSON and ToJSON to unary and binary type constructors
520557-------------------------------------------------------------------------------
@@ -944,6 +981,9 @@ instance (GetConName a, GetConName b) => GetConName (a :+: b) where
944981instance (Constructor c) => GetConName (C1 c a) where
945982 getConName = conName
946983
984+-- For genericToJSONKey
985+instance GetConName a => GetConName (D1 d a) where
986+ getConName (M1 x) = getConName x
947987
948988--------------------------------------------------------------------------------
949989
tests/Encoders.hs+13
@@ -6,6 +6,7 @@
66module Encoders (module Encoders) where
77
88import Prelude.Compat
9+import Data.Text (Text)
910
1011import Data.Aeson.TH
1112import Data.Aeson.Types
@@ -96,6 +97,18 @@ gNullaryToEncodingObjectWithSingleField = genericToEncoding optsObjectWithSingle
9697gNullaryParseJSONObjectWithSingleField :: Value -> Parser Nullary
9798gNullaryParseJSONObjectWithSingleField = genericParseJSON optsObjectWithSingleField
9899
100+keyOptions :: JSONKeyOptions
101+keyOptions = defaultJSONKeyOptions { keyModifier = ('k' :) }
102+
103+gNullaryToJSONKey :: Nullary -> Either String Text
104+gNullaryToJSONKey x = case genericToJSONKey keyOptions of
105+ ToJSONKeyText p _ -> Right (p x)
106+ _ -> Left "Should be a ToJSONKeyText"
107+
108+gNullaryFromJSONKey :: Text -> Parser Nullary
109+gNullaryFromJSONKey t = case genericFromJSONKey keyOptions of
110+ FromJSONKeyTextParser p -> p t
111+ _ -> fail "Not a TextParser"
99112
100113--------------------------------------------------------------------------------
101114-- SomeType encoders/decoders
tests/UnitTests/NullaryConstructors.hs+5
@@ -14,6 +14,7 @@ import Data.Aeson (decode, eitherDecode, fromEncoding, Value)
1414import Data.Aeson.Internal (IResult (..), iparse)
1515import Data.Aeson.Types (Parser)
1616import Data.ByteString.Builder (toLazyByteString)
17+import Data.Foldable (for_)
1718import Data.Maybe (fromJust)
1819import Encoders
1920import Test.Tasty.HUnit ((@=?), Assertion)
@@ -52,6 +53,10 @@ nullaryConstructors =
5253 -- Make sure that the old `"contents" : []' is still allowed
5354 , ISuccess C1 @=? parse thNullaryParseJSONTaggedObject (dec "{\"tag\":\"c1\",\"contents\":[]}")
5455 , ISuccess C1 @=? parse gNullaryParseJSONTaggedObject (dec "{\"tag\":\"c1\",\"contents\":[]}")
56+
57+ , for_ [("kC1", C1), ("kC2", C2), ("kC3", C3)] $ \(jkey, key) -> do
58+ Right jkey @=? gNullaryToJSONKey key
59+ ISuccess key @=? parse gNullaryFromJSONKey jkey
5560 ]
5661 where
5762 enc = eitherDecode . toLazyByteString . fromEncoding