Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Open
carlos-verdes opened this issue Oct 6, 2022 · 9 comments

Comments

@carlos-verdes
Copy link

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
Copy link
Contributor

plokhotnyuk commented Oct 6, 2022

@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
Copy link
Author

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
Copy link
Author

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
Copy link

gcnyin commented Dec 8, 2022

What if the subclasses have the same fields?

@wadouk
Copy link

wadouk commented Dec 9, 2022

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

@carlos-verdes
Copy link
Author

@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
Copy link
Author

@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
Copy link

wadouk commented Dec 15, 2022

@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
Copy link
Author

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

4 participants