Skip to content
Merged
Show file tree
Hide file tree
Changes from 13 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
Contributor 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
Contributor 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"
)
30 changes: 11 additions & 19 deletions modules/core/src/main/scala/jsonrpclib/CallId.scala
Original file line number Diff line number Diff line change
@@ -1,32 +1,24 @@
package jsonrpclib

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

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
}
}
24 changes: 18 additions & 6 deletions modules/core/src/main/scala/jsonrpclib/Codec.scala
Original file line number Diff line number Diff line change
@@ -1,35 +1,47 @@
package jsonrpclib

import io.circe.Json
import io.circe.{Encoder, Decoder}
import com.github.plokhotnyuk.jsoniter_scala.core._
import com.github.plokhotnyuk.jsoniter_scala.circe.JsoniterScalaCodec._

trait Codec[A] {

def encode(a: A): Payload
def decode(payload: Option[Payload]): Either[ProtocolError, A]

}

object Codec {

def encode[A](a: A)(implicit codec: Codec[A]): Payload = codec.encode(a)
def decode[A](payload: Option[Payload])(implicit codec: Codec[A]): Either[ProtocolError, A] = codec.decode(payload)

// TODO replace with CirceJson.fromSchema
implicit def fromJsonCodec[A](implicit jsonCodec: JsonValueCodec[A]): Codec[A] = new Codec[A] {
def encode(a: A): Payload = {
Payload(writeToArray(a))
Payload(readFromArray[Json](writeToArray(a)))
}

def decode(payload: Option[Payload]): Either[ProtocolError, A] = {
try {
payload match {
case Some(Payload.Data(payload)) => Right(readFromArray(payload))
case Some(Payload.NullPayload) => Right(readFromArray(nullArray))
case None => Left(ProtocolError.ParseError("Expected to decode a payload"))
case Some(Payload(data)) => Right(readFromArray[A](writeToArray(data)))
case None => Left(ProtocolError.ParseError("Expected to decode a payload"))
}
} catch { case e: JsonReaderException => Left(ProtocolError.ParseError(e.getMessage())) }
}
}

private val nullArray = "null".getBytes()
implicit def fromCirceCodecs[A: Encoder: Decoder]: Codec[A] = new Codec[A] {
def encode(a: A): Payload = {
Payload(Encoder[A].apply(a))
}

def decode(payload: Option[Payload]): Either[ProtocolError, A] = {
payload match {
case Some(Payload(payload)) => payload.as[A].left.map(e => ProtocolError.ParseError(e.getMessage))
case None => Left(ProtocolError.ParseError("Expected to decode a payload"))
}
}
}
}
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(_))
}
42 changes: 36 additions & 6 deletions modules/core/src/main/scala/jsonrpclib/internals/RawMessage.scala
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
package jsonrpclib
package internals

import com.github.plokhotnyuk.jsoniter_scala.core._
import com.github.plokhotnyuk.jsoniter_scala.macros.JsonCodecMaker
import com.github.plokhotnyuk.jsoniter_scala.macros.CodecMakerConfig
import io.circe.{Decoder, Encoder, Json}
import io.circe.syntax._

private[jsonrpclib] case class RawMessage(
jsonrpc: String,
Expand Down Expand Up @@ -44,7 +43,8 @@ private[jsonrpclib] object RawMessage {
val `2.0` = "2.0"

def from(message: Message): RawMessage = message match {
case InputMessage.NotificationMessage(method, params) => RawMessage(`2.0`, method = Some(method), params = params)
case InputMessage.NotificationMessage(method, params) =>
RawMessage(`2.0`, method = Some(method), params = params)
case InputMessage.RequestMessage(method, callId, params) =>
RawMessage(`2.0`, method = Some(method), params = params, id = Some(callId))
case OutputMessage.ErrorMessage(callId, errorPayload) =>
Expand All @@ -53,7 +53,37 @@ private[jsonrpclib] object RawMessage {
RawMessage(`2.0`, result = Some(data.stripNull), id = Some(callId))
}

implicit val rawMessageJsonValueCodecs: JsonValueCodec[RawMessage] =
JsonCodecMaker.make(CodecMakerConfig.withSkipNestedOptionValues(true))
// Custom encoder to flatten nested Option[Option[Payload]]
implicit val rawMessageEncoder: Encoder[RawMessage] = Encoder.instance { msg =>
Json
.obj(
"jsonrpc" -> Json.fromString(msg.jsonrpc),
"method" -> msg.method.asJson,
"params" -> msg.params.asJson,
"error" -> msg.error.asJson,
"id" -> msg.id.asJson
)
.deepMerge(
msg.result match {
case Some(Some(payload)) => Json.obj("result" -> payload.asJson)
case Some(None) => Json.obj("result" -> Json.Null)
case None => Json.obj()
}
)
}

// Custom decoder to wrap result into Option[Option[Payload]]
implicit val rawMessageDecoder: Decoder[RawMessage] = Decoder.instance { c =>
for {
jsonrpc <- c.downField("jsonrpc").as[String]
method <- c.downField("method").as[Option[String]]
params <- c.downField("params").as[Option[Payload]]
error <- c.downField("error").as[Option[ErrorPayload]]
id <- c.downField("id").as[Option[CallId]]
resultOpt <-
if (c.downField("result").succeeded)
c.downField("result").as[Option[Payload]].map(res => Some(res))
else Right(None)
} yield RawMessage(jsonrpc, method, resultOpt, error, params, id)
}
}
11 changes: 7 additions & 4 deletions modules/core/src/test/scala/jsonrpclib/CallIdSpec.scala
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ package jsonrpclib

import weaver._
import com.github.plokhotnyuk.jsoniter_scala.core._
import io.circe.Json
import com.github.plokhotnyuk.jsoniter_scala.circe.JsoniterScalaCodec._
import cats.syntax.all._

object CallIdSpec extends FunSuite {
test("json parsing") {
Expand All @@ -12,9 +15,9 @@ object CallIdSpec extends FunSuite {
val longJson = Long.MaxValue.toString

val nullJson = "null"
assert.same(readFromString[CallId](strJson), CallId.StringId("25")) &&
assert.same(readFromString[CallId](intJson), CallId.NumberId(25)) &&
assert.same(readFromString[CallId](longJson), CallId.NumberId(Long.MaxValue)) &&
assert.same(readFromString[CallId](nullJson), CallId.NullId)
assert.same(readFromString[Json](strJson).as[CallId], CallId.StringId("25").asRight) &&
assert.same(readFromString[Json](intJson).as[CallId], CallId.NumberId(25).asRight) &&
assert.same(readFromString[Json](longJson).as[CallId], CallId.NumberId(Long.MaxValue).asRight) &&
assert.same(readFromString[Json](nullJson).as[CallId], CallId.NullId.asRight)
}
}
Loading