Skip to content

feat: Replace jsoniter macros with circe #83

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

Open
wants to merge 16 commits into
base: smithy4s-integration
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 9 additions & 16 deletions build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ val commonSettings = Seq(
"com.disneystreaming" %%% "weaver-cats" % "0.8.4" % Test
),
mimaPreviousArtifacts := Set(
organization.value %%% name.value % "0.0.7"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question: does the bincompat breakage affect langoustine? conversely, are the codec instances even used / need to be visible outside of jsonrpclib?

Copy link
Author

@ghostbuster91 ghostbuster91 May 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good question, I checked it manually and it seems that there is one place where it does:

[error] -- [E172] Type Error: /home/kghost/workspace/langoustine/modules/tracer/frontend/src/main/scala/component.jsonviewer.scala:63:44
[error] 63 |      displayJson(ep, mode.signal, modalBus)
[error]    |                                            ^
[error]    |No given instance of type com.github.plokhotnyuk.jsoniter_scala.core.JsonValueCodec[
[error]    |  jsonrpclib.ErrorPayload] was found for a context parameter of method displayJson in package langoustine.tracer

and there is another one in tests:

[info] compiling 17 Scala sources to /home/kghost/workspace/langoustine/modules/tests/target/jvm-3/test-classes ...
[error] -- [E172] Type Error: /home/kghost/workspace/langoustine/modules/tests/src/test/scala/testkit.scala:95:60
[error] 95 |                  upickle.default.read[req.Out](writeToArray(outc.encode(res)))
[error]    |                                                ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
[error]    |No given instance of type com.github.plokhotnyuk.jsoniter_scala.core.JsonValueCodec[jsonrpclib.Payload] was found
[error] one error found

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should we make the circe codecs package private?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sigh... removing jsoniter macros was supposed to help us in the compilation flakiness 😓

not sure about circe codecs. I can see it being useful for tests (like what langoustine did), but the tracer's usecase should probably be supported with something more high-level

// organization.value %%% name.value % "0.0.7"
),
scalacOptions ++= {
CrossVersion.partialVersion(scalaVersion.value) match {
Expand Down Expand Up @@ -69,7 +69,7 @@ val core = projectMatrix
name := "jsonrpclib-core",
commonSettings,
libraryDependencies ++= Seq(
"com.github.plokhotnyuk.jsoniter-scala" %%% "jsoniter-scala-macros" % "2.30.2"
"com.github.plokhotnyuk.jsoniter-scala" %%% "jsoniter-scala-circe" % "2.30.2"
)
)

Expand All @@ -84,7 +84,8 @@ val fs2 = projectMatrix
name := "jsonrpclib-fs2",
commonSettings,
libraryDependencies ++= Seq(
"co.fs2" %%% "fs2-core" % fs2Version
"co.fs2" %%% "fs2-core" % fs2Version,
"io.circe" %%% "circe-generic" % "0.14.7" % Test
)
)

Expand Down Expand Up @@ -141,7 +142,8 @@ val exampleServer = projectMatrix
commonSettings,
publish / skip := true,
libraryDependencies ++= Seq(
"co.fs2" %%% "fs2-io" % fs2Version
"co.fs2" %%% "fs2-io" % fs2Version,
"io.circe" %%% "circe-generic" % "0.14.7"
)
)
.disablePlugins(MimaPlugin)
Expand All @@ -161,7 +163,8 @@ val exampleClient = projectMatrix
commonSettings,
publish / skip := true,
libraryDependencies ++= Seq(
"co.fs2" %%% "fs2-io" % fs2Version
"co.fs2" %%% "fs2-io" % fs2Version,
"io.circe" %%% "circe-generic" % "0.14.7"
)
)
.disablePlugins(MimaPlugin)
Expand Down Expand Up @@ -236,17 +239,7 @@ val root = project
).flatMap(_.projectRefs): _*
)

// The core compiles are a workaround for https://github.com/plokhotnyuk/jsoniter-scala/issues/564
// when we switch to SN 0.5, we can use `makeWithSkipNestedOptionValues` instead: https://github.com/plokhotnyuk/jsoniter-scala/issues/564#issuecomment-2787096068
val compileCoreModules = {
for {
scalaVersionSuffix <- List("", "3")
platformSuffix <- List("", "JS", "Native")
task <- List("compile", "package")
} yield s"core$platformSuffix$scalaVersionSuffix/$task"
}.mkString(";")

addCommandAlias(
"ci",
s"$compileCoreModules;test;scalafmtCheckAll;mimaReportBinaryIssues"
s"compile;test;scalafmtCheckAll;mimaReportBinaryIssues"
)
32 changes: 13 additions & 19 deletions modules/core/src/main/scala/jsonrpclib/CallId.scala
Original file line number Diff line number Diff line change
@@ -1,32 +1,26 @@
package jsonrpclib

