I’ve been having fun recently, writing a RESTful service using Haskell and Servant. I did run into a problem that I couldn’t easily find a solution to on the magical bounty of knowledge that is the Internet, so I thought I’d share my findings and solution.
While writing this service (and practically any Haskell code), step 1 is of course defining our core types. Our REST endpoint is basically a CRUD app which exchanges these with the outside world as JSON objects. Doing this is delightfully simple:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
{-# LANGUAGE DeriveGeneric #-} import Data.Aeson import GHC.Generics data Job = Job { jobInputUrl :: String , jobPriority :: Int , ... } deriving (Eq, Generic, Show) instance ToJSON Job where toJSON = genericToJSON defaultOptions instance FromJSON Job where parseJSON = genericParseJSON defaultOptions |
That’s all it takes to get the basic type up with free serialization using Aeson
and Haskell Generic
s. This is followed by a few more lines to hook up GET and POST handlers, we instantiate the server using warp
, and we’re good to go. All standard stuff, right out of the Servant tutorial.
The POST request accepts a new object in the form of a JSON
object, which is then used to create the corresponding object on the server. Standard operating procedure again, as far as RESTful APIs go.
The nice part about doing it like this is that the input is automatically validated based on types. So input like:
1 2 3 4 |
{ "jobInputUrl": 123, // should have been a string "jobPriority": 123 } |
will result in:
Error in $: expected String, encountered Number
However, as this nice tour of how Aeson works demonstrate, if the input has keys that we don’t recognise, no error will be raised:
1 2 3 4 5 |
{ "jobInputUrl": "http://arunraghavan.net", "jobPriority": 100, "junkField": "junkValue" } |
This behaviour would not be undesirable in use-cases such as mine — if the client is sending fields we don’t understand, I’d like for the server to signal an error so the underlying problem can be caught early.
As it turns out, making the JSON parsing stricter and catch missing fields is just a little more involved. I didn’t find how this could be done in a single place on the Internet, so here’s the best I could do:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 |
{-# LANGUAGE DeriveDataTypeable #-} {-# LANGUAGE DeriveGeneric #-} import Data.Aeson import Data.Data import GHC.Generics data Job = Job { jobInputUrl :: String , jobPriority :: Int , ... } deriving (Data, Eq, Generic, Show) instance ToJSON Job where toJSON = genericToJSON defaultOptions instance FromJSON Job where parseJSON json = do job <- genericParseJSON defaultOptions json if keysMatchRecords json job then return job else fail "extraneous keys in input" where -- Make sure the set of JSON object keys is exactly the same as the fields in our object keysMatchRecords (Object o) d = let objKeys = sort . fmap unpack . keys recFields = sort . fmap (fieldLabelModifier defaultOptions) . constrFields . toConstr in objKeys o == recFields d keysMatchRecords _ _ = False |
The idea is quite straightforward, and likely very easy to make generic. The Data.Data
module lets us extract the constructor for the Job
type, and the list of fields in that constructor. We just make sure that’s an exact match for the list of keys in the JSON object we parsed, and that’s it.
Of course, I’m quite new to the Haskell world so it’s likely there are better ways to do this. Feel free to drop a comment with suggestions! In the mean time, maybe this will be useful to others facing a similar problem.
Update: I’ve fixed parseJSON
to properly use fieldLabelModifier
from the default options, so that comparison actually works when you’re not using Aeson
‘s default options. Thanks to /u/tathougies for catching that.
I’m also hoping to rewrite this in generic form using Generics
, so watch this space for more updates.
1 Pingback