Skip to content

Commit ecdf0c2

Browse files
authored
Merge pull request #6 from fsoikin/track-unknown-props
Strict versions of record codecs that fail on unknown properties
2 parents 6ceaba2 + cbcb337 commit ecdf0c2

File tree

4 files changed

+296
-14
lines changed

4 files changed

+296
-14
lines changed

src/Data/Codec/JSON.purs

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -210,10 +210,13 @@ type PropCodec a =
210210
a
211211
a
212212

213-
-- | A codec for objects that are encoded with specific properties.
213+
-- | A codec for objects that are encoded with specific properties. This codec
214+
-- | will ignore any unknown properties in the incoming record. Use
215+
-- | `Data.Codec.JSON.Strict.objectStrict` for a version that fails upon
216+
-- | encountering unknown properties.
214217
-- |
215-
-- | See also `Data.Codec.JSON.Record.object` for a more commonly useful
216-
-- | version of this function.
218+
-- | See also `Data.Codec.JSON.Record.object` for a more commonly useful version
219+
-- | of this function.
217220
object a. PropCodec a Codec a
218221
object codec =
219222
Codec.codec'

src/Data/Codec/JSON/Record.purs

Lines changed: 80 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
module Data.Codec.JSON.Record where
22

33
import Data.Codec.JSON as CJ
4+
import Data.Codec.JSON.Strict as CJS
45
import Data.Maybe (Maybe)
56
import Data.Symbol (class IsSymbol)
67
import Prim.Row as R
@@ -26,6 +27,23 @@ object
2627
CJ.Codec (Record ro)
2728
object rec = CJ.object (record rec)
2829

30+
-- | A version of the `object` function that fails upon encountering unknown
31+
-- | properties while decoding a record.
32+
-- |
33+
-- | ```purescript
34+
-- | type Person = { name ∷ String, age ∷ Int }
35+
-- |
36+
-- | personCodec ∷ CJ.Codec Person
37+
-- | personCodec = CAR.objectStrict { name: CJ.string, age: CJ.int }
38+
-- | ```
39+
objectStrict
40+
ri ro rl
41+
. RL.RowToList ri rl
42+
RowListCodecStrict rl ri ro
43+
Record ri
44+
CJ.Codec (Record ro)
45+
objectStrict rec = CJS.objectStrict (recordStrict rec)
46+
2947
-- | Constructs a `JPropCodec` for a `Record` from a record of codecs. Commonly
3048
-- | the `object` function in this module will be the preferred choice, as that
3149
-- | produces a `Codec` instead.
@@ -35,7 +53,18 @@ record
3553
RowListCodec rl ri ro
3654
Record ri
3755
CJ.PropCodec (Record ro)
38-
record = rowListCodec (Proxy Proxy rl)
56+
record = rowListCodec @rl
57+
58+
-- | Constructs a `JPropCodec` for a `Record` from a record of codecs. Commonly
59+
-- | the `object` function in this module will be the preferred choice, as that
60+
-- | produces a `Codec` instead.
61+
recordStrict
62+
ri ro rl
63+
. RL.RowToList ri rl
64+
RowListCodecStrict rl ri ro
65+
Record ri
66+
CJS.PropCodec (Record ro)
67+
recordStrict = rowListCodecStrict @rl
3968

4069
-- | Used to wrap codec values provided in `record` to indicate the field is optional.
4170
-- |
@@ -53,39 +82,80 @@ optional = Optional
5382
-- | The class used to enable the building of `Record` codecs by providing a
5483
-- | record of codecs.
5584
class RowListCodec (rlRL.RowList Type) (riRow Type) (roRow Type) | rl ri ro where
56-
rowListCodec proxy. proxy rl Record ri CJ.PropCodec (Record ro)
85+
rowListCodec Record ri CJ.PropCodec (Record ro)
5786

58-
instance rowListCodecNilRowListCodec RL.Nil () () where
59-
rowListCodec _ _ = CJ.record
87+
instance RowListCodec RL.Nil () () where
88+
rowListCodec _ = CJ.record
6089

61-
instance rowListCodecConsOptional
90+
instance
6291
( RowListCodec rs ri' ro'
6392
, R.Cons sym (Optional a) ri' ri
6493
, R.Cons sym (Maybe a) ro' ro
6594
, IsSymbol sym
6695
)
6796
RowListCodec (RL.Cons sym (Optional a) rs) ri ro where
68-
rowListCodec _ codecs =
97+
rowListCodec codecs =
6998
CJ.recordPropOptional (Proxy Proxy sym) codec tail
7099
where
71100
codec CJ.Codec a
72101
codec = coerce (Rec.get (Proxy Proxy sym) codecs Optional a)
73102

