Search code examples
haskellaeson

Parse JSON with known variable field


I have a Haskell query function to get latest token price using

https://coinmarketcap.com/api/documentation/v1/#operation/getV1CryptocurrencyQuotesLatest

The function takes token id as arg, say 2010 for ADA.

import Data.Aeson
import Network.HTTP.Req

newtype Rate = Rate Double

query :: Int -> IO (Either Text Rate)
query tokenId = 
    let
        url = https queryPrefix /: "v1" /: "cryptocurrency" /: "quotes" /: "latest"
        idParam = "id" =: tokenId
        options = standardHeader <> idParam
    in
        runReq defaultHttpConfig $ do
            v <- req GET url NoReqBody jsonResponse options
            let responseCode = responseStatusCode v

            if isValidHttpResponse responseCode then do  
                case fromJSON $ responseBody v of
                    Success x -> pure $ Right x
                    Error e -> pure $ Left $ pack $ "Error decoding state: " <> e
            else
                pure $ Left $ pack ("Error with CoinMarketCap query 'Quotes Latest': " <> show responseCode <> ".  " <> show (responseStatusMessage v))              

The Json output though has "2010" as a key:

{"status":
    {"timestamp":"2021-10-24T03:35:01.583Z","error_code":0,"error_message":null,"elapsed":163,"credit_count":1,"notice":null}
,"data":
    {"2010":
        {"id":2010
        ,"name":"Cardano"
        ,"symbol":"ADA"
        ,"slug":"cardano"
        ,"num_market_pairs":302,"date_added":"2017-10-01T00:00:00.000Z"
        ,"tags":["mineable","dpos","pos","platform","research","smart-contracts","staking","binance-smart-chain","cardano-ecosystem"]
        ,"max_supply":45000000000
        ,"circulating_supply":32904527668.666
        ,"total_supply":33250650235.236,"is_active":1
        ,"platform":null
        ,"cmc_rank":4
        ,"is_fiat":0
        ,"last_updated":"2021-10-24T03:33:31.000Z"
        ,"quote":
            {"USD":
                {"price":2.16109553945978
                ,"volume_24h":2048006882.386299
                ,"volume_change_24h":-24.06,"percent_change_1h":0.24896227
                ,"percent_change_24h":0.38920394
                ,"percent_change_7d":-0.97094597
                ,"percent_change_30d":-6.13245906
                ,"percent_change_60d":-21.94246757
                ,"percent_change_90d":63.56901345
                ,"market_cap":71109827972.785
                ,"market_cap_dominance":2.7813
                ,"fully_diluted_market_cap":97249299275.69,"last_updated":"2021-10-24T03:33:31.000Z"}}}}}

Being that 2010 is an arg to query, I clearly do not want to drill in as data.2010.quote.USD.price with something like this:

instance FromJSON Rate where
    parseJSON = withObject "Rate" $ \o -> do
        dataO  <- o .: "data"
        _2010O <- dataO .: "2010" -- #############
        quoteO <- _2010O .: "quote"
        usdO <- quoteO .: "USD"
        price <- usdO .: "price"
        
        pure $ Rate price  

Question: How can I achieve the flexibility I want? Can I somehow pass in the token id to parseJSON? Or is there perhaps a Lens-Aeson technique to use a wildcard? ...


Solution

  • I you are completely sure that the object inside "data" will only ever have a single key, we can take the object, convert it into a list of values, fail if the list is empty or has more than one value, and otherwise continue parsing. Like this:

    instance FromJSON Rate where
        parseJSON = withObject "Rate" $ \o -> do
            Object dataO  <- o .: "data" -- we expect an Object
             -- get the single value, it should be an Object itself
            [Object _2010O] <- pure $ Data.Foldable.toList dataO
            quoteO <- _2010O .: "quote"
            usdO <- quoteO .: "USD"
            price <- usdO .: "price"
            pure $ Rate price 
    

    When there's no key, more than one key, or the value is not an aeson Object, the pattern [Object _2010O] <- fails to match and gives an parsing error through the MonadFail instance of aeson's Parser.

    We could also be a bit more explicit:

    instance FromJSON Rate where
        parseJSON = withObject "Rate" $ \o -> do
            Object dataO  <- o .: "data"
            let objects = Data.Foldable.toList dataO
            case objects of
                [Object _2010O] -> do
                    quoteO <- _2010O .: "quote"
                    usdO <- quoteO .: "USD"
                    price <- usdO .: "price"
                    pure $ Rate price  
                [_] -> fail "value is not Object"
                _ -> fail "zero or more than one key"