Search code examples
jsonhaskellaeson

Prevent unknown field names in Aeson parseJSON


With the following type and instance deriving:

{-# LANGUAGE RecordWildCards #-}

import           Data.Aeson
import           Data.Text

data MyParams = MyParams {
    mpFoo :: Maybe Text,
    mpBar :: Maybe Text
} deriving Show

instance FromJSON MyParams where
    parseJSON = withObject "MyParams" $ \q -> do
        mpFoo <- q .:? "foo"
        mpBar <- q .:? "bar"
        pure MyParams {..}

How can I make sure that the following JSON would fail?

{
  "foo": "this is a valid field name",
  "baa": "this is an invalid field name"
}

With the code above, this JSON succeeds because 1. bar is optional, so parseJSON doesn't complain if it doesn't find it, and 2. baa will not throw any error but will instead be ignored. The combination of (1) and (2) means that typos in field names can't be caught and will be silently accepted, despite generating an incorrect result (MyParams { foo = Just(this is a valid field name), bar = Nothing }).

As a matter of fact, this JSON string should also fail:

{
  "foo": "this is fine",
  "bar": "this is fine",
  "xyz": "should trigger failure but doesn't with the above code"
}

TL;DR: how can I make parseJSON fail when the JSON contains any field name that doesn't match either foo or bar?


Solution

  • Don't forget that the q you have access to in withObject is just a HashMap. So, you can write:

    import qualified Data.HashMap.Strict as HM
    import qualified Data.HashSet as HS
    import Control.Monad (guard)
    
    instance FromJSON MyParams where
        parseJSON = withObject "MyParams" $ \q -> do
            mpFoo <- q .:? "foo"
            mpBar <- q .:? "bar"
            guard $ HM.keysSet q `HS.isSubsetOf` HS.fromList ["foo","bar"]
            pure MyParams {..}
    

    This will guard that the json only has at most the elements "foo" and "bar".

    But, this does feel like overkill considering aeson gives you all of this for free. If you can derive Generic, then you can just call genericParseJSON, as in:

    {-# LANGUAGE DeriveGeneric #-}
    
    data MyParams = MyParams {
        mpFoo :: Maybe Text,
        mpBar :: Maybe Text
    } deriving (Show, Generic)
    
    instance FromJSON MyParams where
      parseJSON = genericParseJSON $ defaultOptions
        { rejectUnknownFields = True
        , fieldLabelModifier = map toLower . drop 2
        }
    

    Here we adjust the default parse options in two ways: first, we tell it to reject unknown fields, which is exactly what you're asking for, and second, we tell it how to get "foo" from the field name "mpFoo" (and likewise for bar).