Skip to content

Instantly share code, notes, and snippets.

@ferhtaydn
Last active July 25, 2022 14:20
Show Gist options
  • Star 2 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save ferhtaydn/c879e5bc89cc4f61b38802ad6d762222 to your computer and use it in GitHub Desktop.
Save ferhtaydn/c879e5bc89cc4f61b38802ad6d762222 to your computer and use it in GitHub Desktop.
How to serialize a model class has NonEmptyList attributes like normal List

How to use NonEmptyList in a model class

Let's say you are implementing an API and decide to return a base error response in common error situations such as BadRequest, NotFound etc.

final case class Error(code: String, description: String)

If you are returning an error response, your assumption would probably be that such responses should contain at least one error.

You can use cats.data.NonEmptyList to put this constraint in your model class:

import cats.data.{NonEmptyList => NEL}

final case class ErrorResponse(errors: NEL[Error])

So far so good. You are now using cats also :)

How to serialize NonEmptyList in a model class

Then, at some point, you need to serialize those responses to return a response back to the caller. Let's say you are using play-json for that purpose and you define boilerplate-free formatters:

final case class Error(code: String, description: String)

object Error {
  implicit val errorFormat: OFormat[Error] =
    Json.format[Error]
}

final case class ErrorResponse(errors: NEL[Error])

object ErrorResponse {

  def apply(code: String, desc: String): ErrorResponse =
    ErrorResponse(NEL.of(Error(code, desc)))

  implicit val errorResponseFormat: OFormat[ErrorResponse] =
    Json.format[ErrorResponse]

}

When you test your code you realise there is a problem. The serialized result has an object with head and tail fields, instead of a normal JSON array:

import play.api.libs.json._

object Usage {

  val er = ErrorResponse("someCode", "someDesc")
  val erJSon = Json.toJson(er)
  val erJSonString =
    """
      |{
      |  "errors": {
      |    "head": {
      |      "code": "someCode",
      |      "description": "someDesc"
      |    },
      |    "tail": []
      |  }
      |}
    """.stripMargin
    
  val expectedJsonString =
    """
      |{
      |  "errors": [
      |    {
      |      "code": "someCode",
      |      "description": "someDesc"
      |    }
      |  ]
      |}
    """.stripMargin

This is because NonEmptyList is just another case class and play-json serializes it like any other.

package cats.data

final case class NonEmptyList[+A](head: A, tail: List[A])

In order to fix this you need to define custom Reads and Writes instances for fields that have type NEL[Error]. Since you can convert NEL to a regular List easily with the .toList method on NEL, you can write Writes[NEL[Error]] as follows:

object Error {
  
  implicit val errorFormat: OFormat[Error] =
    Json.format[Error]
    
  implicit val nelErrorWrites: Writes[NEL[Error]] = Writes { nelErrors =>
    Json.toJson(nelErrors.toList)
  }  
  
}

Writing the instance of Reads[NEL[Error]] is a bit trickier. You can use the apply method of Reads for that. After extracting a sequence of JsValues from JsArray and converting it to a regular list, we have a List[JsValue]. We need to convert that List[JsValue] to a JsResult[NEL[Error]] which is the expected result of Reads[NEL[Error]].

object Error {
  
  implicit val nelErrorReads: Reads[NEL[Error]] = Reads {
    case JsArray(errors) =>
      errors.toList match {
        case head :: tail => ??? // return type should be JsResult[NEL[Error]]
        case Nil => JsError("expected a NonEmptyList but got empty list")
      }
    case other: JsValue =>
      JsError(s"expected an array but got ${other.toString}")

  }
  
}

After validating the head and tail of that List[JsValue] and creating a NEL as shown below, we need to change the place of the NEL and JsResult data types.

val nelJsResult: NEL[JsResult[Error]] =
  NEL(
    head.validate[Error],
    tail.map(_.validate[Error])
  )

The general solution whenever you encounter this problem, is to do a traverse or sequence, just as it is when transforming a List[Future[Result]] to a Future[List[Result]].

Let's try traverse first. We need a cats.Applicative[JsResult] to use traverse on NEL[JsResult[A]]. To create an applicative instance for your data type, which is JsResult here, you need to implement the pure and ap methods on cats.Applicative. Helpfully the play-json JsResult companion object has a play.api.libs.functional.Applicative[JsResult] instance so we can just create a cats.Applicative instance that delegates to JsResult.applicativeJsResult.

import cats.Applicative

val nelJsResult: NEL[JsResult[Error]] =
  NEL(
    head.validate[Error],
    tail.map(_.validate[Error])
  )
  
implicit object jsResultApplicative extends Applicative[JsResult] {

  override def pure[A](x: A): JsResult[A] =
    JsResult.applicativeJsResult.pure(x)

  override def ap[A, B](
    ff: JsResult[(A) => B]
  )(fa: JsResult[A]): JsResult[B] =
    JsResult.applicativeJsResult.apply(ff, fa)
}
  
nelJsResult.traverse[JsResult, Error](identity)(applicative)

Great! It is done. As a final improvement, if you want to get rid of that identity method, you can call sequence as an extension method on your nelJsResult without any extra method by importing import cats.syntax.traverse._.

import cats.Applicative
import cats.data.{NonEmptyList => NEL}
import cats.syntax.traverse._
import play.api.libs.json._

object Error {

