A ZIO JSON solution to parse/decode JSON with blank spaces in the keys (and a type hierarchy)

As a brief note today, I was starting to look at a free JSON REST web service that to get stock information, and their JSON for a single stock looks like this:

{
    "Global Quote": {
        "01. symbol": "IBM",
        "02. open": "182.4300",
        "03. high": "182.8000",
        "04. low": "180.5700",
        "05. price": "181.5800",
        "06. volume": "3037990",
        "07. latest trading day": "2024-04-19",
        "08. previous close": "181.4700",
        "09. change": "0.1100",
        "10. change percent": "0.0606%"
    }
}

I wanted to figure out how to decode that JSON into a Scala class/object using ZIO JSON, and long story short, someone on Discord helped me find a solution. Their solution wasn’t 100% correct, but it was close enough that I could figure out the rest. Another useful URL is the ZIO JSON Configuration page.

Here’s the ZIO JSON solution:

package test4s
////> using scala "3"
////> using lib "dev.zio::zio::2.0.22"
////> using lib "dev.zio::zio-http::3.0.0-RC4"
////> using lib "dev.zio::zio-json::0.6.2"

import zio.*
import zio.Console.*
import zio.http.{Client, Response, URL}
import zio.json.*
import java.io.IOException

sealed trait Data

/**
 * This solution comes from:
 *     https://discord.com/channels/629491597070827530/733728086637412422/1231821749226832032
 * and:
 *     https://zio.dev/zio-json/configuration
 */
@jsonHint("Global Quote")
case class GlobalQuote(
    @jsonField("01. symbol") symbol: String,
    @jsonField("02. open") open: String,
    @jsonField("03. high") high: String,
    @jsonField("04. low") low: String,
    @jsonField("05. price") price: String,
    @jsonField("06. volume") volume: String,
    @jsonField("07. latest trading day") latestTradingDay: String,
    @jsonField("08. previous close") previousClose: String,
    @jsonField("09. change") change: String,
    @jsonField("10. change percent") changePercent: String
) extends Data

object GlobalQuote {
    implicit val decoder: JsonDecoder[Data] = DeriveJsonDecoder.gen[Data]
}

import GlobalQuote.*

object HttpsJson104Stocks extends ZIOAppDefault:

    val apiKey = "MYKEYHERE"
    val symbol = "AAPL"
    val function = "GLOBAL_QUOTE"
    val maybeUrl: Either[Exception, URL] =
        URL.decode(s"https://www.alphavantage.co/query?function=${function}&symbol=${symbol}&apikey=${apiKey}")

    // TODO: handle this better
    val url: URL = maybeUrl.toOption.get

    // TODO: fix the `Throwable | String` part.
    val program: ZIO[Client & Scope, Throwable | String, Data] = for
        client: Client   <- ZIO.service[Client]
        res: Response    <- client.url(url).get("/")
        jsonBody: String <- res.body.asString
        data: Data       <- ZIO.fromEither(jsonBody.fromJson[Data])
    yield
        data

    val run =
        program.provide(
            Client.default,
            Scope.default
        ).foldZIO(
            failure => Console.printLineError(s"failure = $failure"),
            success => Console.printLine(s"success = $success")
        )

The important parts about that solution are:

  • The @jsonHint("Global Quote") annotation
  • The @jsonField("01. symbol") annotations, that map the JSON strings to my case class fields
  • Having the GlobalQuote extend the sealed trait Data, which helps get past the initial `"Global Quote" string inside the JSON

I also started using foldZIO at the end of the application so I can see both the success and failure information from the app.