Haskell :: Aeson :: parse ADT based on field value

前端 未结 2 1706
太阳男子
太阳男子 2020-12-31 10:26

I\'m using an external API which returns JSON responses. One of the responses is an array of objects and these objects are identified by the field value inside them. I\'m ha

相关标签:
2条回答
  • 2020-12-31 11:08

    The default translation for a data type like:

    data Media = Video     { title :: Text }
               | AudioBook { title :: Text }
                 deriving Generic
    

    is actually very close to what you want. (For the simplicity of my examples, I define ToJSON instances and encode the examples to see what kind of JSON we get.)

    aeson, default

    So, with the default instance we have (view the complete source file which produces this output):

    [{"tag":"Video","title":"Some title"},{"tag":"AudioBook","title":"Other title"}]
    

    Let's see whether we can get even closer with custom options...

    aeson, custom tagFieldName

    With custom options:

    mediaJSONOptions :: Options
    mediaJSONOptions = 
        defaultOptions{ sumEncoding = 
                            TaggedObject{ tagFieldName = "objectClass"
                                        -- , contentsFieldName = undefined
                                        }
                      }
    
    instance ToJSON Media
        where toJSON = genericToJSON mediaJSONOptions
    

    we get:

    [{"objectClass":"Video","title":"Some title"},{"objectClass":"AudioBook","title":"Other title"}]
    

    (Think yourself what you want to do with an undefined field in the real code.)

    aeson, custom constructorTagModifier

    Adding

                  , constructorTagModifier = fmap Char.toLower
    

    to mediaJSONOptions gives:

    [{"objectClass":"video","title":"Some title"},{"objectClass":"audiobook","title":"Other title"}]
    

    Great! Exactly what you specified!

    decoding

    Simply add an instance with the same options to be able to decode from this format:

    instance FromJSON Media
        where parseJSON = genericParseJSON mediaJSONOptions
    

    Example:

    *Main> encode example
    "[{\"objectClass\":\"video\",\"title\":\"Some title\"},{\"objectClass\":\"audiobook\",\"title\":\"Other title\"}]"
    *Main> decode $ fromString "[{\"objectClass\":\"video\",\"title\":\"Some title\"},{\"objectClass\":\"audiobook\",\"title\":\"Other title\"}]" :: Maybe [Media]
    Just [Video {title = "Some title"},AudioBook {title = "Other title"}]
    *Main>
    

    Complete source file.

    generic-aeson, default

    To get a more complete picture, let's also look at what generic-aeson package would give (at hackage). It has also nice default translations, different in some respects from those from aeson.

    Doing

    import Generics.Generic.Aeson -- from generic-aeson package
    

    and defining:

    instance ToJSON Media
        where toJSON = gtoJson
    

    gives the result:

    [{"video":{"title":"Some title"}},{"audioBook":{"title":"Other title"}}]
    

    So, it's different from all what we've seen when using aeson.

    generic-aeson's options (Settings) are not interesting for us (they allow only to strip a prefix).

    (The complete source file.)

    aeson, ObjectWithSingleField

    Apart from lower-casing the first letter of the constructor names, generic-aeson's translation seems similar to an option available in aeson:

    Let's try this:

    mediaJSONOptions = 
        defaultOptions{ sumEncoding = ObjectWithSingleField
                      , constructorTagModifier = fmap Char.toLower
                      }
    

    and yes, the result is:

    [{"video":{"title":"Some title"}},{"audiobook":{"title":"Other title"}}]
    

    the rest of options: (aeson, TwoElemArray)

    One available option for sumEncoding has been left out from consideration above, because it gives an array which is not quite similar to the JSON representation asked about. It's TwoElemArray. Example:

    [["video",{"title":"Some title"}],["audiobook",{"title":"Other title"}]]
    

    is given by:

    mediaJSONOptions = 
        defaultOptions{ sumEncoding = TwoElemArray
                      , constructorTagModifier = fmap Char.toLower
                      }
    
    0 讨论(0)
  • 2020-12-31 11:10

    You basically need a function Text -> Text -> Media:

    toMedia :: Text -> Text -> Media
    toMedia "video"     = Video "video"
    toMedia "audiobook" = AudioBook "audiobook"
    

    The FromJSON instance is now really simple (using <$> and <*> from Control.Applicative):

    instance FromJSON Media where
        parseJSON (Object x) = toMedia <$> x .: "objectClass" <*> x .: "title"
    

    However, at this point you're redundant: the objectClass field in Video or Audio doesn't give you more information than the actual type, so you might remove it:

    data Media = Video     { title :: Text }
               | AudioBook { title :: Text }
    
    toMedia :: Text -> Text -> Media
    toMedia "video"     = Video
    toMedia "audiobook" = AudioBook
    

    Also note that toMedia is partial. You probably want to catch invalid "objectClass" values:

    instance FromJSON Media where
        parseJSON (Object x) = 
            do oc <- x .: "objectClass"
               case oc of
                   String "video"     -> Video     <$> x .: "title"
                   String "audiobook" -> AudioBook <$> x .: "title"
                   _                  -> empty
    
    {- an alternative using a proper toMedia
    toMedia :: Alternative f => Text -> f (Text -> Media)
    toMedia "video"     = pure Video
    toMedia "audiobook" = pure AudioBook
    toMedia _           = empty
    
    instance FromJSON Media where
        parseJSON (Object x) = (x .: "objectClass" >>= toMedia) <*> x .: "title"
    -}
    

    And last, but not least, remember that valid JSON uses strings for the name.

    0 讨论(0)
提交回复
热议问题