/tethys

AST free JSON library for Scala

Primary LanguageScalaApache License 2.0Apache-2.0

CI Release
Build Status Maven Central

tethys

tethys is AST free json library for Scala

It's advantages:

  1. Performant

  2. User friendly

    • Build reader/writer by hand for product and sum types
    • Configurable recursive semiauto derivation
    • Discriminator support for sum types derivation

Quick start

val tethysVersion = "latest version in badge"
libraryDependencies ++= Seq(
  "com.tethys-json" %% "tethys-core" % tethysVersion,
  "com.tethys-json" %% "tethys-jackson" % tethysVersion
)

Read/Write JSON API

tethys provides extension methods allowing you to read and write JSON

They look something like this:

package tethys

extension [A](value: A)
  def asJson(using 
    jw: JsonWriter[A],
    twp: TokenWriterProducer
  ): String = ???

extension (value: String)
  def readJson[A](using 
    jr: JsonReader[A],
    tip: TokenIteratorProducer
  ): Either[ReaderError, A] = ???

Tethys provides TokenWriterProducer and TokenIteratorProducer automatically, so in most cases you only need to provide JsonReader or JsonWriter. Let's see how can we get one.

Basic instances

tethys provides JsonReader and JsonWriter instances for a bunch of basic types

Check links below to see exact ones:

JsonReader instances

JsonWriters instances

Build instances by hand

map and contramap

You can create new instances for your types using:

  1. contramap on already existing writer
  2. map on already existing reader
import tethys.*

case class StringWrapper(value: String) extends AnyVal

given JsonWriter[StringWrapper] =
   JsonWriter[String].contramap(_.value)
   
given JsonReader[StringWrapper] =
   JsonReader[String].map(StringWrapper(_))

JsonWriter

To build JsonWriter for case class you can use obj method on its companion object.

import tethys.*

  case class MobileSession(
    id: Long, 
    deviceId: String, 
    userId: java.lang.UUID
  ) extends Session
  
object MobileSession:
  given JsonObjectWriter[MobileSession] = JsonWriter.obj[MobileSession]
    .addField("id")(_.id)
    .addField("deviceId")(_.deviceId)
    .addField("userId")(_.userId)

You can concat multiple JsonObjectWriter.
Combining concatenation with derivation allows to create JsonWriter for sealed trait. To derive JsonWriter for sealed trait you need to have JsonObjectWriter instances for all subtypes in scope

given JsonWriter[Session] =
   JsonWriter.obj[Session].addField("typ")(_.typ) ++ JsonObjectWriter.derived[Session]

JsonReader

To build JsonReader for case class you can use builder method on its companion object.

import tethys.*

  case class MobileSession(
    id: Long, 
    deviceId: String, 
    userId: java.lang.UUID
  ) extends Session("mobile")
  
  object Mobile:
    given JsonReader[MobileSession] = JsonReader.builder
      .addField[Long]("id")
      .addField[String]("deviceId")
      .addField[java.lang.UUID]("userId")
      .buildReader(MobileSession(_, _, _))
  

To build JsonReader for sealed trait you can use selectReader after adding some field:

import tethys.*
  
  object Session:
    given webReader: JsonReader[WebSession] = ???
    given mobileReader: JsonReader[MobileSession] = ???
    
    given JsonReader[Session] = JsonReader.builder
      .addField[String]("typ")
      .selectReader {
         case "web" => webReader
         case "mobile" => mobileReader
      }
  

Derivation

All examples consider you made this imports:

import tethys.*
import tethys.jackson.* // or tethys.jackson.pretty.* for pretty printing

Basic enums

  1. StringEnumJsonWriter and StringEnumJsonReader
enum SessionType derives StringEnumJsonWriter, StringEnumJsonReader:
  case Mobile, Web
 
case class Session(typ: SessionType) derives JsonReader, JsonObjectWriter

val session = Session(typ = SessionType.Mobile)
val json = """{"typ": "Mobile"}"""

json.jsonAs[Session] == Right(session)
session.asJson == json
  1. OrdinalEnumJsonWriter and OrdinalEnumJsonReader
