CI | Release |
---|---|
tethys is AST free json library for Scala
It's advantages:
-
Performant
- Built in bridge to jackson (benchmarks)
-
User friendly
- Build reader/writer by hand for product and sum types
- Configurable recursive semiauto derivation
- Discriminator support for sum types derivation
val tethysVersion = "latest version in badge"
libraryDependencies ++= Seq(
"com.tethys-json" %% "tethys-core" % tethysVersion,
"com.tethys-json" %% "tethys-jackson" % tethysVersion
)
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.
tethys provides JsonReader and JsonWriter instances for a bunch of basic types
Check links below to see exact ones:
You can create new instances for your types using:
- contramap on already existing writer
- 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(_))
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]
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
}
All examples consider you made this imports:
import tethys.*
import tethys.jackson.* // or tethys.jackson.pretty.* for pretty printing
- 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
- 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 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
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
- You can configure only case class derivation
- To configure JsonReader use ReaderBuilder
- To configure JsonWriter use WriterBuilder
- Configuration can be provided:
- directly to derived method
given JsonWriter[UserAccount.Customer] = JsonObjectWriter.derived { WriterBuilder[UserAccount.Customer] }
- as an inline given to derives
P.S. There are empty WriterBuilder in the examples to simplify demonstration of two approaches. You shouldn't use empty oneobject Customer: inline given WriterBuilder[UserAccount.Customer] = WriterBuilder[UserAccount.Customer]
- 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
}
- 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]]
}
In some cases, you may need to work with raw AST, so tethys can offer you circe and json4s AST support
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
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"))))
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
}
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:
-
WriterDescription and ReaderDescription are deprecated along with describe macro. You can use WriterBuilder and ReaderBuilder directly instead
-
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)
-
0.28.1 scala 3 enum support will not compile to prevent runtime effects during migration
-
updatePartial
for WriterBuilder is deprecated. You can useupdate
instead -
all derivation api is moved directly into core module in tethys package, including
- FieldStyle
- WriterBuilder
- ReaderBuilder
-
auto derivation is removed
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 module contains all type classes for parsing/writing JSON.
JSON string parsing/writing and derivation are separated to tethys-jackson
and tethys-derivation
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 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
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
}
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
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