zio / zio-json

Fast, secure JSON library with tight ZIO integration.
https://zio.dev/zio-json
Apache License 2.0
410 stars 146 forks source link

Support for enum / sealed traits without hints nor discrimination fields #749

Open carlos-verdes opened 2 years ago

carlos-verdes commented 2 years ago

Taking an enum like this:

enum Credentials:
  case UserPassword(username: String, password: String)
  case Token(jwt: String)

I need to be able to parse the next 2 jsons:

{"username": "$username", "password": "$password"}
{"jwt": "$token"}

Taking as input the field names of each option we can implement something like this (manual approach):

  given credentialsEncoder: JsonEncoder[Credentials] =
    (a: Credentials, indent: Option[Int], out: Write) => a match
      case Credentials.UserPassword(username, password) =>
        out.write(s"""{"username": "$username", "password": "$password"}""")
      case Credentials.Token(token) =>
        out.write(s"""{"$JWT": "$token"}""")

  given credentialsDecoder: JsonDecoder[Credentials] = (trace: List[JsonError], in: RetractReader) =>
      val map = JsonDecoder[Map[String, String]].unsafeDecode(trace, in)
      if map.contains(JWT) then
        Token(map.get(JWT).get)
      else if map.contains(USERNAME) && map.contains(PASSWORD) then
        UserPassword(map.get(USERNAME).get, map.get(PASSWORD).get)
      else
        unsafeDecodeMissing(trace)

Which pass the next test:

import scala.concurrent.duration.*

import zio.*
import zio.config.*
import zio.json.*
import zio.test.*
import zio.test.Assertion.*

trait CredentialExamples:

  val username = "root"
  val password = "testPassword"

  val userPasswordJson = s"""{"username": "$username", "password": "$password"}"""

  private val token = """eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJwcmVmZXJyZWRfdXNlcm5hbWUiOiJyb290IiwiaXNzIjoiYXJhbmdvZGIiLCJpYXQiOjE2NjUwNDU2MjAsImV4cCI6MTY2NzYzNzYyMH0.9IZwKAEALrH8iSN_6YHzv4pAM0Y7a-W22mnCz-bvMa0"""

  val tokenJson = s"""{"jwt": "$token"}"""

  val userPasswordCredentials = Credentials.UserPassword(username, password)
  val tokenCredentials = Credentials.Token(token)

object CredentialsSpec extends ZIOSpecDefault with CredentialExamples:

  def assertParsedJsonIs[T: JsonDecoder](json: String, expected: T) =
    assert(json.fromJson[T])(isRight(equalTo(expected)))

  override def spec: Spec[TestEnvironment, Any] =
    suite("Credentials should")(
      test("encode user password credentials") {
        assertTrue(userPasswordCredentials.toJson == userPasswordJson)
      },
      test("decode user password credentials") {
        assertParsedJsonIs(userPasswordJson, userPasswordCredentials)
      },
      test("encode token credentials") {
        assertTrue(tokenCredentials.toJson == tokenJson)
      },
      test("decode token credentials") {
        assertParsedJsonIs(tokenJson, tokenCredentials)
      }
    )

I think this implementation can be done getting the definition of each case and trying to match with the json provided.

Something like this on Circe: https://circe.github.io/circe/codecs/adt.html

plokhotnyuk commented 2 years ago

@carlos-verdes BEWARE: Default implementations for Map are vulnerable. Possible mitigations are limiting a number of accepted key-value pairs during parsing or using implementations which are safer: TreeMap, java.util.HashMap with Map adapter, etc.

carlos-verdes commented 2 years ago

Map is only an example to show the expected output, it's obviously not a good practice to load full document in memory

carlos-verdes commented 2 years ago

I end up with something like that (but there are lot of things to improve:


  given credentialsDecoder: JsonDecoder[Credentials] = new JsonDecoder[Credentials]:
    override def unsafeDecode(trace: List[JsonError], in: RetractReader): Credentials =

      Lexer.char(trace, in, '{')
      Lexer.firstField(trace, in)

      val firstKey = Lexer.string(trace, in).toString
      Lexer.char(trace, in, ':')

      if firstKey == JWT then Token(Lexer.string(trace, in).toString)
      else if firstKey == USERNAME then
        val username = Lexer.string(trace, in).toString

        if Lexer.nextField(trace, in) && Lexer.string(trace, in).toString == PASSWORD then
          Lexer.char(trace, in, ':')
          val password = Lexer.string(trace, in).toString
          UserPassword(username, password)
        else unsafeDecodeMissing(trace)
      else if firstKey == PASSWORD then
        val password = Lexer.string(trace, in).toString

        if Lexer.nextField(trace, in) && Lexer.string(trace, in).toString == USERNAME then
          Lexer.char(trace, in, ':')
          val username = Lexer.string(trace, in).toString
          UserPassword(username, password)
        else unsafeDecodeMissing(trace)
      else unsafeDecodeMissing(trace)
gcnyin commented 1 year ago

What if the subclasses have the same fields?

wadouk commented 1 year ago

I made something similar by having explicit case class for {"username": "$username", "password": "$password"} much simpler IMO

carlos-verdes commented 1 year ago

@wadouk this doesn't cover the need to gather credentials with different models In my example you can login using user/password or using a token, this is a sum type that can be used later in the code with pattern matching

The idea is to do something like "getCredentialsFromJson" without the need of discriminator fields that are not part of the business domain

carlos-verdes commented 1 year ago

@gcnyin in the case there are same fields normally you use the first which match, so the general rule is to put more specific cases first and more generic ones at the end... but in a business domain it's unlikely to happen, normally you can solve the problem with optional fields

wadouk commented 1 year ago

@carlos-verdes haven´t seen the token case. As you mention, using options would to the trick. no need of discriminator. Use a case cass with options for json codec Cretendials(username: Option[String], password: Option[String], token: Option[String]) with transformOrFail and pattern match or <*> to build your business model Credentials if none, a codec error.

carlos-verdes commented 1 year ago

That would be a workaround option but it just shows a lack of a feature don't you think?