Mihai Soloi
Mihai Soloi

Reputation: 383

Scala circe deriveUnwrapped value class doesn't work for missing member

I am trying to decode a String value class in which if the string is empty I need to get a None otherwise a Some. I have the following ammonite script example:

import $ivy.`io.circe::circe-generic:0.13.0`, io.circe._, io.circe.generic.auto._, io.circe.syntax._, io.circe.generic.JsonCodec
import $ivy.`io.circe::circe-generic-extras:0.13.0`, io.circe.generic.extras._, io.circe.generic.extras.semiauto._
import $ivy.`io.circe::circe-parser:0.13.0`, io.circe.parser._

final case class CustomString(value: Option[String]) extends AnyVal
final case class TestString(name: CustomString)

implicit val customStringDecoder: Decoder[CustomString] =
    deriveUnwrappedDecoder[CustomString].map(ss => CustomString(ss.value.flatMap(s => Option.when(s.nonEmpty)(s))))

implicit val customStringEncoder: Encoder[CustomString] = deriveUnwrappedEncoder[CustomString]
implicit val testStringCodec: Codec[TestString] = io.circe.generic.semiauto.deriveCodec

val testString = TestString(CustomString(Some("test")))
val emptyTestString = TestString(CustomString(Some("")))
val noneTestString = TestString(CustomString(None))
val nullJson = """{"name":null}"""
val emptyJson = """{}"""

assert(testString.asJson.noSpaces == """{"name":"test"}""")
assert(emptyTestString.asJson.noSpaces == """{"name":""}""")
assert(noneTestString.asJson.noSpaces == nullJson)
assert(noneTestString.asJson.dropNullValues.noSpaces == emptyJson)

assert(decode[TestString](nullJson).exists(_ == noneTestString)) // this passes
assert(decode[TestString](emptyJson).exists(_ == noneTestString)) // this fails

Upvotes: 1

Views: 1083

Answers (3)

Mihai Soloi
Mihai Soloi

Reputation: 383

The answers that exist don't solve the problem so here's the solution. If you don't want to use refined, you can define the decoder like so:

implicit val customStringDecoder: Decoder[CustomString] =
  Decoder
    .decodeOption(deriveUnwrappedDecoder[CustomString])
    .map(ssOpt => CustomString(ssOpt.flatMap(_.value.flatMap(s => Option.when(s.nonEmpty)(s)))))

However, if you use refined types (which I recommend) it can be even simpler by using the circe-refined and it comes with the benefit of better type safety(i.e. you know that your String is not empty). Here's the complete ammonite script for testing:

import $ivy.`io.circe::circe-generic:0.13.0`, io.circe._, io.circe.generic.auto._, io.circe.syntax._
import $ivy.`io.circe::circe-parser:0.13.0`, io.circe.parser._

import $ivy.`eu.timepit::refined:0.9.14`, eu.timepit.refined.types.string.NonEmptyString
import $ivy.`io.circe::circe-refined:0.13.0`, io.circe.refined._

final case class TestString(name: Option[NonEmptyString])

implicit val customNonEmptyStringDecoder: Decoder[Option[NonEmptyString]] =
    Decoder[Option[String]].map(_.flatMap(NonEmptyString.unapply))

val testString = TestString(NonEmptyString.unapply("test"))
val emptyTestString = TestString(NonEmptyString.unapply(""))
val noneTestString = TestString(None)
val nullJson = """{"name":null}"""
val emptyJson = """{}"""
val emptyStringJson = """{"name":""}"""

assert(testString.asJson.noSpaces == """{"name":"test"}""")
assert(noneTestString.asJson.noSpaces == nullJson)
assert(noneTestString.asJson.dropNullValues.noSpaces == emptyJson)


assert(decode[TestString](nullJson).exists(_ == noneTestString))
assert(decode[TestString](emptyJson).exists(_ == noneTestString))
assert(decode[TestString](emptyStringJson).exists(_ == noneTestString))

Upvotes: 0

user804690
user804690

Reputation:

You could alternatevely go for a different encoding so the intent is more clear and you wouldn't need to pattern match on the nested case class when you need to use the string.

final case class TestString(name: Option[NonEmptyString])
object TestString {
  implicit val decoder: Decoder[TestString] = deriveDecoder
}

sealed trait NonEmptyString {
  def value: String
}
object NonEmptyString {
  private case class NonEmptyStringImpl(value: String) extends NonEmptyString

  def apply(value: String): Either[NonEmptyStringRequiredException, NonEmptyString] = {
    if (value.nonEmpty) Right(NonEmptyStringImpl(value))
    else Left(new NonEmptyStringRequiredException)
  }

  implicit val encoder: Encoder[NonEmptyString] = Encoder[String].contramap(_.value)

  implicit val decoder: Decoder[Option[NonEmptyString]] = Decoder.withReattempt {
    case h: HCursor =>
      if (h.value.isNull) Right(None)
      else h.value.asString match {
        case Some(string) => Right(apply(string).toOption)
        case None => Left(DecodingFailure("Not a string.", h.history))
      }
    case _: FailedCursor =>
      Right(None)
  }
}

Upvotes: 1

Frederick Roth
Frederick Roth

Reputation: 2830

As far as I know there is no automated feature for this.

I would solve it by using the circe cursor api directly:

import $ivy.`io.circe::circe-generic:0.13.0`, io.circe._, io.circe.generic.auto._, io.circe.syntax._, io.circe.generic.JsonCodec
import $ivy.`io.circe::circe-generic-extras:0.13.0`, io.circe.generic.extras._, io.circe.generic.extras.semiauto._
import $ivy.`io.circe::circe-parser:0.13.0`, io.circe.parser._

final case class CustomString(value: Option[String]) extends AnyVal
final case class TestString(name: CustomString)

implicit val testStringDecoder: Decoder[TestString] =  (c: HCursor) =>{
     c.downField("name").as[Option[String]].map(string => TestString(CustomString(string)))
}

implicit val customStringEncoder: Encoder[CustomString] = deriveUnwrappedEncoder[CustomString]
implicit val testStringCodec: Encoder[TestString] = io.circe.generic.semiauto.deriveEncoder

val testString = TestString(CustomString(Some("test")))
val emptyTestString = TestString(CustomString(Some("")))
val noneTestString = TestString(CustomString(None))
val nullJson = """{"name":null}"""
val emptyJson = """{}"""

assert(testString.asJson.noSpaces == """{"name":"test"}""")
assert(emptyTestString.asJson.noSpaces == """{"name":""}""")
assert(noneTestString.asJson.noSpaces == nullJson)
assert(noneTestString.asJson.dropNullValues.noSpaces == emptyJson)

assert(decode[TestString](nullJson).exists(_ == noneTestString)) // this passes
assert(decode[TestString](emptyJson).exists(_ == noneTestString)) // this fails

Upvotes: 1

Related Questions