enum SessionType derives OrdinalEnumJsonWriter, OrdinalEnumJsonReader:
  case Mobile, Web
 
case class Session(typ: SessionType) derives JsonReader, JsonObjectWriter

val session = Session(typ = SessionType.Web)
val json = """{"typ": "1"}"""

json.jsonAs[Session] == Right(session)
session.asJson == json

Case classes

case class Session(
    id: Long, 
    userId: String
) derives JsonReader, JsonObjectWriter

val session = Session(id = 123, userId = "3-X56812")
val json = """{"id": 123, "userId": "3-X56812"}"""

json.jsonAs[Session] == Right(session)
session.asJson == json

Sealed traits and enums

To derive JsonReader you must provide a discriminator. This can be done via selector annotation Discriminator for JsonWriter is optional.

If you don't need readers/writers for subtypes, you can omit them, they will be derived recursively for your trait/enum.

import tethys.selector

sealed trait UserAccount(@selector val typ: String) derives JsonReader, JsonObjectWriter

object UserAccount:
   case class Customer(
        id: Long,
        phone: String
   ) extends UserAccount("Customer")
   
   case class Employee(
        id: Long,
        phone: String,
        position: String
   ) extends UserAccount("Employee")

val account: UserAccount = UserAccount.Customer(id = 123, phone = "+12394283293"
val json = """{"typ": "Customer", "id": 123, "userId": "+12394283293"}"""

json.jsonAs[UserAccount] == Right(account)
account.asJson == json

Configuration

  1. You can configure only case class derivation
  2. To configure JsonReader use ReaderBuilder
  3. To configure JsonWriter use WriterBuilder
  4. Configuration can be provided:
    • directly to derived method
       given JsonWriter[UserAccount.Customer] = 
         JsonObjectWriter.derived {
           WriterBuilder[UserAccount.Customer]
         }
    • as an inline given to derives
       object Customer:
         inline given WriterBuilder[UserAccount.Customer] =
           WriterBuilder[UserAccount.Customer]
    P.S. There are empty WriterBuilder in the examples to simplify demonstration of two approaches. You shouldn't use empty one
  5. WriterBuilder features
case class Foo(a: Int, b: String, c: Any, d: Boolean, e: Double)

inline given WriterBuilder[Foo] =
   WriterBuilder[Foo]
     // choose field style
     .fieldStyle(FieldStyle.UpperSnakeCase)
     // remove field
     .remove(_.b)
     // add new field
     .add("d")(_.b.trim)
     // rename field
     .rename(_.e)("z")
     // update field (also you can rename it using withRename after choosing field)
     .update(_.a)(_ + 1)
     // update field from root (same as update, but function is from root element)
     .update(_.d).fromRoot(foo => if (foo.d) foo.a else foo.a / 2)
     // possibility to semiauto derive any
     .update(_.c) {
        case s: String => s
        case i: Int if i % 2 == 0 => i / 2
        case i: Int => i + 1
        case other => other.toString
     }
  1. ReaderBuilder features
inline given ReaderBuilder[Foo] =
  ReaderBuilder[Foo]
    // extract field from a value of a specific type
    .extract(_.e).as[Option[Double]](_.getOrElse(1.0))
  
    // extract field as combination of model fields and some other fields from json
    .extract(_.a).from(_.b).and[Int]("otherField2")((b, other) => d.toInt + other)
  
    // provide reader for Any field
    .extractReader(_.c).from(_.a) {
       case 1 => JsonReader[String]
       case 2 => JsonReader[Int]
       case _ => JsonReader[Option[Boolean]]
    }

integrations

In some cases, you may need to work with raw AST, so tethys can offer you circe and json4s AST support

Circe

see project page

libraryDependencies += "com.tethys-json" %% "tethys-circe" % tethysVersion
import tethys.*
import tethys.jackson.*
import tethys.circe.*

import io.circe.Json

case class Foo(bar: Int, baz: Json) derives JsonReader

val json = """{"bar": 1, "baz": ["some", {"arbitrary": "json"}]}"""
val foo = json.jsonAs[Foo].fold(throw _, identity)

foo.bar // 1: Int
foo.baz // [ "some", { "arbitrary" : "json" } ]: io.circe.Json

Json4s

see project page

libraryDependencies += "com.tethys-json" %% "tethys-json4s" % tethysVersion
import tethys.*
import tethys.jackson.*
import tethys.json4s.*

import org.json4s.JsonAST.*

case class Foo(bar: Int, baz: JValue) derives JsonReader

val json = """{"bar": 1, "baz": ["some", {"arbitrary": "json"}]"""
val foo = json.jsonAs[Foo].fold(throw _, identity)

foo.bar // 1
foo.baz // JArray(List(JString("some"), JObject("arbitrary" -> JString("json"))))

Enumeratum

see project page

libraryDependencies += "com.tethys-json" %% "tethys-enumeratum" % tethysVersion

enumeratum module provides a bunch of mixins for your Enum classes.

import enumeratum.{Enum, EnumEntry}
import tethys.enumeratum.*

sealed trait Direction extends EnumEntry
case object Direction extends Enum[Direction] 
  with TethysEnum[Direction] // provides JsonReader and JsonWriter instances 
  with TethysKeyEnum[Direction] { // provides KeyReader and KeyWriter instances
  
  
  case object Up extends    Direction
  case object Down extends  Direction
  case object Left extends  Direction
  case object Right extends Direction

  val values = findValues
}

scala 2

migration notes

When migrating to scala 3 you should use 0.28.1 version.

Scala 3 derivation API in 1.0.0 has a lot of deprecations and is not fully compatible with 0.28.1, including:

  1. WriterDescription and ReaderDescription are deprecated along with describe macro. You can use WriterBuilder and ReaderBuilder directly instead

  2. DependentField model for ReaderBuilder has changed. Now extract field from feature works like this:

    • exactly one from call
    • chain of and calls (until compiler lets you)
    • both methods from/and has two forms
      • select some field from your model
      • provide type to method and name of field as string parameter
   ReaderBuilder[SimpleType]
     .extract(_.i).from(_.d).and[Double]("e")((d, e) => (d + e).toInt)
  1. 0.28.1 scala 3 enum support will not compile to prevent runtime effects during migration

  2. updatePartial for WriterBuilder is deprecated. You can use update instead

  3. all derivation api is moved directly into core module in tethys package, including

    • FieldStyle
    • WriterBuilder
    • ReaderBuilder
  4. auto derivation is removed

Quick start

Add dependencies to your build.sbt

val tethysVersion = "latest version in badge"
libraryDependencies ++= Seq(
  "com.tethys-json" %% "tethys-core" % tethysVersion,
  "com.tethys-json" %% "tethys-jackson213" % tethysVersion,
  "com.tethys-json" %% "tethys-derivation" % tethysVersion
)
libraryDependencies ++= Seq(
  "com.tethys-json" %% "tethys" % "latest version in badge"
)

core

core module contains all type classes for parsing/writing JSON. JSON string parsing/writing and derivation are separated to tethys-jackson and tethys-derivation

JsonWriter

JsonWriter writes json tokens to TokenWriter

import tethys._
import tethys.jackson._

List(1, 2, 3, 4).asJson

//or write directly to TokenWriter

val tokenWriter = YourWriter

tokenWriter.writeJson(List(1, 2, 3, 4))

New writers can be created with an object builder or with a combination of a few writers

import tethys._
import tethys.jackson._
import scala.reflect.ClassTag

case class Foo(bar: Int)

def classWriter[A](implicit ct: ClassTag[A]): JsonObjectWriter[A] = {
    JsonWriter.obj[A].addField("clazz")(_ => ct.toString())
}

implicit val fooWriter: JsonObjectWriter[Foo] = {
  classWriter[Foo] ++ JsonWriter.obj[Foo].addField("bar")(_.bar)
}

Foo(1).asJson

or just using another JsonWriter

import tethys._

case class Foo(bar: Int)

JsonWriter.stringWriter.contramap[Foo](_.bar.toString)

JsonReader

JsonReader converts a json token from TokenIterator to its value

import tethys._
import tethys.jackson._

"[1, 2, 3, 4]".jsonAs[List[Int]]

New readers can be created with a builder

import tethys._
import tethys.jackson._

case class Foo(bar: Int)

implicit val fooReader: JsonReader[Foo] = JsonReader.builder
    .addField[Int]("bar")
    .buildReader(Foo.apply)
    
"""{"bar":1}""".jsonAs[Foo]

Also you can select an existing reader that depends on other json fields

import tethys._
import tethys.jackson._

trait FooBar
case class Foo(foo: Int) extends FooBar
case class Bar(bar: String)  extends FooBar

val fooReader: JsonReader[Foo] = JsonReader.builder
    .addField[Int]("foo")
    .buildReader(Foo.apply)
    
val barReader: JsonReader[Bar] = JsonReader.builder
    .addField[String]("bar")
    .buildReader(Bar.apply)
    
implicit val fooBarReader: JsonReader[FooBar] = JsonReader.builder
    .addField[String]("clazz")
    .selectReader[FooBar] {
      case "Foo" => fooReader
      case _ => barReader 
    }    
    
"""{"clazz":"Foo","foo":1}""".jsonAs[FooBar]

Please check out tethys package object for all available syntax Ops classes

derivation

tethys-derivation provides semiauto and auto macro derivation JsonReader and JsonWriter instances.
In most cases you should prefer semiauto derivation because it's more precise, faster in compilation and flexible.

import tethys._
import tethys.jackson._
import tethys.derivation.auto._
import tethys.derivation.semiauto._

case class Foo(bar: Bar)
case class Bar(seq: Seq[Int])

implicit val barWriter: JsonObjectWriter[Bar] = jsonWriter[Bar] //semiauto
implicit val barReader: JsonReader[Bar] = jsonReader[Bar]

"""{"bar":{"seq":[1,2,3]}}""".jsonAs[Foo] //Foo reader auto derived

In complex cases you can provide some additional information to jsonWriter and jsonReader functions

import tethys._
import tethys.derivation.builder._
import tethys.derivation.semiauto._

case class Foo(a: Int, b: String, c: Any, d: Boolean, e: Double)

implicit val fooWriter = jsonWriter[Foo] {
  describe {
    //Any functions are allowed in lambdas
    WriterBuilder[Foo]
      .remove(_.b)
      .add("d")(_.b.trim)
      .update(_.a)(_ + 1)
      // the only way to semiauto derive Any
      // this partial function will be replaced with match in the final writer
      .updatePartial(_.c) {  
        case s: String => s
        case i: Int if i % 2 == 0 => i / 2
        case i: Int => i + 1
        case other => other.toString 
      }
      .update(_.d).fromRoot(foo => if(foo.d) foo.a else foo.a / 2) //same as update but function accepts root element
      .updatePartial(_.e).fromRoot { //same as updatePartial but function accepts root element
        case Foo(1, _, _, _, e) => e
        case Foo(2, _, _, _, e) => e % 2
        case foo => e.toString
      }
  }
}

implicit val fooReader = jsonReader[Foo] {
    //Any functions are allowed in lambdas
    ReaderBuilder[Foo]
      .extractReader(_.c).from(_.a)('otherField.as[String]) { // provide reader for Any field
        case (1, "str") => JsonReader[String]
        case (_, "int") => JsonReader[Int]
        case _ => JsonReader[Option[Boolean]]
      }
      .extract(_.a).from(_.b).and("otherField2".as[Int])((b, other) => d.toInt + other) // calculate a field that depends on other fields
      .extract(_.e).as[Option[Double]](_.getOrElse(1.0)) // extract a field from a value of a specific type
}

jackson

tethys-jackson module provides bridge instances for jackson streaming api

import tethys.jackson._
//import tethys.jackson.pretty._ //pretty writing

//that's it. welcome to use jackson

complex case

import tethys._
import tethys.jackson._
import tethys.derivation.auto._

case class Foo(bar: Bar)
case class Bar(seq: Seq[Int])

val foo = """{"bar":{"seq":[1,2,3]}}""".jsonAs[Foo].fold(throw _, identity)
val json = foo.asJson