import com.github.plokhotnyuk.jsoniter_scala.core._
import scala.annotation.switch
import io.circe.{Decoder, Encoder, Json, Codec}

sealed trait CallId
object CallId {
final case class NumberId(long: Long) extends CallId
final case class StringId(string: String) extends CallId
case object NullId extends CallId

implicit val callIdRW: JsonValueCodec[CallId] = new JsonValueCodec[CallId] {
def decodeValue(in: JsonReader, default: CallId): CallId = {
val nt = in.nextToken()

(nt: @switch) match {
case 'n' => in.readNullOrError(default, "expected null")
case '"' => in.rollbackToken(); StringId(in.readString(null))
case _ => in.rollbackToken(); NumberId(in.readLong())

implicit val callIdDecoder: Decoder[CallId] =
Decoder
.decodeOption(Decoder.decodeString.map(StringId(_): CallId).or(Decoder.decodeLong.map(NumberId(_): CallId)))
.map {
case None => NullId
case Some(v) => v
}
}

def encodeValue(x: CallId, out: JsonWriter): Unit = x match {
case NumberId(long) => out.writeVal(long)
case StringId(string) => out.writeVal(string)
case NullId => out.writeNull()
}

def nullValue: CallId = CallId.NullId
implicit val callIdEncoder: Encoder[CallId] = Encoder.instance {
case NumberId(n) => Json.fromLong(n)
case StringId(str) => Json.fromString(str)
case NullId => Json.Null
}

implicit val codec: Codec[CallId] = Codec.from(callIdDecoder, callIdEncoder)
}
2 changes: 2 additions & 0 deletions modules/core/src/main/scala/jsonrpclib/Channel.scala
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package jsonrpclib

import io.circe.Codec

trait Channel[F[_]] {
def mountEndpoint(endpoint: Endpoint[F]): F[Unit]
def unmountEndpoint(method: String): F[Unit]
Expand Down
35 changes: 0 additions & 35 deletions modules/core/src/main/scala/jsonrpclib/Codec.scala

This file was deleted.

2 changes: 2 additions & 0 deletions modules/core/src/main/scala/jsonrpclib/Endpoint.scala
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package jsonrpclib

import io.circe.Codec

sealed trait Endpoint[F[_]] {
def method: String
}
Expand Down
9 changes: 5 additions & 4 deletions modules/core/src/main/scala/jsonrpclib/ErrorPayload.scala
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
package jsonrpclib

import com.github.plokhotnyuk.jsoniter_scala.core.JsonValueCodec
import com.github.plokhotnyuk.jsoniter_scala.macros.JsonCodecMaker
import io.circe.{Decoder, Encoder}

case class ErrorPayload(code: Int, message: String, data: Option[Payload]) extends Throwable {
override def getMessage(): String = s"JsonRPC Error $code: $message"
}

object ErrorPayload {

implicit val rawMessageStubJsonValueCodecs: JsonValueCodec[ErrorPayload] =
JsonCodecMaker.make
implicit val errorPayloadEncoder: Encoder[ErrorPayload] =
Encoder.forProduct3("code", "message", "data")(e => (e.code, e.message, e.data))

implicit val errorPayloadDecoder: Decoder[ErrorPayload] =
Decoder.forProduct3("code", "message", "data")(ErrorPayload.apply)
}
29 changes: 15 additions & 14 deletions modules/core/src/main/scala/jsonrpclib/Message.scala
Original file line number Diff line number Diff line change
@@ -1,42 +1,43 @@
package jsonrpclib

import com.github.plokhotnyuk.jsoniter_scala.core.JsonReader
import com.github.plokhotnyuk.jsoniter_scala.core.JsonValueCodec
import com.github.plokhotnyuk.jsoniter_scala.core.JsonWriter
import io.circe.{Decoder, Encoder}
import io.circe.syntax._

sealed trait Message { def maybeCallId: Option[CallId] }
sealed trait InputMessage extends Message { def method: String }
sealed trait OutputMessage extends Message {
def callId: CallId; final override def maybeCallId: Option[CallId] = Some(callId)
def callId: CallId
final override def maybeCallId: Option[CallId] = Some(callId)
}

object InputMessage {
case class RequestMessage(method: String, callId: CallId, params: Option[Payload]) extends InputMessage {
def maybeCallId: Option[CallId] = Some(callId)
}

case class NotificationMessage(method: String, params: Option[Payload]) extends InputMessage {
def maybeCallId: Option[CallId] = None
}

}

object OutputMessage {
def errorFrom(callId: CallId, protocolError: ProtocolError): OutputMessage =
ErrorMessage(callId, ErrorPayload(protocolError.code, protocolError.getMessage(), None))

case class ErrorMessage(callId: CallId, payload: ErrorPayload) extends OutputMessage
case class ResponseMessage(callId: CallId, data: Payload) extends OutputMessage

}

object Message {
import jsonrpclib.internals.RawMessage

implicit val decoder: Decoder[Message] = Decoder.instance { c =>
c.as[RawMessage].flatMap(_.toMessage.left.map(e => io.circe.DecodingFailure(e.getMessage, c.history)))
}

implicit val messageJsonValueCodecs: JsonValueCodec[Message] = new JsonValueCodec[Message] {
val rawMessageCodec = implicitly[JsonValueCodec[internals.RawMessage]]
def decodeValue(in: JsonReader, default: Message): Message =
rawMessageCodec.decodeValue(in, null).toMessage match {
case Left(error) => throw error
case Right(value) => value
}
def encodeValue(x: Message, out: JsonWriter): Unit =
rawMessageCodec.encodeValue(internals.RawMessage.from(x), out)
def nullValue: Message = null
implicit val encoder: Encoder[Message] = Encoder.instance { msg =>
RawMessage.from(msg).asJson
}
}
47 changes: 6 additions & 41 deletions modules/core/src/main/scala/jsonrpclib/Payload.scala
Original file line number Diff line number Diff line change
@@ -1,50 +1,15 @@
package jsonrpclib

import com.github.plokhotnyuk.jsoniter_scala.core.JsonReader
import com.github.plokhotnyuk.jsoniter_scala.core.JsonValueCodec
import com.github.plokhotnyuk.jsoniter_scala.core.JsonWriter
import io.circe.{Decoder, Encoder, Json}

import java.util.Base64
import jsonrpclib.Payload.Data
import jsonrpclib.Payload.NullPayload

sealed trait Payload extends Product with Serializable {
def stripNull: Option[Payload.Data] = this match {
case d @ Data(_) => Some(d)
case NullPayload => None
}
case class Payload(data: Json) {
def stripNull: Option[Payload] = Option(Payload(data)).filter(p => !p.data.isNull)
}

object Payload {
def apply(value: Array[Byte]) = {
if (value == null) NullPayload
else Data(value)
}
final case class Data(array: Array[Byte]) extends Payload {
override def equals(other: Any) = other match {
case bytes: Data => java.util.Arrays.equals(array, bytes.array)
case _ => false
}

override lazy val hashCode: Int = java.util.Arrays.hashCode(array)

override def toString = Base64.getEncoder.encodeToString(array)
}

case object NullPayload extends Payload

implicit val payloadJsonValueCodec: JsonValueCodec[Payload] = new JsonValueCodec[Payload] {
def decodeValue(in: JsonReader, default: Payload): Payload = {
Data(in.readRawValAsBytes())
}

def encodeValue(bytes: Payload, out: JsonWriter): Unit =
bytes match {
case Data(array) => out.writeRawVal(array)
case NullPayload => out.writeNull()

}
val NullPayload: Payload = Payload(Json.Null)

def nullValue: Payload = null
}
implicit val payloadEncoder: Encoder[Payload] = Encoder[Json].contramap(_.data)
implicit val payloadDecoder: Decoder[Payload] = Decoder[Json].map(Payload(_))
}
2 changes: 2 additions & 0 deletions modules/core/src/main/scala/jsonrpclib/StubTemplate.scala
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package jsonrpclib

import io.circe.Codec

sealed trait StubTemplate[In, Err, Out]
object StubTemplate {
def notification[In](method: String)(implicit inCodec: Codec[In]): StubTemplate[In, Nothing, Unit] =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import scala.concurrent.ExecutionContext
import scala.concurrent.Future
import scala.concurrent.Promise
import scala.util.Try
import io.circe.Codec
import io.circe.Encoder

abstract class FutureBasedChannel(endpoints: List[Endpoint[Future]])(implicit ec: ExecutionContext)
extends MessageDispatcher[Future] {
Expand All @@ -25,7 +27,7 @@ abstract class FutureBasedChannel(endpoints: List[Endpoint[Future]])(implicit ec
protected def getEndpoint(method: String): Future[Option[Endpoint[Future]]] =
Future.successful(endpointsMap.get(method))
protected def sendMessage(message: Message): Future[Unit] = {
sendPayload(Codec.encode(message)).map(_ => ())
sendPayload(Payload(Encoder[Message].apply(message))).map(_ => ())
}
protected def nextCallId(): Future[CallId] = Future.successful(CallId.NumberId(nextID.incrementAndGet()))

Expand Down
Loading