Parsing JSON with ezon for a composite data type
I have the following data type:
data DocumentOrDirectory = Document DocumentName DocumentContent
| Directory DirectoryName [DocumentOrDirectory]
I came up with the following code for JSON. It works but needs improvement. It should convert document and directory separately, but I don't know how.
instance JSON.ToJSON DocumentOrDirectory where
toJSON (Document documentName documentContent) = JSON.object
[ "document" JSON..= JSON.object
[ "name" JSON..= (T.pack $ id documentName)
, "content" JSON..= (T.pack $ id documentContent)
]
]
toJSON (Directory dirName dirContent) = JSON.object
[ "directory" JSON..= JSON.object
[ "name" JSON..= (T.pack $ id dirName)
, "content" JSON..= JSON.toJSON dirContent
]
]
I need to be able to parse a DocumentOrDirectory object from JSON. This is what I came up with (doesn't work):
instance JSON.FromJSON DocumentOrDirectory where
parseJSON (Object v@(Document documentName documentContent)) =
DocumentOrDirectory <$> documentName .: "name"
<*> documentContent .: "content"
parseJSON (Object v@(Directory dirName dirContent) =
DocumentOrDirectory <$> dirName .: "name"
<*> dirContent .: "content"
parseJSON _ = mzero
How do I modify my existing code to be able to convert data from JSON to JSON?
source to share
We approach this problem step by step.
First, I'll assume, for example, that the names and contents are just String
:
type DirectoryName = String
type DocumentName = String
type DocumentContent = String
You mentioned that you want to serialize Document
and Directory
separately. You might also want to work with them separately. Let's make them separate types:
data Document = Document DocumentName DocumentContent deriving Show
data Directory = Directory DirectoryName [DocumentOrDirectory] deriving Show
newtype DocumentOrDirectory = DocumentOrDirectory (Either Document Directory) deriving Show
It DocumentOrDirectory
is now an alias for the type or Either Document Directory
. We used newtype
it because we want to write our own instance for it. The default instance Either
won't work for us.
And let's define some helper functions:
liftDocument :: Document -> DocumentOrDirectory liftDocument = DocumentOrDirectory . Left liftDirectory :: Directory -> DocumentOrDirectory liftDirectory = DocumentOrDirectory . Right
With these definitions, we can write individual instances ToJSON
:
instance ToJSON Document where
toJSON (Document name content) = object [ "document" .= object [
"name" .= name,
"content" .= content ]]
instance ToJSON Directory where
toJSON (Directory name content) = object [ "directory" .= object [
"name" .= name,
"content" .= content ]]
instance ToJSON DocumentOrDirectory where
toJSON (DocumentOrDirectory (Left d)) = toJSON d
toJSON (DocumentOrDirectory (Right d)) = toJSON d
We have to check how are serialized Document
and Directory
(I previously disabled JSON output):
*Main> let document = Document "docname" "lorem"
*Main> B.putStr (encode document)
{
"document": {
"content": "lorem",
"name": "docname"
}
}
*Main> let directory = Directory "dirname" [Left document, Left document]
*Main> B.putStr (encode directory) >> putChar '\n'
{
"directory": {
"content": [
{
"document": {
"content": "lorem",
"name": "docname"
}
},
{
"document": {
"content": "lorem",
"name": "docname"
}
}
],
"name": "directory"
}
}
B.putStr (encode $ liftDirectory directory)
will lead to the same!
The next step is to write decoders, FromJSON
instances. We can see that the key ( Directory
or Document
) indicates whether the underlying data is Directory
or Document
. So the JSON format is non-overlapping (unambiguous), so we can just try to parse Document
and then Directory
.
instance FromJSON Document where
parseJSON (Object v) = maybe mzero parser $ HashMap.lookup "document" v
where parser (Object v') = Document <$> v' .: "name"
<*> v' .: "content"
parser _ = mzero
parseJSON _ = mzero
instance FromJSON Directory where
parseJSON (Object v) = maybe mzero parser $ HashMap.lookup "directory" v
where parser (Object v') = Directory <$> v' .: "name"
<*> v' .: "content"
parser _ = mzero
parseJSON _ = mzero
instance FromJSON DocumentOrDirectory where
parseJSON json = (liftDocument <$> parseJSON json) <|> (liftDirectory <$> parseJSON json)
And check:
*Main> decode $ encode directory :: Maybe DocumentOrDirectory
Just (DocumentOrDirectory (Right (Directory "directory" [DocumentOrDirectory (Left (Document "docname" "lorem")),DocumentOrDirectory (Left (Document "docname" "lorem"))])))
We could serialize data with a type tag inside the object data, then serialization and deserialization would look a little better:
instance ToJSON Document where
toJSON (Document name content) = object [
"type" .= ("document" :: Text),
"name" .= name,
"content" .= content ]
The generated document will look like this:
{
"type": "document",
"name": "docname",
"content": "lorem"
}
And decoding:
instance FromJSON Document where
-- We could have guard here
parseJSON (Object v) = Document <$> v .: "name"
<*> v .= "content"
instance FromJSON DocumentOrDirectory where
-- Here we check the type, and dynamically select appropriate subparser
parseJSON (Object v) = do typ <- v .= "type"
case typ of
"document" -> liftDocument $ parseJSON v
"directory" -> liftDirectory $ parseJSON v
_ -> mzero
In subtyped languages ββlike scala, you could:
sealed trait DocumentOrDirectory
case class Document(name: String, content: String) extends DocumentOrDirectory
case class Directory(name: String, content: Seq[DocumentOrDirectory]) extends DocumentOrDirectory
one could argue that this approach (which relies on subtyping) is more convenient. In Haskell we are more explicit: liftDocument
and liftDirectory
can be thought of as explicit types of coercions / upcasts if you like thinking about objects.
EDIT: Working code as entity
source to share