74103
tail CJ.PropCodec (Record ro')
75-
tail = rowListCodec (Proxy Proxy rs) ((unsafeCoerce Record ri Record ri') codecs)
104+
tail = rowListCodec @rs ((unsafeCoerce Record ri Record ri') codecs)
76105

77-
else instance rowListCodecCons
106+
else instance
78107
( RowListCodec rs ri' ro'
79108
, R.Cons sym (CJ.Codec a) ri' ri
80109
, R.Cons sym a ro' ro
81110
, IsSymbol sym
82111
)
83112
RowListCodec (RL.Cons sym (CJ.Codec a) rs) ri ro where
84-
rowListCodec _ codecs =
113+
rowListCodec codecs =
85114
CJ.recordProp (Proxy Proxy sym) codec tail
86115
where
87116
codec CJ.Codec a
88117
codec = Rec.get (Proxy Proxy sym) codecs
89118

90119
tail CJ.PropCodec (Record ro')
91-
tail = rowListCodec (Proxy Proxy rs) ((unsafeCoerce Record ri Record ri') codecs)
120+
tail = rowListCodec @rs ((unsafeCoerce Record ri Record ri') codecs)
121+
122+
123+
-- | The class used to enable the building of `Record` codecs by providing a
124+
-- | record of codecs.
125+
class RowListCodecStrict (rlRL.RowList Type) (riRow Type) (roRow Type) | rl ri ro where
126+
rowListCodecStrict Record ri CJS.PropCodec (Record ro)
127+
128+
instance RowListCodecStrict RL.Nil () () where
129+
rowListCodecStrict _ = CJS.record
130+
131+
instance
132+
( RowListCodecStrict rs ri' ro'
133+
, R.Cons sym (Optional a) ri' ri
134+
, R.Cons sym (Maybe a) ro' ro
135+
, IsSymbol sym
136+
)
137+
RowListCodecStrict (RL.Cons sym (Optional a) rs) ri ro where
138+
rowListCodecStrict codecs =
139+
CJS.recordPropOptional (Proxy Proxy sym) codec tail
140+
where
141+
codec CJ.Codec a
142+
codec = coerce (Rec.get (Proxy Proxy sym) codecs Optional a)
143+
144+
tail CJS.PropCodec (Record ro')
145+
tail = rowListCodecStrict @rs ((unsafeCoerce Record ri Record ri') codecs)
146+
147+
else instance
148+
( RowListCodecStrict rs ri' ro'
149+
, R.Cons sym (CJ.Codec a) ri' ri
150+
, R.Cons sym a ro' ro
151+
, IsSymbol sym
152+
)
153+
RowListCodecStrict (RL.Cons sym (CJ.Codec a) rs) ri ro where
154+
rowListCodecStrict codecs =
155+
CJS.recordProp (Proxy Proxy sym) codec tail
156+
where
157+
codec CJ.Codec a
158+
codec = Rec.get (Proxy Proxy sym) codecs
159+
160+
tail CJS.PropCodec (Record ro')
161+
tail = rowListCodecStrict @rs ((unsafeCoerce Record ri Record ri') codecs)

src/Data/Codec/JSON/Strict.purs

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
module Data.Codec.JSON.Strict
2+
( PropCodec
3+
, ClaimedProps
4+
, objectStrict
5+
, prop
6+
, record
7+
, recordProp
8+
, recordPropOptional
9+
) where
10+
11+
import Prelude hiding ((<<<), (<=<), (>=>), (>>>))
12+
13+
import Data.Codec.JSON as CJ
14+
import Codec.JSON.DecodeError as Error
15+
import Control.Monad.Except (Except, lift, throwError, withExceptT)
16+
import Control.Monad.State (StateT, modify_, runStateT)
17+
import Data.Codec (Codec(..), codec, codec', decode, encode) as Codec
18+
import Data.List ((:))
19+
import Data.List as L
20+
import Data.Maybe (Maybe(..))
21+
import Data.Set as Set
22+
import Data.String as S
23+
import Data.Symbol (class IsSymbol, reflectSymbol)
24+
import Data.Tuple (Tuple(..))
25+
import JSON (JObject, JSON)
26+
import JSON.Object as JO
27+
import JSON.Path as JP
28+
import Prim.Row as Row
29+
import Record.Unsafe as Record
30+
import Type.Proxy (Proxy)
31+
import Unsafe.Coerce (unsafeCoerce)
32+
33+
-- | The set of properties that have been claimed "so far" during parsing of a
34+
-- | record. This is used internally to track which properties have been parsed
35+
-- | in order to determine which properties are unknown.
36+
type ClaimedProps = Set.Set String
37+
38+
-- | Codec type for `JObject` prop/value pairs.
39+
type PropCodec a =
40+
Codec.Codec
41+
(StateT ClaimedProps (Except Error.DecodeError))
42+
JObject
43+
(L.List (Tuple String JSON))
44+
a
45+
a
46+
47+
-- | A codec for objects that are encoded with specific properties. This codec
48+
-- | will fail upon encountering unknown properties in the incoming record. Use
49+
-- | `Data.Codec.JSON.object` for a version that ignores unknown properties.
50+
-- |
51+
-- | See also `Data.Codec.JSON.Record.objectStrict` for a more commonly useful
52+
-- | version of this function.
53+
objectStrict a. PropCodec a -> CJ.Codec a
54+
objectStrict codec = Codec.codec' dec enc
55+
where
56+
dec j = do
57+
obj <- Codec.decode CJ.jobject j
58+
Tuple rec claimedProps <- runStateT (Codec.decode codec obj) Set.empty
59+
60+
let unclaimedProps = Set.difference (Set.fromFoldable (JO.keys obj)) claimedProps
61+
when (not Set.isEmpty unclaimedProps) $
62+
throwError $ Error.error JP.Tip $
63+
"Unknown field(s): " <> S.joinWith ", " (Set.toUnfoldable unclaimedProps)
64+
65+
pure rec
66+
67+
enc a = Codec.encode CJ.jobject $ JO.fromFoldable $ Codec.encode codec a
68+
69+
-- | A codec for a property of an object.
70+
prop a. String CJ.Codec a PropCodec a
71+
prop key codec =
72+
Codec.codec
73+
( \obj → do
74+
v <- case JO.lookup key obj of
75+
Just j → lift $ withExceptT liftError $ Codec.decode codec j
76+
Nothing → throwError (Error.noValueFound (JP.AtKey key JP.Tip))
77+
modify_ $ Set.insert key
78+
pure v
79+
)
80+
(\a → pure (Tuple key (CJ.encode codec a)))
81+
where
82+
liftError = Error.withPath (JP.AtKey key)
83+
84+
85+
-- | The starting value for a object-record codec. Used with `recordProp` it
86+
-- | provides a convenient method for defining codecs for record types that
87+
-- | encode into JSON objects of the same shape.
88+
-- |
89+
-- | For example, to encode a record as the JSON object
90+
-- | `{ "name": "Karl", "age": 25 }` we would define a codec like this:
91+
-- | ```
92+
-- | import Data.Codec.JSON as CJ
93+
-- | import Type.Proxy (Proxy(..))
94+
-- |
95+
-- | type Person = { name ∷ String, age ∷ Int }
96+
-- |
97+
-- | codecPerson ∷ CJ.Codec Person
98+
-- | codecPerson =
99+
-- | CJ.object $ CJ.record
100+
-- | # CJ.recordProp (Proxy :: _ "name") CJ.string
101+
-- | # CJ.recordProp (Proxy :: _ "age") CJ.int
102+
-- | ```
103+
-- |
104+
-- | See also `Data.Codec.JSON.Record.object` for a more commonly useful
105+
-- | version of this function.
106+
record PropCodec {}
107+
record = Codec.Codec (const (pure {})) pure
108+
109+
-- | Used with `record` to define codecs for record types that encode into JSON
110+
-- | objects of the same shape. See the comment on `record` for an example.
111+
recordProp
112+
p a r r'
113+
. IsSymbol p
114+
Row.Cons p a r r'
115+
Proxy p
116+
CJ.Codec a
117+
PropCodec (Record r)
118+
PropCodec (Record r')
119+
recordProp p codecA codecR = Codec.codec dec enc
120+
where
121+
key = reflectSymbol p
122+
liftError = Error.withPath (JP.AtKey key)
123+
124+
dec JObject -> StateT _ (Except Error.DecodeError) (Record r')
125+
dec obj = do
126+
r <- Codec.decode codecR obj
127+
a :: a <- case JO.lookup key obj of
128+
Just val -> lift $ withExceptT liftError $ Codec.decode codecA val
129+
Nothing -> throwError $ Error.noValueFound (JP.AtKey key JP.Tip)
130+
modify_ $ Set.insert key
131+
pure $ Record.unsafeSet key a r
132+
133+
enc Record r' -> L.List (Tuple String JSON)
134+
enc val =
135+
Tuple key (Codec.encode codecA (Record.unsafeGet key val))
136+
: Codec.encode codecR ((unsafeCoerce Record r' -> Record r) val)
137+
138+
-- | Used with `record` to define an optional field.
139+
-- |
140+
-- | This will only decode the property as `Nothing` if the field does not exist
141+
-- | in the object - having a values such as `null` assigned will need handling
142+
-- | separately.
143+
-- |
144+
-- | The property will be omitted when encoding and the value is `Nothing`.
145+
recordPropOptional
146+
p a r r'
147+
. IsSymbol p
148+
Row.Cons p (Maybe a) r r'
149+
Proxy p
150+
CJ.Codec a
151+
PropCodec (Record r)
152+
PropCodec (Record r')
153+
recordPropOptional p codecA codecR = Codec.codec dec enc
154+
where
155+
key = reflectSymbol p
156+
liftError = Error.withPath (JP.AtKey key)
157+
158+
dec JObject -> StateT _ (Except Error.DecodeError) (Record r')
159+
dec obj = do
160+
r <- Codec.decode codecR obj
161+
a :: Maybe a <- case JO.lookup key obj of
162+
Just val -> lift $ withExceptT liftError $ Just <$> Codec.decode codecA val
163+
Nothing -> pure Nothing
164+
modify_ $ Set.insert key
165+
pure $ Record.unsafeSet key a r
166+
167+
enc Record r' -> L.List (Tuple String JSON)
168+
enc val = do
169+
let r = Codec.encode codecR ((unsafeCoerce Record r' -> Record r) val)
170+
case Record.unsafeGet key val of
171+
Nothing -> r
172+
Just val' -> Tuple key (Codec.encode codecA val') : r

test/Test/Record.purs

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,19 @@ module Test.Record where
22

33
import Prelude
44

5+
import Codec.JSON.DecodeError as Error
56
import Control.Monad.Gen as Gen
67
import Control.Monad.Gen.Common as GenC
7-
import Data.Codec.JSON.Common as CJ
8+
import Data.Bifunctor (lmap)
9+
import Data.Codec.JSON.Common (Codec, boolean, decode, encode, int, maybe, object, string) as CJ
810
import Data.Codec.JSON.Record as CJR
11+
import Data.Codec.JSON.Strict as CJS
12+
import Data.Either (Either(..))
913
import Data.Maybe (Maybe(..))
1014
import Data.Newtype (class Newtype, unwrap, wrap)
1115
import Data.Profunctor (dimap)
1216
import Data.String.Gen (genAsciiString)
17+
import Data.Tuple.Nested ((/\))
1318
import Effect (Effect)
1419
import Effect.Console (log)
1520
import JSON as J
@@ -62,6 +67,14 @@ innerCodec =
6267
, o: CJR.optional CJ.boolean
6368
}
6469

70+
innerCodecStrict CJ.Codec InnerR
71+
innerCodecStrict =
72+
CJS.objectStrict $ CJR.recordStrict
73+
{ n: CJ.int
74+
, m: CJ.boolean
75+
, o: CJR.optional CJ.boolean
76+
}
77+
6578
genOuter Gen OuterR
6679
genOuter = do
6780
a ← genInt
@@ -94,4 +107,28 @@ main = do
94107
let obj = J.toJObject $ CJ.encode innerCodec (v { o = Just b })
95108
pure $ assertEquals (Just [ "m", "n", "o" ]) (JO.keys <$> obj)
96109

110+
log "Check ignoring unrecognized fields"
111+
quickCheckGen do
112+
b ← Gen.chooseBool
113+
n ← genInt
114+
let obj = J.fromJObject $ JO.fromEntries
115+
[ "m" /\ J.fromBoolean b
116+
, "n" /\ J.fromInt n
117+
, "bogus" /\ J.fromInt 42
118+
]
119+
pure $ assertEquals (CJ.decode innerCodec obj) (Right { m: b, n, o: Nothing })
120+
121+
log "Check failing on unrecognized fields"
122+
quickCheckGen do
123+
b ← Gen.chooseBool
124+
n ← genInt
125+
let obj = J.fromJObject $ JO.fromEntries
126+
[ "m" /\ J.fromBoolean b
127+
, "n" /\ J.fromInt n
128+
, "bogus" /\ J.fromInt 42
129+
]
130+
pure $ assertEquals
131+
(lmap Error.print $ CJ.decode innerCodecStrict obj)
132+
(Left "Unknown field(s): bogus")
133+
97134
pure unit

0 commit comments

Comments
 (0)