文章

Decoding dynamically shaped JSON in Elm

I need to read from a RSS feed that contains <enclosure> element in Elm. While I can conveniently use YQL to get the JSON version of the feed directly, they comes in different shapes:

//case 1: As object
{ ... enclosure: { url: 'http://...' , length:12345, type:'audio/mpeg' } ... }

//case 2: As list, I'll take the first one
{ ... enclosure : [
          { url:'http://...', length:12345, type:'audio/mpeg'} ,
          { url:'http://...', length:12345, type:'audio/mpeg'} ,
          ...
      ]
}

//case 3: enclosure with type other than audio will be filtered out
{ ... enclosure: { url: 'http://...' , length:12345, type:'video/mp4' } ... }

//case 4: no enclosure, this will be filtered out too
{ ... }

To decode it into type like this:

type alias Model = 
   { list : List Item }

type alias Item =
   { url : String }

We can do this one by one and compose it together. First we do the simple case of just decode the url and type as tuple for further processing.

import Json.Decode as Json

decodeSingleEnclosure : Json.Decoder (String, String)
decodeSingleEnclosure =
    Json.object2 (,)
        ("url" := Json.string)
        ("type" := Json.string)

We need to do a validation on that enclosure type as in case 3. We will use andThen to do it.

decodeSingleEnclosureUrl : Json.Decoder String
decodeSingleEnclosureUrl =
    decodeSingleEnclosure
    `Json.andThen`
    (\(url, type_) ->
        if Regex.contains (Regex.regex "^audio/") type_ then
            Json.succeed url
        else
            Json.fail "not audio type"
    )

To decode the list of enclosure in case 2, we first decode it as list, andThen take the first item. Note that we use maybe to allow failed decoding in list and filter it using filterMap.

decodeEnclosureListUrl : Json.Decoder String
decodeEnclosureListUrl =
    Json.list (Json.maybe decodeSingleEnclosureUrl)
    `Json.andThen`
    (\list ->   -- List (Maybe String)
        let
            head = list
                |> List.filterMap identity
                |> List.head
        in
            case head of
                Just first ->
                    Json.succeed first
                Nothing ->
                    Json.fail "cannot get enclosure"
    )

Now we can use oneOf to deal with both case 1 and 2

decodeEnclosureUrl : Json.Decoder String
decodeEnclosureUrl =
    Json.oneOf
        [ "enclosure" := decodeSingleEnclosureUrl
        , "enclosure" := decodeEnclosureListUrl
        ]

Fianlly, we deocde that url into list item. If no URL is found, the item is skipped

decodeItem: Json.Decoder (Maybe Item)
decodeItem =
    Json.maybe (Json.object1 Item decodeEnclosureUrl)


decodeModel : Json.Decoder Model
decodeModel =
    Json.map Model
        ( ("items" := Json.list decodeItem)
          `Json.andThen`
          (\list -> list 
                    |> List.filterMap identity
                    |> Json.succeed
          )
        )

Check the whole exmaple at this gist. You can copy it to elm-lang.org/try to run it.

Decoding complex JSON in Elm is something I found quite hard at first. I need to wrap my head around and get familiar with the whole decoder concept. Again, function composition is the main key to deal with complex JSON. Try to arrive at the simplest case first and then gradually build on top of that.

*