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

import com.github.plokhotnyuk.jsoniter_scala.core._
import scala.annotation.switch
import io.circe.{Decoder, Encoder, HCursor, 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())

}
}

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()
}
implicit val callIdDecoder: Decoder[CallId] = Decoder.instance { cursor =>
cursor.value.fold(
jsonNull = Right(NullId),
jsonNumber = num => num.toLong.map(NumberId(_)).toRight(decodingError(cursor)),
jsonString = str => Right(StringId(str)),
jsonBoolean = _ => Left(decodingError(cursor)),
jsonArray = _ => Left(decodingError(cursor)),
jsonObject = _ => Left(decodingError(cursor))
)
}

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
}

private def decodingError(cursor: HCursor) =
io.circe.DecodingFailure("CallId must be number, string, or null", cursor.history)
}
24 changes: 22 additions & 2 deletions modules/core/src/main/scala/jsonrpclib/Codec.scala
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
package jsonrpclib

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

trait Codec[A] {

Expand All @@ -22,14 +25,31 @@ object Codec {
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 Some(Payload.Data(payload)) => Right(readFromArray[A](payload))
case Some(Payload.NullPayload) => Right(readFromArray[A](nullArray))
case None => Left(ProtocolError.ParseError("Expected to decode a payload"))
}
} catch { case e: JsonReaderException => Left(ProtocolError.ParseError(e.getMessage())) }
}
}

implicit def fromCirceCodecs[A: Encoder: Decoder]: Codec[A] = new Codec[A] {
def encode(a: A): Payload = {
Payload(writeToArray[Json](Encoder[A].apply(a)))
}

def decode(payload: Option[Payload]): Either[ProtocolError, A] = {
def decodeImpl(bytes: Array[Byte]) =
readFromArray[Json](bytes).as[A].left.map(e => ProtocolError.ParseError(e.getMessage))

payload match {
case Some(Payload.Data(payload)) => decodeImpl(payload)
case Some(Payload.NullPayload) => decodeImpl(nullArray)
case None => Left(ProtocolError.ParseError("Expected to decode a payload"))
}
}
}

private val nullArray = "null".getBytes()

}
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
}
}
48 changes: 26 additions & 22 deletions modules/core/src/main/scala/jsonrpclib/Payload.scala
Original file line number Diff line number Diff line change
@@ -1,12 +1,9 @@
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 java.util.Base64
import jsonrpclib.Payload.Data
import jsonrpclib.Payload.NullPayload
import io.circe.{Decoder, Encoder, Json}

sealed trait Payload extends Product with Serializable {
def stripNull: Option[Payload.Data] = this match {
Expand All @@ -16,35 +13,42 @@ sealed trait Payload extends Product with Serializable {
}

object Payload {
def apply(value: Array[Byte]) = {
if (value == null) NullPayload
else Data(value)
}
def apply(value: Array[Byte]): Payload =
if (value == null) NullPayload else Data(value)

final case class Data(array: Array[Byte]) extends Payload {
override def equals(other: Any) = other match {
override def equals(other: Any): Boolean = 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)
override def toString: String = 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()

}
implicit val payloadEncoder: Encoder[Payload] = Encoder.instance {
case Data(arr) =>
val base64 = Base64.getEncoder.encodeToString(arr)
Json.fromString(base64)
case NullPayload =>
Json.Null
}

def nullValue: Payload = null
implicit val payloadDecoder: Decoder[Payload] = Decoder.instance { c =>
c.as[String] match {
case Right(base64str) =>
try {
Right(Data(Base64.getDecoder.decode(base64str)))
} catch {
case _: IllegalArgumentException =>
Left(io.circe.DecodingFailure(s"Invalid base64 string: $base64str", c.history))
}
case Left(_) =>
if (c.value.isNull) Right(NullPayload)
else Left(io.circe.DecodingFailure("Expected base64 string or null", c.history))
}
}
}
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