zio-json icon indicating copy to clipboard operation
zio-json copied to clipboard

Support for enum / sealed traits without hints nor discrimination fields

Open carlos-verdes opened this issue 1 year ago • 9 comments

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

carlos-verdes avatar Oct 06 '22 13:10 carlos-verdes

@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.

plokhotnyuk avatar Oct 06 '22 15:10 plokhotnyuk

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 avatar Oct 07 '22 07:10 carlos-verdes

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)

carlos-verdes avatar Oct 09 '22 19:10 carlos-verdes