  implicit object jsResultApplicative extends Applicative[JsResult] {

    override def pure[A](x: A): JsResult[A] =
      JsResult.applicativeJsResult.pure(x)

    override def ap[A, B](
      ff: JsResult[(A) => B]
    )(fa: JsResult[A]): JsResult[B] =
      JsResult.applicativeJsResult.apply(ff, fa)
  }

  implicit val nelErrorReads: Reads[NEL[Error]] = Reads {
    case JsArray(errors) =>
      errors.toList match {
        case head :: tail =>
          val nelJsResult: NEL[JsResult[Error]] =
            NEL(
              head.validate[Error],
              tail.map(_.validate[Error])
            )
            
          nelJsResult.sequence[JsResult, Error]

        case Nil => JsError("expected a NonEmptyList but got empty list")
      }
    case other: JsValue =>
      JsError(s"expected an array but got ${other.toString}")

  }
}

Now, you are ready to get the JSON results you expect.

object Usage {

  val er = ErrorResponse("someCode", "someDesc")

  val erJSon = Json.toJson(er)
  val erJsonString =
    """
      |{
      |  "errors": [
      |    {
      |      "code": "someCode",
      |      "description": "someDesc"
      |    }
      |  ]
      |}
    """.stripMargin

  val er2 = ErrorResponse(
    NEL.of(
      Error("someCode", "someDesc"),
      Error("someCode2", "someDesc2")
    )
  )
  val er2Json = Json.toJson(er2)
  val er2JsonString = """
    |{
    |  "errors": [
    |    {
    |      "code": "someCode",
    |      "description": "someDesc"
    |    },
    |    {
    |      "code": "someCode2",
    |      "description": "someDesc2"
    |    }
    |  ]
    |}
  """.stripMargin

}

Simplifying the solution

Actually, if you pay enough attention to the play-json library internals (specifically Reads and Writes companion objects), you will realise that there is a simpler way of creating the Reads and Writes for NEL which involves less boilerplate.

play-json can derive a Format[List[A]] for us, thanks to the existence of implicit def traversableWrites[A: Writes] in play.api.libs.json.DefaultWrites and implicit def traversableReads[F[_], A](implicit bf: generic.CanBuildFrom[F[_], A, F[A]], ra: Reads[A]) in play.api.libs.json.DefaultReads. Because we can convert an NEL to a List, you can define Reads[NEL[T]] in terms of Reads[List[T]] with the help of the collect function. In the same way, Writes[NEL[T]] can be defined in terms of Writes[List[T]] with contramap as follows:

import cats.data.{NonEmptyList => NEL}
import play.api.libs.functional.syntax._
import play.api.libs.json._

object NonEmptyListOps {

  def reads[T: Reads]: Reads[NEL[T]] =
    Reads
      .of[List[T]]
      .collect(
        JsonValidationError("expected a NonEmptyList but got an empty list")
      ) {
        case head :: tail => NEL(head, tail)
      }

  def writes[T: Writes]: Writes[NEL[T]] =
    Writes
      .of[List[T]]
      .contramap(_.toList)

  def format[T: Format]: Format[NEL[T]] =
    Format(reads, writes)

}

After summoning a Reads[List[Error] we use a partial function to match on the structure of the deserialized list and extract the head so we can pass it to NEL. collect takes a JsonValidationError which will be used in the scenario where the match fails because the JSON list was empty.

Conclusion

You can start with the first solution for your problem that comes to mind. Sometimes, by digging into the details and trying different approaches, you will find you can simplify it.

Simplicity is the ultimate sophistication.

You can access the complete versions of the code snippets here.

This post is a collaborative work of me (Ferhat Aydin) and David Piggott.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment