Barbie and KenJSON
After higher-kinded data (HKD) and barbies were mentioned in episode 35 of Haskell Weekly I've been wondering if it could be used in combination with aeson to do validation when implementing web services.
TLDR; I think it'd work, but I have a feeling I'd have to spend some more time on it to get an API with nice ergonomics.
Defining a type to play with
I opted to use barbies-th to save on the typing a bit. Defining a simple type holding a name and an age can then look like this
declareBareB [d| data Person = Person {name :: Text, age :: Int} |] deriving instance Show (Person Covered Identity) deriving instance Show (Person Covered Maybe) deriving instance Show (Person Covered (Either Text))
The two functions from the Barbies
module documentation, addDefaults
and
check
, can then be written like this
addDefaults :: Person Covered Maybe -> Person Covered Identity -> Person Covered Identity addDefaults = bzipWith trans where trans m d = maybe d pure m check :: Person Covered (Either Text) -> Either [Text] (Person Covered Identity) check pe = case btraverse (either (const Nothing) (Just . Identity)) pe of Just pin -> Right pin Nothing -> Left $ bfoldMap (either (: []) (const [])) pe
I found it straight forward to define some instances and play with those functions a bit.
Adding in JSON
The bit that wasn't immediately obvious to me was how to use aeson to parse into
a type like Person Covered (Either Text)
.
First off I needed some data to test things out with.
bs0, bs1 :: BSL.ByteString bs0 = "{\"name\": \"the name\", \"age\": 17}" bs1 = "{\"name\": \"the name\", \"age\": true}"
To keep things simple I took baby steps, first I tried parsing into Person
Covered Identity
. It turns out that the FromJSON
instance from that doesn't
need much thought at all. (It's a bit of a pain to have to specify types in GHCi
all the time, so I'm throwing in a specialised decoding function for each type
too.)
instance FromJSON (Person Covered Identity) where parseJSON = withObject "Person" $ \o -> Person <$> o .: "name" <*> o .: "age" decodePI :: BSL.ByteString -> Maybe (Person Covered Identity) decodePI = decode
Trying it out on the test data gives the expected results
λ> let i0 = decodePI bs0 λ> i0 Just (Person {name = Identity "the name", age = Identity 17}) λ> let i1 = decodePI bs1 λ> i1 Nothing
So far so good! Moving onto Person Covered Maybe
. I spent some time trying to
use the combinators in Data.Aeson
for dealing with parser failures, but in the
end I had to resort to using <|>
from Alternative
.
instance FromJSON (Person Covered Maybe) where parseJSON = withObject "Person" $ \o -> Person <$> (o .: "name" <|> pure Nothing) <*> (o .: "age" <|> pure Nothing) decodePM :: BSL.ByteString -> Maybe (Person Covered Maybe) decodePM = decode
Trying that out I saw exactly the behaviour I expected, i.e. that parsing won't fail. (Well, at least not as long as it's a valid JSON object to being with.)
λ> let m0 = decodePM bs0 λ> m0 Just (Person {name = Just "the name", age = Just 17}) λ> let m1 = decodePM bs1 λ> m1 Just (Person {name = Just "the name", age = Nothing})
With that done I found that the instance for Person Covered (Either Text)
followed quite naturally. I had to spend a little time on getting the types
right to parse the fields properly. Somewhat disappointingly I didn't get type
errors when the behaviour of the code turned out to be wrong. I'm gussing
aeson's Parser
was a little too willing to give me parser failures. Anyway, I
ended up with this instance
instance FromJSON (Person Covered (Either Text)) where parseJSON = withObject "Person" $ \o -> Person <$> ((Right <$> o .: "name") <|> pure (Left "A name is most needed")) <*> ((Right <$> o .: "age") <|> pure (Left "An integer age is needed")) decodePE :: BSL.ByteString -> Maybe (Person Covered (Either Text)) decodePE = decode
That does exhibit the behaviour I want
λ> let e0 = decodePE bs0 λ> e0 Just (Person {name = Right "the name", age = Right 17}) λ> let e1 = decodePE bs1 λ> e1 Just (Person {name = Right "the name", age = Left "An integer age is needed"})
In closing
I think everyone will agree that the FromJSON
instances are increasingly
messy. I think that can be fixed by putting some thought into what a more
pleasing API should look like.
I'd also like to mix in validation beyond what aeson offers out-of-the-box,
which really only is "is the field present?" and "does the value have the
correct type?". For instance, Once we know there is a field called age
, and
that it's an Int
, then we might want to make sure it's non-negitive, or that
the person is at least 18. I'm guessing that wouldn't be too difficult.
Finally, I'd love to see examples of using HKDs for parsing/validation in the wild. It's probably easiest to reach me at @magthe@mastodon.technology.