From b11823c74c3c1cea411fa3c58ffcb47862608631 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Olivier=20M=C3=A9lois?= Date: Tue, 21 Feb 2023 10:21:48 +0100 Subject: [PATCH 01/47] Wrote server/client interpreters --- build.sc | 61 ++++++++++++- .../META-INF/smithy/jsonrpclib.smithy | 23 +++++ smithy/resources/META-INF/smithy/manifest | 1 + .../smithy4sinterop/ClientStub.scala | 86 +++++++++++++++++++ .../smithy4sinterop/EndpointSpec.scala | 15 ++++ .../smithy4sinterop/ServerEndpoints.scala | 50 +++++++++++ 6 files changed, 235 insertions(+), 1 deletion(-) create mode 100644 smithy/resources/META-INF/smithy/jsonrpclib.smithy create mode 100644 smithy/resources/META-INF/smithy/manifest create mode 100644 smithy4s/src/jsonrpclib/smithy4sinterop/ClientStub.scala create mode 100644 smithy4s/src/jsonrpclib/smithy4sinterop/EndpointSpec.scala create mode 100644 smithy4s/src/jsonrpclib/smithy4sinterop/ServerEndpoints.scala diff --git a/build.sc b/build.sc index 385e4d9..4fb977b 100644 --- a/build.sc +++ b/build.sc @@ -1,8 +1,10 @@ +import mill.define.Sources import mill.define.Target import mill.util.Jvm import $ivy.`com.lihaoyi::mill-contrib-bloop:$MILL_VERSION` import $ivy.`io.github.davidgregory084::mill-tpolecat::0.3.1` import $ivy.`io.chris-kipp::mill-ci-release::0.1.1` +import $ivy.`com.disneystreaming.smithy4s::smithy4s-mill-codegen-plugin::0.17.4` import os.Path import mill._ @@ -13,11 +15,12 @@ import scalanativelib._ import mill.scalajslib.api._ import io.github.davidgregory084._ import io.kipp.mill.ci.release.CiReleaseModule +import _root_.smithy4s.codegen.mill._ object versions { val scala212Version = "2.12.16" val scala213Version = "2.13.8" - val scala3Version = "3.1.2" + val scala3Version = "3.2.1" val scalaJSVersion = "1.10.1" val scalaNativeVersion = "0.4.8" val munitVersion = "0.7.29" @@ -83,6 +86,46 @@ object fs2 extends RPCCrossPlatformModule { cross => } +object smithy extends JavaModule {} + +object smithy4s extends RPCCrossPlatformModule { cross => + + override def crossPlatformModuleDeps = Seq(fs2) + def crossPlatformIvyDeps: T[Agg[Dep]] = Agg( + ivy"com.disneystreaming.smithy4s::smithy4s-json::${_root_.smithy4s.codegen.BuildInfo.version}" + ) + + // A module holding the code-generation logic to help cache that task + object gen extends Smithy4sModule { + def scalaVersion = "2.13.10" + def smithy4sInternalDependenciesAsJars = T { + smithy.jar() +: super.smithy4sInternalDependenciesAsJars() + } + } + + object jvm extends mill.Cross[JvmModule](scala213, scala3) + class JvmModule(cv: String) extends cross.JVM(cv) { + override def sources: Sources = T.sources { + super.sources() ++ gen.generatedSources() + } + } + + object js extends mill.Cross[JsModule](scala213, scala3) + class JsModule(cv: String) extends cross.JS(cv) { + override def sources: Sources = T.sources { + super.sources() ++ gen.generatedSources() + } + } + + object native extends mill.Cross[NativeModule](scala3) + class NativeModule(cv: String) extends cross.Native(cv) { + override def sources: Sources = T.sources { + super.sources() ++ gen.generatedSources() + } + } + +} + object examples extends mill.define.Module { object server extends ScalaModule { @@ -101,6 +144,22 @@ object examples extends mill.define.Module { } } + // object smithyServer extends ScalaModule { + // def ivyDeps = Agg(ivy"co.fs2::fs2-io:${versions.fs2}") + // def moduleDeps = Seq(fs2.jvm(versions.scala213), smithy4s.jvm(versions.scala213)) + // def scalaVersion = versions.scala213Version + // } + + // object smithyClient extends ScalaModule { + // def ivyDeps = Agg(ivy"co.fs2::fs2-io:${versions.fs2}") + // def moduleDeps = Seq(fs2.jvm(versions.scala213), smithy4s.jvm(versions.scala213)) + // def scalaVersion = versions.scala213Version + // def forkEnv: Target[Map[String, String]] = T { + // val assembledServer = smithyServer.assembly() + // super.forkEnv() ++ Map("SERVER_JAR" -> assembledServer.path.toString()) + // } + // } + } // ############################################################################# diff --git a/smithy/resources/META-INF/smithy/jsonrpclib.smithy b/smithy/resources/META-INF/smithy/jsonrpclib.smithy new file mode 100644 index 0000000..5d4e6f3 --- /dev/null +++ b/smithy/resources/META-INF/smithy/jsonrpclib.smithy @@ -0,0 +1,23 @@ +$version: "2.0" + +namespace jsonrpclib + +/// the JSON-RPC protocol, +/// see https://www.jsonrpc.org/specification +@protocolDefinition(traits: [ + jsonRequest + jsonNotification +]) +@trait(selector: "service") +structure jsonRPC { +} + +/// Identifies an operation that abides by request/response semantics +/// https://www.jsonrpc.org/specification#request_object +@trait(selector: "operation") +string jsonRequest + +/// Identifies an operation that abides by fire-and-forget semantics +/// see https://www.jsonrpc.org/specification#notification +@trait(selector: "operation") +string jsonNotification diff --git a/smithy/resources/META-INF/smithy/manifest b/smithy/resources/META-INF/smithy/manifest new file mode 100644 index 0000000..94839e2 --- /dev/null +++ b/smithy/resources/META-INF/smithy/manifest @@ -0,0 +1 @@ +jsonrpclib.smithy diff --git a/smithy4s/src/jsonrpclib/smithy4sinterop/ClientStub.scala b/smithy4s/src/jsonrpclib/smithy4sinterop/ClientStub.scala new file mode 100644 index 0000000..a800b3d --- /dev/null +++ b/smithy4s/src/jsonrpclib/smithy4sinterop/ClientStub.scala @@ -0,0 +1,86 @@ +package jsonrpclib.smithy4sinterop + +import cats.MonadThrow +import jsonrpclib.fs2._ +import smithy4s.Service +import smithy4s.http.json.JCodec +import smithy4s.schema._ +import cats.effect.kernel.Async +import smithy4s.kinds.PolyFunction5 +import smithy4s.ShapeId +import cats.syntax.all._ + +object ClientStub { + + def apply[Alg[_[_, _, _, _, _]], F[_]](service: Service[Alg], channel: FS2Channel[F])(implicit + F: Async[F] + ): F[service.Impl[F]] = new ClientStub(service, channel).compile + + def stream[Alg[_[_, _, _, _, _]], F[_]](service: Service[Alg], channel: FS2Channel[F])(implicit + F: Async[F] + ): fs2.Stream[F, service.Impl[F]] = fs2.Stream.eval(new ClientStub(service, channel).compile) +} + +private class ClientStub[Alg[_[_, _, _, _, _]], F[_]](val service: Service[Alg], channel: FS2Channel[F])(implicit + F: Async[F] +) { + + def compile: F[service.Impl[F]] = precompileAll.map { stubCache => + val interpreter = new service.FunctorInterpreter[F] { + def apply[I, E, O, SI, SO](op: service.Operation[I, E, O, SI, SO]): F[O] = { + val (input, smithy4sEndpoint) = service.endpoint(op) + (stubCache(smithy4sEndpoint): F[I => F[O]]).flatMap { stub => + stub(input) + } + } + } + service.fromPolyFunction(interpreter) + } + + private type Stub[I, E, O, SI, SO] = F[I => F[O]] + private val precompileAll: F[PolyFunction5[service.Endpoint, Stub]] = { + F.ref(Map.empty[ShapeId, Any]).flatMap { cache => + service.endpoints + .traverse_ { ep => + val shapeId = ep.id + EndpointSpec.fromHints(ep.hints).liftTo[F](NotJsonRPCEndpoint(shapeId)).map { epSpec => + val stub = jsonRPCStub(ep, epSpec) + cache.update(_ + (shapeId -> stub)) + } + } + .as { + new PolyFunction5[service.Endpoint, Stub] { + def apply[I, E, O, SI, SO](ep: service.Endpoint[I, E, O, SI, SO]): Stub[I, E, O, SI, SO] = { + cache.get.map { _(ep.id).asInstanceOf[I => F[O]] } + } + } + } + } + } + + def jsonRPCStub[I, E, O, SI, SO]( + smithy4sEndpoint: service.Endpoint[I, E, O, SI, SO], + endpointSpec: EndpointSpec + ): I => F[O] = { + + implicit val inputCodec: JCodec[I] = JCodec.fromSchema(smithy4sEndpoint.input) + implicit val outputCodec: JCodec[O] = JCodec.fromSchema(smithy4sEndpoint.output) + + endpointSpec match { + case EndpointSpec.Notification(methodName) => + val coerce = coerceUnit[O](smithy4sEndpoint.output) + channel.notificationStub[I](methodName).andThen(_ *> coerce) + case EndpointSpec.Request(methodName) => + channel.simpleStub[I, O](methodName) + } + } + + case class NotJsonRPCEndpoint(shapeId: ShapeId) extends Throwable + case object NotUnitReturnType extends Throwable + private def coerceUnit[A](schema: Schema[A]): F[A] = + schema match { + case Schema.PrimitiveSchema(_, _, Primitive.PUnit) => MonadThrow[F].unit + case _ => MonadThrow[F].raiseError[A](NotUnitReturnType) + } + +} diff --git a/smithy4s/src/jsonrpclib/smithy4sinterop/EndpointSpec.scala b/smithy4s/src/jsonrpclib/smithy4sinterop/EndpointSpec.scala new file mode 100644 index 0000000..2e29930 --- /dev/null +++ b/smithy4s/src/jsonrpclib/smithy4sinterop/EndpointSpec.scala @@ -0,0 +1,15 @@ +package jsonrpclib.smithy4sinterop + +import smithy4s.Hints + +sealed trait EndpointSpec +object EndpointSpec { + case class Notification(methodName: String) extends EndpointSpec + case class Request(methodName: String) extends EndpointSpec + + def fromHints(hints: Hints): Option[EndpointSpec] = hints match { + case jsonrpclib.JsonRequest.hint(r) => Some(Request(r.value)) + case jsonrpclib.JsonNotification.hint(r) => Some(Notification(r.value)) + case _ => None + } +} diff --git a/smithy4s/src/jsonrpclib/smithy4sinterop/ServerEndpoints.scala b/smithy4s/src/jsonrpclib/smithy4sinterop/ServerEndpoints.scala new file mode 100644 index 0000000..3aa3c57 --- /dev/null +++ b/smithy4s/src/jsonrpclib/smithy4sinterop/ServerEndpoints.scala @@ -0,0 +1,50 @@ +package jsonrpclib.smithy4sinterop + +import _root_.smithy4s.{Endpoint => Smithy4sEndpoint} +import cats.MonadThrow +import cats.syntax.all._ +import jsonrpclib.Endpoint +import jsonrpclib.fs2._ +import smithy4s.Service +import smithy4s.http.json.JCodec +import smithy4s.kinds.FunctorAlgebra +import smithy4s.kinds.FunctorInterpreter + +object ServerEndpoints { + + def apply[Alg[_[_, _, _, _, _]], F[_]]( + impl: FunctorAlgebra[Alg, F] + )(implicit service: Service[Alg], F: MonadThrow[F]): List[Endpoint[F]] = { + val interpreter: service.FunctorInterpreter[F] = service.toPolyFunction(impl) + service.endpoints.flatMap { smithy4sEndpoint => + EndpointSpec.fromHints(smithy4sEndpoint.hints).map { endpointSpec => + jsonRPCEndpoint(smithy4sEndpoint, endpointSpec, interpreter) + } + } + + } + + // TODO : codify errors at smithy level and handle them. + def jsonRPCEndpoint[F[_]: MonadThrow, Op[_, _, _, _, _], I, E, O, SI, SO]( + smithy4sEndpoint: Smithy4sEndpoint[Op, I, E, O, SI, SO], + endpointSpec: EndpointSpec, + impl: FunctorInterpreter[Op, F] + ): Endpoint[F] = { + + implicit val inputCodec: JCodec[I] = JCodec.fromSchema(smithy4sEndpoint.input) + implicit val outputCodec: JCodec[O] = JCodec.fromSchema(smithy4sEndpoint.output) + + endpointSpec match { + case EndpointSpec.Notification(methodName) => + Endpoint[F](methodName).notification { (input: I) => + val op = smithy4sEndpoint.wrap(input) + impl(op).void + } + case EndpointSpec.Request(methodName) => + Endpoint[F](methodName).simple { (input: I) => + val op = smithy4sEndpoint.wrap(input) + impl(op) + } + } + } +} From 8b5795fe6e2ef7ea9dfcf1626de6d0efcf1a3f3e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Olivier=20M=C3=A9lois?= Date: Tue, 21 Feb 2023 11:13:48 +0100 Subject: [PATCH 02/47] Example compiles --- build.sc | 56 +++++++-------- .../examples/smithy/client/ChildProcess.scala | 68 +++++++++++++++++++ .../examples/smithy/client/ClientMain.scala | 61 +++++++++++++++++ .../examples/smithy/server/ServerMain.scala | 41 +++++++++++ examples/smithyShared/smithy/spec.smithy | 45 ++++++++++++ 5 files changed, 243 insertions(+), 28 deletions(-) create mode 100644 examples/smithyClient/src/examples/smithy/client/ChildProcess.scala create mode 100644 examples/smithyClient/src/examples/smithy/client/ClientMain.scala create mode 100644 examples/smithyServer/src/examples/smithy/server/ServerMain.scala create mode 100644 examples/smithyShared/smithy/spec.smithy diff --git a/build.sc b/build.sc index 4fb977b..00daa76 100644 --- a/build.sc +++ b/build.sc @@ -99,29 +99,24 @@ object smithy4s extends RPCCrossPlatformModule { cross => object gen extends Smithy4sModule { def scalaVersion = "2.13.10" def smithy4sInternalDependenciesAsJars = T { - smithy.jar() +: super.smithy4sInternalDependenciesAsJars() + List(smithy.jar()) } } object jvm extends mill.Cross[JvmModule](scala213, scala3) - class JvmModule(cv: String) extends cross.JVM(cv) { - override def sources: Sources = T.sources { - super.sources() ++ gen.generatedSources() - } + def sharedSmithy = T.sources(T.workspace / "smithy" / "resources" / "META-INF" / "smithy") + class JvmModule(cv: String) extends cross.JVM(cv) with Smithy4sModule { + def smithy4sInputDirs = sharedSmithy } object js extends mill.Cross[JsModule](scala213, scala3) - class JsModule(cv: String) extends cross.JS(cv) { - override def sources: Sources = T.sources { - super.sources() ++ gen.generatedSources() - } + class JsModule(cv: String) extends cross.JS(cv) with Smithy4sModule { + def smithy4sInputDirs = sharedSmithy } object native extends mill.Cross[NativeModule](scala3) - class NativeModule(cv: String) extends cross.Native(cv) { - override def sources: Sources = T.sources { - super.sources() ++ gen.generatedSources() - } + class NativeModule(cv: String) extends cross.Native(cv) with Smithy4sModule { + def smithy4sInputDirs = sharedSmithy } } @@ -144,21 +139,26 @@ object examples extends mill.define.Module { } } - // object smithyServer extends ScalaModule { - // def ivyDeps = Agg(ivy"co.fs2::fs2-io:${versions.fs2}") - // def moduleDeps = Seq(fs2.jvm(versions.scala213), smithy4s.jvm(versions.scala213)) - // def scalaVersion = versions.scala213Version - // } - - // object smithyClient extends ScalaModule { - // def ivyDeps = Agg(ivy"co.fs2::fs2-io:${versions.fs2}") - // def moduleDeps = Seq(fs2.jvm(versions.scala213), smithy4s.jvm(versions.scala213)) - // def scalaVersion = versions.scala213Version - // def forkEnv: Target[Map[String, String]] = T { - // val assembledServer = smithyServer.assembly() - // super.forkEnv() ++ Map("SERVER_JAR" -> assembledServer.path.toString()) - // } - // } + object smithyShared extends Smithy4sModule { + def moduleDeps = Seq(smithy4s.jvm(versions.scala213)) + def scalaVersion = versions.scala213Version + } + + object smithyServer extends ScalaModule { + def ivyDeps = Agg(ivy"co.fs2::fs2-io:${versions.fs2}") + def moduleDeps = Seq(fs2.jvm(versions.scala213), smithyShared) + def scalaVersion = versions.scala213Version + } + + object smithyClient extends ScalaModule { + def ivyDeps = Agg(ivy"co.fs2::fs2-io:${versions.fs2}") + def moduleDeps = Seq(fs2.jvm(versions.scala213), smithyShared) + def scalaVersion = versions.scala213Version + def forkEnv: Target[Map[String, String]] = T { + val assembledServer = smithyServer.assembly() + super.forkEnv() ++ Map("SERVER_JAR" -> assembledServer.path.toString()) + } + } } diff --git a/examples/smithyClient/src/examples/smithy/client/ChildProcess.scala b/examples/smithyClient/src/examples/smithy/client/ChildProcess.scala new file mode 100644 index 0000000..4056634 --- /dev/null +++ b/examples/smithyClient/src/examples/smithy/client/ChildProcess.scala @@ -0,0 +1,68 @@ +package examples.smithy.client + +import fs2.Stream +import cats.effect._ +import cats.syntax.all._ +import scala.jdk.CollectionConverters._ +import java.io.OutputStream + +trait ChildProcess[F[_]] { + def stdin: fs2.Pipe[F, Byte, Unit] + def stdout: Stream[F, Byte] + def stderr: Stream[F, Byte] +} + +object ChildProcess { + + def spawn[F[_]: Async](command: String*): Stream[F, ChildProcess[F]] = + Stream.bracket(start[F](command))(_._2).map(_._1) + + val readBufferSize = 512 + private def start[F[_]: Async](command: Seq[String]) = Async[F].interruptible { + val p = + new java.lang.ProcessBuilder(command.asJava) + .start() // .directory(new java.io.File(wd)).start() + val done = Async[F].fromCompletableFuture(Sync[F].delay(p.onExit())) + + val terminate: F[Unit] = Sync[F].interruptible(p.destroy()) + + import cats._ + val onGlobal = new (F ~> F) { + def apply[A](fa: F[A]): F[A] = Async[F].evalOn(fa, scala.concurrent.ExecutionContext.global) + } + + val cp = new ChildProcess[F] { + def stdin: fs2.Pipe[F, Byte, Unit] = + writeOutputStreamFlushingChunks[F](Sync[F].interruptible(p.getOutputStream())) + + def stdout: fs2.Stream[F, Byte] = fs2.io + .readInputStream[F](Sync[F].interruptible(p.getInputStream()), chunkSize = readBufferSize) + .translate(onGlobal) + + def stderr: fs2.Stream[F, Byte] = fs2.io + .readInputStream[F](Sync[F].blocking(p.getErrorStream()), chunkSize = readBufferSize) + .translate(onGlobal) + // Avoids broken pipe - we cut off when the program ends. + // Users can decide what to do with the error logs using the exitCode value + .interruptWhen(done.void.attempt) + } + (cp, terminate) + } + + /** Adds a flush after each chunk + */ + def writeOutputStreamFlushingChunks[F[_]]( + fos: F[OutputStream], + closeAfterUse: Boolean = true + )(implicit F: Sync[F]): fs2.Pipe[F, Byte, Nothing] = + s => { + def useOs(os: OutputStream): Stream[F, Nothing] = + s.chunks.foreach(c => F.interruptible(os.write(c.toArray)) >> F.blocking(os.flush())) + + val os = + if (closeAfterUse) Stream.bracket(fos)(os => F.blocking(os.close())) + else Stream.eval(fos) + os.flatMap(os => useOs(os) ++ Stream.exec(F.blocking(os.flush()))) + } + +} diff --git a/examples/smithyClient/src/examples/smithy/client/ClientMain.scala b/examples/smithyClient/src/examples/smithy/client/ClientMain.scala new file mode 100644 index 0000000..e74ff63 --- /dev/null +++ b/examples/smithyClient/src/examples/smithy/client/ClientMain.scala @@ -0,0 +1,61 @@ +package examples.smithy.client + +import cats.effect._ +import cats.syntax.all._ +import fs2.Stream +import fs2.io._ +import jsonrpclib.CallId +import jsonrpclib.fs2._ +import jsonrpclib.smithy4sinterop.ClientStub +import jsonrpclib.smithy4sinterop.ServerEndpoints +import test._ + +import java.io.InputStream +import java.io.OutputStream + +object SmithyClientMain extends IOApp.Simple { + + // Reserving a method for cancelation. + val cancelEndpoint = CancelTemplate.make[CallId]("$/cancel", identity, identity) + + type IOStream[A] = fs2.Stream[IO, A] + def log(str: String): IOStream[Unit] = Stream.eval(IO.consoleForIO.errorln(str)) + + // Implementing the generated interface + object Client extends TestClient[IO] { + def greet(name: String): IO[GreetOutput] = IO.pure(GreetOutput(s"Client says: hello $name !")) + def pong(pong: String): IO[Unit] = IO.consoleForIO.errorln(s"Client received pong: $pong") + } + + def run: IO[Unit] = { + import scala.concurrent.duration._ + val run = for { + //////////////////////////////////////////////////////// + /////// BOOTSTRAPPING + //////////////////////////////////////////////////////// + _ <- log("Starting client") + serverJar <- sys.env.get("SERVER_JAR").liftTo[IOStream](new Exception("SERVER_JAR env var does not exist")) + // Starting the server + rp <- ChildProcess.spawn[IO]("java", "-jar", serverJar) + // Creating a channel that will be used to communicate to the server + fs2Channel <- FS2Channel[IO](cancelTemplate = cancelEndpoint.some) + // Mounting our implementation of the generated interface onto the channel + _ <- fs2Channel.withEndpointsStream(ServerEndpoints(Client)) + // Creating stubs to talk to the remote server + server: TestServer[IO] <- ClientStub.stream(test.TestServer, fs2Channel) + _ <- Stream(()) + .concurrently(fs2Channel.output.through(lsp.encodePayloads).through(rp.stdin)) + .concurrently(rp.stdout.through(lsp.decodePayloads).through(fs2Channel.input)) + .concurrently(rp.stderr.through(fs2.io.stderr[IO])) + + //////////////////////////////////////////////////////// + /////// INTERACTION + //////////////////////////////////////////////////////// + result1 <- Stream.eval(server.greet("Client")) + _ <- log(s"Client received $result1") + _ <- Stream.eval(server.ping("Ping")) + } yield () + run.compile.drain + } + +} diff --git a/examples/smithyServer/src/examples/smithy/server/ServerMain.scala b/examples/smithyServer/src/examples/smithy/server/ServerMain.scala new file mode 100644 index 0000000..890a575 --- /dev/null +++ b/examples/smithyServer/src/examples/smithy/server/ServerMain.scala @@ -0,0 +1,41 @@ +package examples.smithy.server + +import jsonrpclib.CallId +import jsonrpclib.fs2._ +import cats.effect._ +import fs2.io._ +import jsonrpclib.Endpoint +import cats.syntax.all._ +import test._ // smithy4s-generated package +import jsonrpclib.smithy4sinterop.ClientStub +import jsonrpclib.smithy4sinterop.ServerEndpoints + +object ServerMain extends IOApp.Simple { + + // Reserving a method for cancelation. + val cancelEndpoint = CancelTemplate.make[CallId]("$/cancel", identity, identity) + + // Implementing an incrementation endpoint + class ServerImpl(client: TestClient[IO]) extends TestServer[IO] { + def greet(name: String): IO[GreetOutput] = IO.pure(GreetOutput(s"Server says: hello $name !")) + + def ping(ping: String): IO[Unit] = client.pong(s"Returned to sender: $ping") + } + + def run: IO[Unit] = { + val run = for { + channel <- FS2Channel[IO](cancelTemplate = Some(cancelEndpoint)) + testClient <- ClientStub.stream(TestClient, channel) + _ <- channel.withEndpointsStream(ServerEndpoints(new ServerImpl(testClient))) + _ <- fs2.Stream + .eval(IO.never) // running the server forever + .concurrently(stdin[IO](512).through(lsp.decodePayloads).through(channel.input)) + .concurrently(channel.output.through(lsp.encodePayloads).through(stdout[IO])) + } yield {} + + // Using errorln as stdout is used by the RPC channel + IO.consoleForIO.errorln("Starting server") >> run.compile.drain + .guarantee(IO.consoleForIO.errorln("Terminating server")) + } + +} diff --git a/examples/smithyShared/smithy/spec.smithy b/examples/smithyShared/smithy/spec.smithy new file mode 100644 index 0000000..f832f90 --- /dev/null +++ b/examples/smithyShared/smithy/spec.smithy @@ -0,0 +1,45 @@ +$version: "2.0" + +namespace test + +use jsonrpclib#jsonRequest +use jsonrpclib#jsonRPC +use jsonrpclib#jsonNotification + +@jsonRPC +service TestServer { + operations: [Greet, Ping] +} + +@jsonRPC +service TestClient { + operations: [Greet, Pong] +} + +@jsonRequest("greet") +operation Greet { + input := { + @required + name: String + } + output := { + @required + message: String + } +} + +@jsonNotification("ping") +operation Ping { + input := { + @required + ping: String + } +} + +@jsonNotification("pong") +operation Pong { + input := { + @required + pong: String + } +} From c18a8141b0120fb72156640c57dc17423696540e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Olivier=20M=C3=A9lois?= Date: Tue, 21 Feb 2023 12:20:38 +0100 Subject: [PATCH 03/47] Fixed un-bound flatMap, tried a few other things --- build.sc | 2 +- .../examples/smithy/client/ChildProcess.scala | 49 ++++++++----------- .../examples/smithy/client/ClientMain.scala | 3 +- .../examples/smithy/server/ServerMain.scala | 5 +- examples/smithyShared/smithy/spec.smithy | 2 +- fs2/src/jsonrpclib/fs2/FS2Channel.scala | 9 ++-- .../smithy4sinterop/ClientStub.scala | 6 ++- 7 files changed, 36 insertions(+), 40 deletions(-) diff --git a/build.sc b/build.sc index 00daa76..e224253 100644 --- a/build.sc +++ b/build.sc @@ -25,7 +25,7 @@ object versions { val scalaNativeVersion = "0.4.8" val munitVersion = "0.7.29" val munitNativeVersion = "1.0.0-M7" - val fs2 = "3.3.0" + val fs2 = "3.6.1" val weaver = "0.8.0" val scala213 = "2.13" diff --git a/examples/smithyClient/src/examples/smithy/client/ChildProcess.scala b/examples/smithyClient/src/examples/smithy/client/ChildProcess.scala index 4056634..154e66d 100644 --- a/examples/smithyClient/src/examples/smithy/client/ChildProcess.scala +++ b/examples/smithyClient/src/examples/smithy/client/ChildProcess.scala @@ -15,39 +15,32 @@ trait ChildProcess[F[_]] { object ChildProcess { def spawn[F[_]: Async](command: String*): Stream[F, ChildProcess[F]] = - Stream.bracket(start[F](command))(_._2).map(_._1) + Stream.resource(startRes(command)) val readBufferSize = 512 - private def start[F[_]: Async](command: Seq[String]) = Async[F].interruptible { - val p = - new java.lang.ProcessBuilder(command.asJava) - .start() // .directory(new java.io.File(wd)).start() - val done = Async[F].fromCompletableFuture(Sync[F].delay(p.onExit())) - val terminate: F[Unit] = Sync[F].interruptible(p.destroy()) - - import cats._ - val onGlobal = new (F ~> F) { - def apply[A](fa: F[A]): F[A] = Async[F].evalOn(fa, scala.concurrent.ExecutionContext.global) + private def startRes[F[_]: Async](command: Seq[String]) = Resource + .make { + Async[F].interruptible(new java.lang.ProcessBuilder(command.asJava).start()) + } { p => + Sync[F].interruptible(p.destroy()) } - - val cp = new ChildProcess[F] { - def stdin: fs2.Pipe[F, Byte, Unit] = - writeOutputStreamFlushingChunks[F](Sync[F].interruptible(p.getOutputStream())) - - def stdout: fs2.Stream[F, Byte] = fs2.io - .readInputStream[F](Sync[F].interruptible(p.getInputStream()), chunkSize = readBufferSize) - .translate(onGlobal) - - def stderr: fs2.Stream[F, Byte] = fs2.io - .readInputStream[F](Sync[F].blocking(p.getErrorStream()), chunkSize = readBufferSize) - .translate(onGlobal) - // Avoids broken pipe - we cut off when the program ends. - // Users can decide what to do with the error logs using the exitCode value - .interruptWhen(done.void.attempt) + .map { p => + val done = Async[F].fromCompletableFuture(Sync[F].delay(p.onExit())) + new ChildProcess[F] { + def stdin: fs2.Pipe[F, Byte, Unit] = + writeOutputStreamFlushingChunks[F](Sync[F].interruptible(p.getOutputStream())) + + def stdout: fs2.Stream[F, Byte] = fs2.io + .readInputStream[F](Sync[F].interruptible(p.getInputStream()), chunkSize = readBufferSize) + + def stderr: fs2.Stream[F, Byte] = fs2.io + .readInputStream[F](Sync[F].blocking(p.getErrorStream()), chunkSize = readBufferSize) + // Avoids broken pipe - we cut off when the program ends. + // Users can decide what to do with the error logs using the exitCode value + .interruptWhen(done.void.attempt) + } } - (cp, terminate) - } /** Adds a flush after each chunk */ diff --git a/examples/smithyClient/src/examples/smithy/client/ClientMain.scala b/examples/smithyClient/src/examples/smithy/client/ClientMain.scala index e74ff63..66a3c1f 100644 --- a/examples/smithyClient/src/examples/smithy/client/ClientMain.scala +++ b/examples/smithyClient/src/examples/smithy/client/ClientMain.scala @@ -23,7 +23,6 @@ object SmithyClientMain extends IOApp.Simple { // Implementing the generated interface object Client extends TestClient[IO] { - def greet(name: String): IO[GreetOutput] = IO.pure(GreetOutput(s"Client says: hello $name !")) def pong(pong: String): IO[Unit] = IO.consoleForIO.errorln(s"Client received pong: $pong") } @@ -55,7 +54,7 @@ object SmithyClientMain extends IOApp.Simple { _ <- log(s"Client received $result1") _ <- Stream.eval(server.ping("Ping")) } yield () - run.compile.drain + run.compile.drain.guarantee(IO.consoleForIO.errorln("Terminating client")) } } diff --git a/examples/smithyServer/src/examples/smithy/server/ServerMain.scala b/examples/smithyServer/src/examples/smithy/server/ServerMain.scala index 890a575..266c1e1 100644 --- a/examples/smithyServer/src/examples/smithy/server/ServerMain.scala +++ b/examples/smithyServer/src/examples/smithy/server/ServerMain.scala @@ -22,6 +22,8 @@ object ServerMain extends IOApp.Simple { def ping(ping: String): IO[Unit] = client.pong(s"Returned to sender: $ping") } + def printErr(s: String): IO[Unit] = IO.consoleForIO.errorln(s) + def run: IO[Unit] = { val run = for { channel <- FS2Channel[IO](cancelTemplate = Some(cancelEndpoint)) @@ -34,8 +36,7 @@ object ServerMain extends IOApp.Simple { } yield {} // Using errorln as stdout is used by the RPC channel - IO.consoleForIO.errorln("Starting server") >> run.compile.drain - .guarantee(IO.consoleForIO.errorln("Terminating server")) + printErr("Starting server") >> run.compile.drain.guarantee(printErr("Terminating server")) } } diff --git a/examples/smithyShared/smithy/spec.smithy b/examples/smithyShared/smithy/spec.smithy index f832f90..518c745 100644 --- a/examples/smithyShared/smithy/spec.smithy +++ b/examples/smithyShared/smithy/spec.smithy @@ -13,7 +13,7 @@ service TestServer { @jsonRPC service TestClient { - operations: [Greet, Pong] + operations: [Pong] } @jsonRequest("greet") diff --git a/fs2/src/jsonrpclib/fs2/FS2Channel.scala b/fs2/src/jsonrpclib/fs2/FS2Channel.scala index 4e4a454..77aa640 100644 --- a/fs2/src/jsonrpclib/fs2/FS2Channel.scala +++ b/fs2/src/jsonrpclib/fs2/FS2Channel.scala @@ -14,6 +14,7 @@ import cats.syntax.all._ import cats.effect.syntax.all._ import jsonrpclib.internals.MessageDispatcher import jsonrpclib.internals._ +import _root_.fs2.concurrent.{Channel => ConcurrentChannel} import scala.util.Try @@ -53,7 +54,7 @@ object FS2Channel { for { supervisor <- Stream.resource(Supervisor[F]) ref <- Ref[F].of(State[F](Map.empty, Map.empty, Map.empty, 0)).toStream - queue <- cats.effect.std.Queue.bounded[F, Payload](bufferSize).toStream + queue <- Stream.bracket(ConcurrentChannel.bounded[F, Payload](bufferSize))(_.closed) impl = new Impl(queue, ref, supervisor, cancelTemplate) // Creating a bespoke endpoint to receive cancelation requests @@ -98,7 +99,7 @@ object FS2Channel { } private class Impl[F[_]]( - private val queue: cats.effect.std.Queue[F, Payload], + private val queue: ConcurrentChannel[F, Payload], private val state: Ref[F, FS2Channel.State[F]], supervisor: Supervisor[F], maybeCancelTemplate: Option[CancelTemplate] @@ -106,7 +107,7 @@ object FS2Channel { extends MessageDispatcher[F] with FS2Channel[F] { - def output: Stream[F, Payload] = Stream.fromQueueUnterminated(queue) + def output: Stream[F, Payload] = queue.stream def input: Pipe[F, Payload, Unit] = _.evalMap(handleReceivedPayload) def mountEndpoint(endpoint: Endpoint[F]): F[Unit] = state @@ -136,7 +137,7 @@ object FS2Channel { } protected def reportError(params: Option[Payload], error: ProtocolError, method: String): F[Unit] = ??? protected def getEndpoint(method: String): F[Option[Endpoint[F]]] = state.get.map(_.endpoints.get(method)) - protected def sendMessage(message: Message): F[Unit] = queue.offer(Codec.encode(message)) + protected def sendMessage(message: Message): F[Unit] = queue.send(Codec.encode(message)).void protected def nextCallId(): F[CallId] = state.modify(_.nextCallId) protected def createPromise[A](callId: CallId): F[(Try[A] => F[Unit], () => F[A])] = Deferred[F, Try[A]].map { diff --git a/smithy4s/src/jsonrpclib/smithy4sinterop/ClientStub.scala b/smithy4s/src/jsonrpclib/smithy4sinterop/ClientStub.scala index a800b3d..07f72ec 100644 --- a/smithy4s/src/jsonrpclib/smithy4sinterop/ClientStub.scala +++ b/smithy4s/src/jsonrpclib/smithy4sinterop/ClientStub.scala @@ -43,7 +43,7 @@ private class ClientStub[Alg[_[_, _, _, _, _]], F[_]](val service: Service[Alg], service.endpoints .traverse_ { ep => val shapeId = ep.id - EndpointSpec.fromHints(ep.hints).liftTo[F](NotJsonRPCEndpoint(shapeId)).map { epSpec => + EndpointSpec.fromHints(ep.hints).liftTo[F](NotJsonRPCEndpoint(shapeId)).flatMap { epSpec => val stub = jsonRPCStub(ep, epSpec) cache.update(_ + (shapeId -> stub)) } @@ -51,7 +51,9 @@ private class ClientStub[Alg[_[_, _, _, _, _]], F[_]](val service: Service[Alg], .as { new PolyFunction5[service.Endpoint, Stub] { def apply[I, E, O, SI, SO](ep: service.Endpoint[I, E, O, SI, SO]): Stub[I, E, O, SI, SO] = { - cache.get.map { _(ep.id).asInstanceOf[I => F[O]] } + cache.get.map { c => + c(ep.id).asInstanceOf[I => F[O]] + } } } } From cce5d1df52ca3da6e65b01c1bae2d28c3f0bde68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Olivier=20M=C3=A9lois?= Date: Tue, 21 Feb 2023 12:29:41 +0100 Subject: [PATCH 04/47] Fix comment --- .../smithyServer/src/examples/smithy/server/ServerMain.scala | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/smithyServer/src/examples/smithy/server/ServerMain.scala b/examples/smithyServer/src/examples/smithy/server/ServerMain.scala index 40d8153..279ef38 100644 --- a/examples/smithyServer/src/examples/smithy/server/ServerMain.scala +++ b/examples/smithyServer/src/examples/smithy/server/ServerMain.scala @@ -15,7 +15,7 @@ object ServerMain extends IOApp.Simple { // Reserving a method for cancelation. val cancelEndpoint = CancelTemplate.make[CallId]("$/cancel", identity, identity) - // Implementing an incrementation endpoint + // Implementing the generated interface class ServerImpl(client: TestClient[IO]) extends TestServer[IO] { def greet(name: String): IO[GreetOutput] = IO.pure(GreetOutput(s"Server says: hello $name !")) From 0c3251b2ded24d371e7d6eb307f1cdc29c3cc065 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Olivier=20M=C3=A9lois?= Date: Tue, 21 Feb 2023 15:13:19 +0100 Subject: [PATCH 05/47] Revert changes to FS2Channel --- fs2/src/jsonrpclib/fs2/FS2Channel.scala | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/fs2/src/jsonrpclib/fs2/FS2Channel.scala b/fs2/src/jsonrpclib/fs2/FS2Channel.scala index 14c5e7d..811ced9 100644 --- a/fs2/src/jsonrpclib/fs2/FS2Channel.scala +++ b/fs2/src/jsonrpclib/fs2/FS2Channel.scala @@ -12,8 +12,7 @@ import cats.effect.kernel._ import cats.effect.std.Supervisor import cats.syntax.all._ import cats.effect.syntax.all._ -import jsonrpclib.internals._ -import _root_.fs2.concurrent.{Channel => ConcurrentChannel} +import jsonrpclib.internals.MessageDispatcher import scala.util.Try import java.util.regex.Pattern @@ -55,7 +54,7 @@ object FS2Channel { for { supervisor <- Stream.resource(Supervisor[F]) ref <- Ref[F].of(State[F](Map.empty, Map.empty, Map.empty, Vector.empty, 0)).toStream - queue <- Stream.bracket(ConcurrentChannel.bounded[F, Message](bufferSize))(_.closed) + queue <- cats.effect.std.Queue.bounded[F, Message](bufferSize).toStream impl = new Impl(queue, ref, supervisor, cancelTemplate) // Creating a bespoke endpoint to receive cancelation requests @@ -117,7 +116,7 @@ object FS2Channel { } private class Impl[F[_]]( - private val queue: ConcurrentChannel[F, Message], + private val queue: cats.effect.std.Queue[F, Message], private val state: Ref[F, FS2Channel.State[F]], supervisor: Supervisor[F], maybeCancelTemplate: Option[CancelTemplate] @@ -125,7 +124,7 @@ object FS2Channel { extends MessageDispatcher[F] with FS2Channel[F] { - def output: Stream[F, Message] = queue.stream + def output: Stream[F, Message] = Stream.fromQueueUnterminated(queue) def inputOrBounce: Pipe[F, Either[ProtocolError, Message], Unit] = _.evalMap { case Left(error) => sendProtocolError(error) case Right(message) => handleReceivedMessage(message) @@ -159,7 +158,7 @@ object FS2Channel { } protected def reportError(params: Option[Payload], error: ProtocolError, method: String): F[Unit] = ??? protected def getEndpoint(method: String): F[Option[Endpoint[F]]] = state.get.map(_.getEndpoint(method)) - protected def sendMessage(message: Message): F[Unit] = queue.send(message).void + protected def sendMessage(message: Message): F[Unit] = queue.offer(message) protected def nextCallId(): F[CallId] = state.modify(_.nextCallId) protected def createPromise[A](callId: CallId): F[(Try[A] => F[Unit], () => F[A])] = Deferred[F, Try[A]].map { From 2cfb5891e7bc2e5097fabe662bf30d1b25a28044 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Olivier=20M=C3=A9lois?= Date: Tue, 21 Feb 2023 15:47:52 +0100 Subject: [PATCH 06/47] Bump remaining versions --- .mill-version | 2 +- build.sc | 9 +++++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/.mill-version b/.mill-version index cd47247..223df19 100644 --- a/.mill-version +++ b/.mill-version @@ -1 +1 @@ -0.10.10 \ No newline at end of file +0.10.11 diff --git a/build.sc b/build.sc index 16cbe39..6cb4d6e 100644 --- a/build.sc +++ b/build.sc @@ -20,11 +20,12 @@ import _root_.smithy4s.codegen.mill._ object versions { val scala212Version = "2.12.16" val scala213Version = "2.13.10" - val scala3Version = "3.2.1" - val scalaJSVersion = "1.10.1" - val scalaNativeVersion = "0.4.8" + val scala3Version = "3.2.2" + val scalaJSVersion = "1.13.0" + val scalaNativeVersion = "0.4.10" val munitVersion = "0.7.29" val munitNativeVersion = "1.0.0-M7" + val jsoniterVersion = "2.21.0" val fs2 = "3.6.1" val weaver = "0.8.0" @@ -43,7 +44,7 @@ import versions._ object core extends RPCCrossPlatformModule { cross => def crossPlatformIvyDeps: T[Agg[Dep]] = Agg( - ivy"com.github.plokhotnyuk.jsoniter-scala::jsoniter-scala-macros::2.17.0" + ivy"com.github.plokhotnyuk.jsoniter-scala::jsoniter-scala-macros::$jsoniterVersion" ) object jvm extends mill.Cross[JvmModule](scala213, scala3) From 53cee0a5284ab9b924ceb25677194b0867164cb6 Mon Sep 17 00:00:00 2001 From: ghostbuster91 Date: Sun, 4 May 2025 19:16:50 +0200 Subject: [PATCH 07/47] Upgrade to latest smithy4s --- build.sbt | 2 +- .../smithy4sinterop/ClientStub.scala | 34 +++++++++++++------ .../smithy4sinterop/ServerEndpoints.scala | 26 +++++++++----- project/plugins.sbt | 2 +- 4 files changed, 44 insertions(+), 20 deletions(-) diff --git a/build.sbt b/build.sbt index e7d2bf7..67dcf77 100644 --- a/build.sbt +++ b/build.sbt @@ -105,7 +105,7 @@ val smithy4s = projectMatrix commonSettings, libraryDependencies ++= Seq( "co.fs2" %%% "fs2-core" % fs2Version, - "com.disneystreaming.smithy4s" %%% "smithy4s-json" % "0.17.4" + "com.disneystreaming.smithy4s" %%% "smithy4s-json" % smithy4sVersion.value ) ) diff --git a/modules/smithy4s/src/main/scala/jsonrpclib/smithy4sinterop/ClientStub.scala b/modules/smithy4s/src/main/scala/jsonrpclib/smithy4sinterop/ClientStub.scala index 07f72ec..f6e56ea 100644 --- a/modules/smithy4s/src/main/scala/jsonrpclib/smithy4sinterop/ClientStub.scala +++ b/modules/smithy4s/src/main/scala/jsonrpclib/smithy4sinterop/ClientStub.scala @@ -1,14 +1,17 @@ package jsonrpclib.smithy4sinterop +import smithy4s.~> import cats.MonadThrow import jsonrpclib.fs2._ import smithy4s.Service -import smithy4s.http.json.JCodec import smithy4s.schema._ import cats.effect.kernel.Async import smithy4s.kinds.PolyFunction5 import smithy4s.ShapeId import cats.syntax.all._ +import smithy4s.json.Json +import jsonrpclib.Codec._ +import com.github.plokhotnyuk.jsoniter_scala.core._ object ClientStub { @@ -28,7 +31,8 @@ private class ClientStub[Alg[_[_, _, _, _, _]], F[_]](val service: Service[Alg], def compile: F[service.Impl[F]] = precompileAll.map { stubCache => val interpreter = new service.FunctorInterpreter[F] { def apply[I, E, O, SI, SO](op: service.Operation[I, E, O, SI, SO]): F[O] = { - val (input, smithy4sEndpoint) = service.endpoint(op) + val smithy4sEndpoint = service.endpoint(op) + val input = service.input(op) (stubCache(smithy4sEndpoint): F[I => F[O]]).flatMap { stub => stub(input) } @@ -40,7 +44,7 @@ private class ClientStub[Alg[_[_, _, _, _, _]], F[_]](val service: Service[Alg], private type Stub[I, E, O, SI, SO] = F[I => F[O]] private val precompileAll: F[PolyFunction5[service.Endpoint, Stub]] = { F.ref(Map.empty[ShapeId, Any]).flatMap { cache => - service.endpoints + service.endpoints.toList .traverse_ { ep => val shapeId = ep.id EndpointSpec.fromHints(ep.hints).liftTo[F](NotJsonRPCEndpoint(shapeId)).flatMap { epSpec => @@ -60,18 +64,23 @@ private class ClientStub[Alg[_[_, _, _, _, _]], F[_]](val service: Service[Alg], } } + private val jsoniterCodecGlobalCache = Json.jsoniter.createCache() + + private def deriveJsonCodec[A](schema: Schema[A]): JsonCodec[A] = + Json.jsoniter.fromSchema(schema, jsoniterCodecGlobalCache) + def jsonRPCStub[I, E, O, SI, SO]( smithy4sEndpoint: service.Endpoint[I, E, O, SI, SO], endpointSpec: EndpointSpec ): I => F[O] = { - implicit val inputCodec: JCodec[I] = JCodec.fromSchema(smithy4sEndpoint.input) - implicit val outputCodec: JCodec[O] = JCodec.fromSchema(smithy4sEndpoint.output) + implicit val inputCodec: JsonCodec[I] = deriveJsonCodec(smithy4sEndpoint.input) + implicit val outputCodec: JsonCodec[O] = deriveJsonCodec(smithy4sEndpoint.output) endpointSpec match { case EndpointSpec.Notification(methodName) => val coerce = coerceUnit[O](smithy4sEndpoint.output) - channel.notificationStub[I](methodName).andThen(_ *> coerce) + channel.notificationStub[I](methodName).andThen(f => f *> coerce) case EndpointSpec.Request(methodName) => channel.simpleStub[I, O](methodName) } @@ -79,10 +88,15 @@ private class ClientStub[Alg[_[_, _, _, _, _]], F[_]](val service: Service[Alg], case class NotJsonRPCEndpoint(shapeId: ShapeId) extends Throwable case object NotUnitReturnType extends Throwable - private def coerceUnit[A](schema: Schema[A]): F[A] = - schema match { - case Schema.PrimitiveSchema(_, _, Primitive.PUnit) => MonadThrow[F].unit - case _ => MonadThrow[F].raiseError[A](NotUnitReturnType) + + private object CoerceUnitVisitor extends (Schema ~> F) { + def apply[A](schema: Schema[A]): F[A] = schema match { + case s @ Schema.StructSchema(_, _, _, make) if s.isUnit => + MonadThrow[F].unit.asInstanceOf[F[A]] + case _ => MonadThrow[F].raiseError[A](NotUnitReturnType) } + } + + private def coerceUnit[A](schema: Schema[A]): F[A] = CoerceUnitVisitor(schema) } diff --git a/modules/smithy4s/src/main/scala/jsonrpclib/smithy4sinterop/ServerEndpoints.scala b/modules/smithy4s/src/main/scala/jsonrpclib/smithy4sinterop/ServerEndpoints.scala index 3aa3c57..39b476a 100644 --- a/modules/smithy4s/src/main/scala/jsonrpclib/smithy4sinterop/ServerEndpoints.scala +++ b/modules/smithy4s/src/main/scala/jsonrpclib/smithy4sinterop/ServerEndpoints.scala @@ -6,9 +6,12 @@ import cats.syntax.all._ import jsonrpclib.Endpoint import jsonrpclib.fs2._ import smithy4s.Service -import smithy4s.http.json.JCodec import smithy4s.kinds.FunctorAlgebra import smithy4s.kinds.FunctorInterpreter +import smithy4s.json.Json +import smithy4s.schema.Schema +import jsonrpclib.Codec._ +import com.github.plokhotnyuk.jsoniter_scala.core._ object ServerEndpoints { @@ -16,14 +19,21 @@ object ServerEndpoints { impl: FunctorAlgebra[Alg, F] )(implicit service: Service[Alg], F: MonadThrow[F]): List[Endpoint[F]] = { val interpreter: service.FunctorInterpreter[F] = service.toPolyFunction(impl) - service.endpoints.flatMap { smithy4sEndpoint => - EndpointSpec.fromHints(smithy4sEndpoint.hints).map { endpointSpec => - jsonRPCEndpoint(smithy4sEndpoint, endpointSpec, interpreter) - } + service.endpoints.toList.flatMap { smithy4sEndpoint => + EndpointSpec + .fromHints(smithy4sEndpoint.hints) + .map { endpointSpec => + jsonRPCEndpoint(smithy4sEndpoint, endpointSpec, interpreter) + } + .toList } - } + private val jsoniterCodecGlobalCache = Json.jsoniter.createCache() + + private def deriveJsonCodec[A](schema: Schema[A]): JsonCodec[A] = + Json.jsoniter.fromSchema(schema, jsoniterCodecGlobalCache) + // TODO : codify errors at smithy level and handle them. def jsonRPCEndpoint[F[_]: MonadThrow, Op[_, _, _, _, _], I, E, O, SI, SO]( smithy4sEndpoint: Smithy4sEndpoint[Op, I, E, O, SI, SO], @@ -31,8 +41,8 @@ object ServerEndpoints { impl: FunctorInterpreter[Op, F] ): Endpoint[F] = { - implicit val inputCodec: JCodec[I] = JCodec.fromSchema(smithy4sEndpoint.input) - implicit val outputCodec: JCodec[O] = JCodec.fromSchema(smithy4sEndpoint.output) + implicit val inputCodec: JsonCodec[I] = deriveJsonCodec(smithy4sEndpoint.input) + implicit val outputCodec: JsonCodec[O] = deriveJsonCodec(smithy4sEndpoint.output) endpointSpec match { case EndpointSpec.Notification(methodName) => diff --git a/project/plugins.sbt b/project/plugins.sbt index ce36e16..00f2eb5 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -14,6 +14,6 @@ addSbtPlugin("com.eed3si9n" % "sbt-assembly" % "2.3.1") addSbtPlugin("org.scalameta" % "sbt-scalafmt" % "2.5.4") -addSbtPlugin("com.disneystreaming.smithy4s" % "smithy4s-sbt-codegen" % "0.17.4") +addSbtPlugin("com.disneystreaming.smithy4s" % "smithy4s-sbt-codegen" % "0.18.34") addDependencyTreePlugin From fe670debebbc362d7e4a4976cdd10a3b3313de3f Mon Sep 17 00:00:00 2001 From: ghostbuster91 Date: Mon, 5 May 2025 22:59:51 +0200 Subject: [PATCH 08/47] Fix scala native part of the build --- .envrc | 1 + build.sbt | 2 +- flake.lock | 82 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ flake.nix | 40 ++++++++++++++++++++++++++ 4 files changed, 124 insertions(+), 1 deletion(-) create mode 100644 .envrc create mode 100644 flake.lock create mode 100644 flake.nix diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..3550a30 --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +use flake diff --git a/build.sbt b/build.sbt index 67dcf77..afbc055 100644 --- a/build.sbt +++ b/build.sbt @@ -95,7 +95,7 @@ val smithy4s = projectMatrix .in(file("modules") / "smithy4s") .jvmPlatform(jvmScalaVersions) .jsPlatform(jsScalaVersions) - .nativePlatform(nativeScalaVersions) + .nativePlatform(Seq(scala3)) .disablePlugins(AssemblyPlugin) .enablePlugins(Smithy4sCodegenPlugin) .dependsOn(fs2) diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..330a04d --- /dev/null +++ b/flake.lock @@ -0,0 +1,82 @@ +{ + "nodes": { + "flake-parts": { + "inputs": { + "nixpkgs-lib": "nixpkgs-lib" + }, + "locked": { + "lastModified": 1743550720, + "narHash": "sha256-hIshGgKZCgWh6AYJpJmRgFdR3WUbkY04o82X05xqQiY=", + "owner": "hercules-ci", + "repo": "flake-parts", + "rev": "c621e8422220273271f52058f618c94e405bb0f5", + "type": "github" + }, + "original": { + "owner": "hercules-ci", + "repo": "flake-parts", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1746422338, + "narHash": "sha256-NTtKOTLQv6dPfRe00OGSywg37A1FYqldS6xiNmqBUYc=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "5b35d248e9206c1f3baf8de6a7683fee126364aa", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-24.11", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs-lib": { + "locked": { + "lastModified": 1743296961, + "narHash": "sha256-b1EdN3cULCqtorQ4QeWgLMrd5ZGOjLSLemfa00heasc=", + "owner": "nix-community", + "repo": "nixpkgs.lib", + "rev": "e4822aea2a6d1cdd36653c134cacfd64c97ff4fa", + "type": "github" + }, + "original": { + "owner": "nix-community", + "repo": "nixpkgs.lib", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-parts": "flake-parts", + "nixpkgs": "nixpkgs", + "treefmt-nix": "treefmt-nix" + } + }, + "treefmt-nix": { + "inputs": { + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1746216483, + "narHash": "sha256-4h3s1L/kKqt3gMDcVfN8/4v2jqHrgLIe4qok4ApH5x4=", + "owner": "numtide", + "repo": "treefmt-nix", + "rev": "29ec5026372e0dec56f890e50dbe4f45930320fd", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "treefmt-nix", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..324723f --- /dev/null +++ b/flake.nix @@ -0,0 +1,40 @@ +{ + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.11"; + flake-parts = { + url = "github:hercules-ci/flake-parts"; + }; + treefmt-nix = { + url = "github:numtide/treefmt-nix"; + inputs.nixpkgs.follows = "nixpkgs"; + }; + }; + + outputs = inputs@{ nixpkgs, flake-parts, ... }: + flake-parts.lib.mkFlake { inherit inputs; } { + systems = [ "x86_64-linux" "aarch64-darwin" "x86_64-darwin" ]; + imports = [ + inputs.treefmt-nix.flakeModule + ]; + perSystem = { system, config, pkgs, ... }: + { + devShells.default = pkgs.mkShell { + packages = [ pkgs.openjdk21 pkgs.scalafmt pkgs.sbt pkgs.clang pkgs.glibc.dev ]; + inputsFrom = [ + config.treefmt.build.devShell + ]; + }; + + treefmt.config = { + projectRootFile = "flake.nix"; + + programs = { + nixpkgs-fmt.enable = true; + scalafmt.enable = true; + }; + settings.formatter.scalafmt.include = [ "*.scala" "*.sc" ]; + }; + }; + }; +} + From 799e8186646791645299dd20c27a9b20a3ab25cd Mon Sep 17 00:00:00 2001 From: ghostbuster91 Date: Mon, 5 May 2025 23:18:39 +0200 Subject: [PATCH 09/47] Fix duplicated resources error --- build.sbt | 11 +++++++++-- flake.nix | 2 +- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/build.sbt b/build.sbt index afbc055..1447902 100644 --- a/build.sbt +++ b/build.sbt @@ -103,6 +103,7 @@ val smithy4s = projectMatrix .settings( name := "jsonrpclib-smithy4s", commonSettings, + mimaPreviousArtifacts := Set.empty, libraryDependencies ++= Seq( "co.fs2" %%% "fs2-core" % fs2Version, "com.disneystreaming.smithy4s" %%% "smithy4s-json" % smithy4sVersion.value @@ -162,7 +163,13 @@ val exampleSmithyServer = projectMatrix publish / skip := true, libraryDependencies ++= Seq( "co.fs2" %%% "fs2-io" % fs2Version - ) + ), + assembly / assemblyMergeStrategy := { + case PathList("META-INF", "smithy", _*) => MergeStrategy.concat + case PathList("jsonrpclib", "package.class") => MergeStrategy.first + case PathList("META-INF", xs @ _*) if xs.nonEmpty => MergeStrategy.discard + case x => MergeStrategy.first + } ) .disablePlugins(MimaPlugin) @@ -183,7 +190,7 @@ val exampleSmithyClient = projectMatrix "co.fs2" %%% "fs2-io" % fs2Version ) ) - .disablePlugins(MimaPlugin) + .disablePlugins(MimaPlugin, AssemblyPlugin) val root = project .in(file(".")) diff --git a/flake.nix b/flake.nix index 324723f..a7e5619 100644 --- a/flake.nix +++ b/flake.nix @@ -19,7 +19,7 @@ perSystem = { system, config, pkgs, ... }: { devShells.default = pkgs.mkShell { - packages = [ pkgs.openjdk21 pkgs.scalafmt pkgs.sbt pkgs.clang pkgs.glibc.dev ]; + packages = [ pkgs.openjdk21 pkgs.scalafmt pkgs.sbt pkgs.clang pkgs.glibc.dev pkgs.nodejs_23]; inputsFrom = [ config.treefmt.build.devShell ]; From 9523f6121a0438df8ad98e7beb56dd55015d0bd0 Mon Sep 17 00:00:00 2001 From: ghostbuster91 Date: Mon, 5 May 2025 23:27:31 +0200 Subject: [PATCH 10/47] Cross publish smithy across all platforms --- build.sbt | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/build.sbt b/build.sbt index 1447902..2efc40d 100644 --- a/build.sbt +++ b/build.sbt @@ -77,15 +77,9 @@ val fs2 = projectMatrix val smithy = projectMatrix .in(file("modules") / "smithy") - // TODO - // .jvmPlatform(jvmScalaVersions) - // .jsPlatform(jsScalaVersions) - // .nativePlatform(nativeScalaVersions) - .jvmPlatform( - autoScalaLibrary = false, - scalaVersions = Seq.empty, - settings = Seq() - ) + .jvmPlatform(jvmScalaVersions) + .jsPlatform(jsScalaVersions) + .nativePlatform(nativeScalaVersions) .disablePlugins(AssemblyPlugin, MimaPlugin) .settings( name := "jsonrpclib-smithy" @@ -99,7 +93,7 @@ val smithy4s = projectMatrix .disablePlugins(AssemblyPlugin) .enablePlugins(Smithy4sCodegenPlugin) .dependsOn(fs2) - .dependsOn(smithy.projectRefs.head) + .dependsOn(smithy) .settings( name := "jsonrpclib-smithy4s", commonSettings, From 67c49a587f4c6c8a037797c42749450f08902db5 Mon Sep 17 00:00:00 2001 From: ghostbuster91 Date: Mon, 5 May 2025 23:41:55 +0200 Subject: [PATCH 11/47] Add versionScheme --- build.sbt | 1 + 1 file changed, 1 insertion(+) diff --git a/build.sbt b/build.sbt index 2efc40d..4182fd9 100644 --- a/build.sbt +++ b/build.sbt @@ -24,6 +24,7 @@ val nativeScalaVersions = allScalaVersions val fs2Version = "3.12.0" +ThisBuild / versionScheme := Some("early-semver") ThisBuild / tpolecatOptionsMode := DevMode val commonSettings = Seq( From 703e25a66a7c7eaa2ef5d6ab4cc97c7daad80598 Mon Sep 17 00:00:00 2001 From: ghostbuster91 Date: Mon, 5 May 2025 23:52:35 +0200 Subject: [PATCH 12/47] Convert for-comp to flatMap --- .../examples/smithy/server/ServerMain.scala | 26 +++++++++++-------- .../src/main/scala/jsonrpclib/fs2/lsp.scala | 2 +- 2 files changed, 16 insertions(+), 12 deletions(-) diff --git a/modules/examples/smithyServer/src/main/scala/examples/smithy/server/ServerMain.scala b/modules/examples/smithyServer/src/main/scala/examples/smithy/server/ServerMain.scala index 279ef38..79075bb 100644 --- a/modules/examples/smithyServer/src/main/scala/examples/smithy/server/ServerMain.scala +++ b/modules/examples/smithyServer/src/main/scala/examples/smithy/server/ServerMain.scala @@ -4,8 +4,6 @@ import jsonrpclib.CallId import jsonrpclib.fs2._ import cats.effect._ import fs2.io._ -import jsonrpclib.Endpoint -import cats.syntax.all._ import test._ // smithy4s-generated package import jsonrpclib.smithy4sinterop.ClientStub import jsonrpclib.smithy4sinterop.ServerEndpoints @@ -25,15 +23,21 @@ object ServerMain extends IOApp.Simple { def printErr(s: String): IO[Unit] = IO.consoleForIO.errorln(s) def run: IO[Unit] = { - val run = for { - channel <- FS2Channel[IO](cancelTemplate = Some(cancelEndpoint)) - testClient <- ClientStub.stream(TestClient, channel) - _ <- channel.withEndpointsStream(ServerEndpoints(new ServerImpl(testClient))) - _ <- fs2.Stream - .eval(IO.never) // running the server forever - .concurrently(stdin[IO](512).through(lsp.decodeMessages).through(channel.inputOrBounce)) - .concurrently(channel.output.through(lsp.encodeMessages).through(stdout[IO])) - } yield {} + val run = + FS2Channel[IO](cancelTemplate = Some(cancelEndpoint)) + .flatMap { channel => + ClientStub + .stream(TestClient, channel) + .flatMap { testClient => + channel.withEndpointsStream(ServerEndpoints(new ServerImpl(testClient))) + } + } + .flatMap { channel => + fs2.Stream + .eval(IO.never) // running the server forever + .concurrently(stdin[IO](512).through(lsp.decodeMessages).through(channel.inputOrBounce)) + .concurrently(channel.output.through(lsp.encodeMessages).through(stdout[IO])) + } // Using errorln as stdout is used by the RPC channel printErr("Starting server") >> run.compile.drain.guarantee(printErr("Terminating server")) diff --git a/modules/fs2/src/main/scala/jsonrpclib/fs2/lsp.scala b/modules/fs2/src/main/scala/jsonrpclib/fs2/lsp.scala index 29963a7..5ef82c4 100644 --- a/modules/fs2/src/main/scala/jsonrpclib/fs2/lsp.scala +++ b/modules/fs2/src/main/scala/jsonrpclib/fs2/lsp.scala @@ -138,7 +138,7 @@ object lsp { } continue = false } else { - bb.put(byte) + val _ = bb.put(byte) } } if (newState != null) { From 0e7dd223fa1b0a8d8b2d1285b0e2012a19a0d5c5 Mon Sep 17 00:00:00 2001 From: Kasper Kondzielski Date: Tue, 6 May 2025 03:01:35 +0200 Subject: [PATCH 13/47] Add method on FS2Channel: resource (#80) * Add method on FS2Channel: resources * Rewrite old stream variant in terms of resource --------- Co-authored-by: ghostbuster91 --- .../main/scala/jsonrpclib/fs2/FS2Channel.scala | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/modules/fs2/src/main/scala/jsonrpclib/fs2/FS2Channel.scala b/modules/fs2/src/main/scala/jsonrpclib/fs2/FS2Channel.scala index 811ced9..2be38ab 100644 --- a/modules/fs2/src/main/scala/jsonrpclib/fs2/FS2Channel.scala +++ b/modules/fs2/src/main/scala/jsonrpclib/fs2/FS2Channel.scala @@ -47,14 +47,14 @@ trait FS2Channel[F[_]] extends Channel[F] { object FS2Channel { - def apply[F[_]: Concurrent]( + def resource[F[_]: Concurrent]( bufferSize: Int = 2048, cancelTemplate: Option[CancelTemplate] = None - ): Stream[F, FS2Channel[F]] = { + ): Resource[F, FS2Channel[F]] = { for { - supervisor <- Stream.resource(Supervisor[F]) - ref <- Ref[F].of(State[F](Map.empty, Map.empty, Map.empty, Vector.empty, 0)).toStream - queue <- cats.effect.std.Queue.bounded[F, Message](bufferSize).toStream + supervisor <- Supervisor[F] + ref <- Resource.eval(Ref[F].of(State[F](Map.empty, Map.empty, Map.empty, Vector.empty, 0))) + queue <- Resource.eval(cats.effect.std.Queue.bounded[F, Message](bufferSize)) impl = new Impl(queue, ref, supervisor, cancelTemplate) // Creating a bespoke endpoint to receive cancelation requests @@ -66,10 +66,15 @@ object FS2Channel { } } // mounting the cancelation endpoint - _ <- maybeCancelEndpoint.traverse_(ep => impl.mountEndpoint(ep)).toStream + _ <- Resource.eval(maybeCancelEndpoint.traverse_(ep => impl.mountEndpoint(ep))) } yield impl } + def apply[F[_]: Concurrent]( + bufferSize: Int = 2048, + cancelTemplate: Option[CancelTemplate] = None + ): Stream[F, FS2Channel[F]] = Stream.resource(resource(bufferSize, cancelTemplate)) + private case class State[F[_]]( runningCalls: Map[CallId, Fiber[F, Throwable, Unit]], pendingCalls: Map[CallId, OutputMessage => F[Unit]], From 7d1323cef9aa2a93d78ac1fa006df07c5012e0bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Koz=C5=82owski?= Date: Tue, 6 May 2025 03:53:58 +0200 Subject: [PATCH 14/47] Fix smithy protocol wiring --- build.sbt | 27 +++++++++++++++++++++------ 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/build.sbt b/build.sbt index 4182fd9..bc248b2 100644 --- a/build.sbt +++ b/build.sbt @@ -78,14 +78,28 @@ val fs2 = projectMatrix val smithy = projectMatrix .in(file("modules") / "smithy") - .jvmPlatform(jvmScalaVersions) - .jsPlatform(jsScalaVersions) - .nativePlatform(nativeScalaVersions) + .jvmPlatform(false) .disablePlugins(AssemblyPlugin, MimaPlugin) .settings( name := "jsonrpclib-smithy" ) +lazy val buildTimeProtocolDependency = + /** By default, smithy4sInternalDependenciesAsJars doesn't contain the jars in the "smithy4s" configuration. We have + * to add them manually - this is the equivalent of a "% Smithy4s"-scoped dependency. + * + * Ideally, this would be + * {{{ + * (Compile / smithy4sInternalDependenciesAsJars) ++= + * Smithy4s / smithy4sInternalDependenciesAsJars).value.map(_.data) + * }}} + * + * but that doesn't work because the Smithy4s configuration doesn't extend from Compile so it doesn't have the + * `internalDependencyAsJars` setting. + */ + Compile / smithy4sInternalDependenciesAsJars ++= + (smithy.jvm(autoScalaLibrary = false) / Compile / fullClasspathAsJars).value.map(_.data) + val smithy4s = projectMatrix .in(file("modules") / "smithy4s") .jvmPlatform(jvmScalaVersions) @@ -94,7 +108,6 @@ val smithy4s = projectMatrix .disablePlugins(AssemblyPlugin) .enablePlugins(Smithy4sCodegenPlugin) .dependsOn(fs2) - .dependsOn(smithy) .settings( name := "jsonrpclib-smithy4s", commonSettings, @@ -102,7 +115,8 @@ val smithy4s = projectMatrix libraryDependencies ++= Seq( "co.fs2" %%% "fs2-core" % fs2Version, "com.disneystreaming.smithy4s" %%% "smithy4s-json" % smithy4sVersion.value - ) + ), + buildTimeProtocolDependency ) val exampleServer = projectMatrix @@ -145,7 +159,8 @@ val exampleSmithyShared = projectMatrix .enablePlugins(Smithy4sCodegenPlugin) .settings( commonSettings, - publish / skip := true + publish / skip := true, + buildTimeProtocolDependency ) .disablePlugins(MimaPlugin) From 6269973d932df65b6fd75bfdacfe06913e5f624e Mon Sep 17 00:00:00 2001 From: ghostbuster91 Date: Tue, 6 May 2025 23:08:45 +0200 Subject: [PATCH 15/47] Generate java parts for smithy traits --- build.sbt | 3 +- project/MetaDependencies.scala | 11 +++ project/PathRef.scala | 32 +++++++ project/SmithyTraitCodegen.scala | 115 +++++++++++++++++++++++++ project/SmithyTraitCodegenPlugin.scala | 73 ++++++++++++++++ project/build.sbt | 4 + 6 files changed, 237 insertions(+), 1 deletion(-) create mode 100644 project/MetaDependencies.scala create mode 100644 project/PathRef.scala create mode 100644 project/SmithyTraitCodegen.scala create mode 100644 project/SmithyTraitCodegenPlugin.scala create mode 100644 project/build.sbt diff --git a/build.sbt b/build.sbt index bc248b2..aed8d57 100644 --- a/build.sbt +++ b/build.sbt @@ -80,8 +80,9 @@ val smithy = projectMatrix .in(file("modules") / "smithy") .jvmPlatform(false) .disablePlugins(AssemblyPlugin, MimaPlugin) + .enablePlugins(SmithyTraitCodegenPlugin) .settings( - name := "jsonrpclib-smithy" + name := "jsonrpclib-smithy", ) lazy val buildTimeProtocolDependency = diff --git a/project/MetaDependencies.scala b/project/MetaDependencies.scala new file mode 100644 index 0000000..013a72e --- /dev/null +++ b/project/MetaDependencies.scala @@ -0,0 +1,11 @@ +import sbt.* + +object MetaDependencies { + + val smithy = new { + val version = "1.56.0" + + val model = "software.amazon.smithy" % "smithy-model" % version + val traitCodegen = "software.amazon.smithy" % "smithy-trait-codegen" % version + } +} diff --git a/project/PathRef.scala b/project/PathRef.scala new file mode 100644 index 0000000..45539c5 --- /dev/null +++ b/project/PathRef.scala @@ -0,0 +1,32 @@ +import sbt.io.Hash +import sbt.util.FileInfo +import sbt.util.HashFileInfo +import sjsonnew.* + +import java.io.File + +case class PathRef(path: os.Path) + +object PathRef { + + def apply(f: File): PathRef = PathRef(os.Path(f)) + + implicit val pathFormat: JsonFormat[PathRef] = + BasicJsonProtocol.projectFormat[PathRef, HashFileInfo]( + p => + if (os.isFile(p.path)) FileInfo.hash(p.path.toIO) + else + // If the path is a directory, we get the hashes of all files + // then hash the concatenation of the hash's bytes. + FileInfo.hash( + p.path.toIO, + Hash( + os.walk(p.path) + .map(_.toIO) + .map(Hash(_)) + .foldLeft(Array.emptyByteArray)(_ ++ _) + ) + ), + hash => PathRef(hash.file) + ) +} diff --git a/project/SmithyTraitCodegen.scala b/project/SmithyTraitCodegen.scala new file mode 100644 index 0000000..3341cba --- /dev/null +++ b/project/SmithyTraitCodegen.scala @@ -0,0 +1,115 @@ +import sbt.* +import sbt.io.IO +import software.amazon.smithy.build.FileManifest +import software.amazon.smithy.build.PluginContext +import software.amazon.smithy.model.node.ArrayNode +import software.amazon.smithy.model.node.ObjectNode +import software.amazon.smithy.model.shapes.ShapeId +import software.amazon.smithy.model.Model +import software.amazon.smithy.traitcodegen.TraitCodegenPlugin + +import java.io.File +import java.nio.file.Paths +import java.util.UUID + +object SmithyTraitCodegen { + + import sjsonnew.* + + import BasicJsonProtocol.* + + case class Args(targetDir: os.Path, smithySourcesDir: PathRef, dependencies: List[PathRef]) + object Args { + + // format: off + private type ArgsDeconstructed = os.Path :*: PathRef :*: List[PathRef] :*: LNil + // format: on + + private implicit val pathFormat: JsonFormat[os.Path] = + BasicJsonProtocol.projectFormat[os.Path, File](p => p.toIO, file => os.Path(file)) + + implicit val argsIso = + LList.iso[Args, ArgsDeconstructed]( + { args: Args => + ("targetDir", args.targetDir) :*: + ("smithySourcesDir", args.smithySourcesDir) :*: + ("dependencies", args.dependencies) :*: + LNil + }, + { + case (_, targetDir) :*: + (_, smithySourcesDir) :*: + (_, dependencies) :*: + LNil => + Args( + targetDir = targetDir, + smithySourcesDir = smithySourcesDir, + dependencies = dependencies + ) + } + ) + + } + + case class Output(metaDir: File, javaDir: File) + + object Output { + + // format: off + private type OutputDeconstructed = File :*: File :*: LNil + // format: on + + implicit val outputIso = + LList.iso[Output, OutputDeconstructed]( + { output: Output => + ("metaDir", output.metaDir) :*: + ("javaDir", output.javaDir) :*: + LNil + }, + { + case (_, metaDir) :*: + (_, javaDir) :*: + LNil => + Output( + metaDir = metaDir, + javaDir = javaDir + ) + } + ) + } + + def generate(args: Args): Output = { + val outputDir = args.targetDir / "smithy-trait-generator-output" + val genDir = outputDir / "java" + val metaDir = outputDir / "meta" + os.remove.all(outputDir) + List(outputDir, genDir, metaDir).foreach(os.makeDir.all(_)) + + val manifest = FileManifest.create(genDir.toNIO) + + val model = args.dependencies + .foldLeft(Model.assembler().addImport(args.smithySourcesDir.path.toNIO)) { case (acc, dep) => + acc.addImport(dep.path.toNIO) + } + .assemble() + .unwrap() + val context = PluginContext + .builder() + .model(model) + .fileManifest(manifest) + .settings( + ObjectNode + .builder() + .withMember("package", "jsonrpclib") + .withMember("namespace", "jsonrpclib") + .withMember("header", ArrayNode.builder.build()) + .withMember("excludeTags", ArrayNode.builder.withValue("nocodegen").build()) + .build() + ) + .build() + val plugin = new TraitCodegenPlugin() + plugin.execute(context) + os.move(genDir / "META-INF", metaDir / "META-INF") + Output(metaDir = metaDir.toIO, javaDir = genDir.toIO) + } +} diff --git a/project/SmithyTraitCodegenPlugin.scala b/project/SmithyTraitCodegenPlugin.scala new file mode 100644 index 0000000..7a47261 --- /dev/null +++ b/project/SmithyTraitCodegenPlugin.scala @@ -0,0 +1,73 @@ +import sbt.* +import sbt.plugins.JvmPlugin +import software.amazon.smithy.build.FileManifest +import software.amazon.smithy.build.PluginContext +import software.amazon.smithy.model.node.ArrayNode +import software.amazon.smithy.model.node.ObjectNode +import software.amazon.smithy.model.Model +import software.amazon.smithy.traitcodegen.TraitCodegenPlugin + +import Keys.* + +object SmithyTraitCodegenPlugin extends AutoPlugin { + override def trigger: PluginTrigger = noTrigger + override def requires: Plugins = JvmPlugin + + override def projectSettings: Seq[Setting[?]] = + Seq( + Keys.generateSmithyTraits := Def.task { + import sbt.util.CacheImplicits.* + val s = (Compile / streams).value + val logger = sLog.value + val args = SmithyTraitCodegen.Args( + targetDir = os.Path((Compile / target).value), + smithySourcesDir = PathRef((Compile / resourceDirectory).value / "META-INF" / "smithy"), + dependencies = List.empty + ) + val cachedCodegen = + Tracked.inputChanged[SmithyTraitCodegen.Args, SmithyTraitCodegen.Output]( + s.cacheStoreFactory.make("smithy-trait-codegen-args") + ) { + Function.untupled( + Tracked + .lastOutput[(Boolean, SmithyTraitCodegen.Args), SmithyTraitCodegen.Output]( + s.cacheStoreFactory.make("smithy-trait-codegen-output") + ) { case ((inputChanged, codegenArgs), cached) => + cached + .filter(_ => !inputChanged) + .fold { + SmithyTraitCodegen.generate(codegenArgs) + } { last => + logger.info(s"Using cached result of smithy-trait-codegen") + last + } + } + ) + } + cachedCodegen(args) + }.value, + Compile / sourceGenerators += Def.task { + val codegenOutput = (Compile / Keys.generateSmithyTraits).value + cleanCopy(source = codegenOutput.javaDir, target = (Compile / sourceManaged).value / "java") + }, + Compile / resourceGenerators += Def.task { + val codegenOutput = (Compile / Keys.generateSmithyTraits).value + cleanCopy(source = codegenOutput.metaDir, target = (Compile / resourceManaged).value) + }.taskValue, + libraryDependencies += "software.amazon.smithy" % "smithy-model" % "1.56.0" + ) + + private def cleanCopy(source: File, target: File) = { + val sourcePath = os.Path(source) + val targetPath = os.Path(target) + os.remove.all(targetPath) + os.copy(from = sourcePath, to = targetPath, createFolders = true) + os.walk(targetPath).map(_.toIO).filter(_.isFile()) + } + + object Keys { + val generateSmithyTraits = + taskKey[SmithyTraitCodegen.Output]("Run AWS smithy-trait-codegen on the protocol specs") + } + +} diff --git a/project/build.sbt b/project/build.sbt new file mode 100644 index 0000000..27775d2 --- /dev/null +++ b/project/build.sbt @@ -0,0 +1,4 @@ +libraryDependencies ++= Seq( + "software.amazon.smithy" % "smithy-trait-codegen", + "software.amazon.smithy" % "smithy-model" +).map(_ % "1.56.0") From 8aea9e65401dbc2c8d8f16189f9fe82e81312a17 Mon Sep 17 00:00:00 2001 From: ghostbuster91 Date: Wed, 7 May 2025 00:04:59 +0200 Subject: [PATCH 16/47] Set minJdkVersion to 11 --- build.sbt | 35 ++++++++++++++++++++++++----------- 1 file changed, 24 insertions(+), 11 deletions(-) diff --git a/build.sbt b/build.sbt index aed8d57..65108b7 100644 --- a/build.sbt +++ b/build.sbt @@ -17,6 +17,7 @@ inThisBuild( val scala213 = "2.13.16" val scala3 = "3.3.5" +val jdkVersion = 11 val allScalaVersions = List(scala213, scala3) val jvmScalaVersions = allScalaVersions val jsScalaVersions = allScalaVersions @@ -34,16 +35,27 @@ val commonSettings = Seq( mimaPreviousArtifacts := Set( organization.value %%% name.value % "0.0.7" ), - scalacOptions += "-java-output-version:11" + scalacOptions ++= { + CrossVersion.partialVersion(scalaVersion.value) match { + case Some((2, _)) => Seq(s"-target:jvm-$jdkVersion") + case _ => Seq(s"-java-output-version:$jdkVersion") + } + } +) + +val commonJvmSettings = Seq( + javacOptions ++= Seq("--release", jdkVersion.toString) ) val core = projectMatrix .in(file("modules") / "core") .jvmPlatform( jvmScalaVersions, - Test / unmanagedSourceDirectories ++= Seq( - (projectMatrixBaseDirectory.value / "src" / "test" / "scalajvm-native").getAbsoluteFile - ) + Seq( + Test / unmanagedSourceDirectories ++= Seq( + (projectMatrixBaseDirectory.value / "src" / "test" / "scalajvm-native").getAbsoluteFile + ) + ) ++ commonJvmSettings ) .jsPlatform(jsScalaVersions) .nativePlatform( @@ -63,7 +75,7 @@ val core = projectMatrix val fs2 = projectMatrix .in(file("modules") / "fs2") - .jvmPlatform(jvmScalaVersions) + .jvmPlatform(jvmScalaVersions, commonJvmSettings) .jsPlatform(jsScalaVersions) .nativePlatform(nativeScalaVersions) .disablePlugins(AssemblyPlugin) @@ -83,6 +95,7 @@ val smithy = projectMatrix .enablePlugins(SmithyTraitCodegenPlugin) .settings( name := "jsonrpclib-smithy", + commonJvmSettings ) lazy val buildTimeProtocolDependency = @@ -103,7 +116,7 @@ lazy val buildTimeProtocolDependency = val smithy4s = projectMatrix .in(file("modules") / "smithy4s") - .jvmPlatform(jvmScalaVersions) + .jvmPlatform(jvmScalaVersions, commonJvmSettings) .jsPlatform(jsScalaVersions) .nativePlatform(Seq(scala3)) .disablePlugins(AssemblyPlugin) @@ -122,7 +135,7 @@ val smithy4s = projectMatrix val exampleServer = projectMatrix .in(file("modules") / "examples/server") - .jvmPlatform(List(scala213)) + .jvmPlatform(List(scala213), commonJvmSettings) .dependsOn(fs2) .settings( commonSettings, @@ -140,7 +153,7 @@ val exampleClient = projectMatrix Seq( fork := true, envVars += "SERVER_JAR" -> (exampleServer.jvm(scala213) / assembly).value.toString - ) + ) ++ commonJvmSettings ) .disablePlugins(AssemblyPlugin) .dependsOn(fs2) @@ -155,7 +168,7 @@ val exampleClient = projectMatrix val exampleSmithyShared = projectMatrix .in(file("modules") / "examples/smithyShared") - .jvmPlatform(List(scala213)) + .jvmPlatform(List(scala213), commonJvmSettings) .dependsOn(smithy4s) .enablePlugins(Smithy4sCodegenPlugin) .settings( @@ -167,7 +180,7 @@ val exampleSmithyShared = projectMatrix val exampleSmithyServer = projectMatrix .in(file("modules") / "examples/smithyServer") - .jvmPlatform(List(scala213)) + .jvmPlatform(List(scala213), commonJvmSettings) .dependsOn(exampleSmithyShared) .settings( commonSettings, @@ -191,7 +204,7 @@ val exampleSmithyClient = projectMatrix Seq( fork := true, envVars += "SERVER_JAR" -> (exampleSmithyServer.jvm(scala213) / assembly).value.toString - ) + ) ++ commonJvmSettings ) .dependsOn(exampleSmithyShared) .settings( From e45acf798f9c30ca07ca2b75f3d1c975dad94ce4 Mon Sep 17 00:00:00 2001 From: ghostbuster91 Date: Wed, 7 May 2025 00:19:28 +0200 Subject: [PATCH 17/47] Remove unused file --- project/MetaDependencies.scala | 11 ----------- 1 file changed, 11 deletions(-) delete mode 100644 project/MetaDependencies.scala diff --git a/project/MetaDependencies.scala b/project/MetaDependencies.scala deleted file mode 100644 index 013a72e..0000000 --- a/project/MetaDependencies.scala +++ /dev/null @@ -1,11 +0,0 @@ -import sbt.* - -object MetaDependencies { - - val smithy = new { - val version = "1.56.0" - - val model = "software.amazon.smithy" % "smithy-model" % version - val traitCodegen = "software.amazon.smithy" % "smithy-trait-codegen" % version - } -} From 97462b6fa5455465c33e9561119cca61f192367d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Koz=C5=82owski?= Date: Wed, 7 May 2025 02:28:01 +0200 Subject: [PATCH 18/47] Simplify smithy4s implementation, get rid of fs2 in it --- build.sbt | 4 +- .../src/main/scala/jsonrpclib/Monadic.scala | 6 ++ .../scala/examples/client/ClientMain.scala | 2 +- .../scala/examples/server/ServerMain.scala | 3 +- .../examples/smithy/client/ClientMain.scala | 4 +- .../examples/smithy/server/ServerMain.scala | 10 ++- .../scala/jsonrpclib/fs2/FS2Channel.scala | 6 ++ .../main/scala/jsonrpclib/fs2/package.scala | 4 ++ .../scala/jsonrpclib/fs2/FS2ChannelSpec.scala | 4 +- .../smithy4sinterop/ClientStub.scala | 67 +++++-------------- .../smithy4sinterop/EndpointSpec.scala | 4 +- .../smithy4sinterop/ServerEndpoints.scala | 10 ++- 12 files changed, 52 insertions(+), 72 deletions(-) diff --git a/build.sbt b/build.sbt index 4182fd9..a8faea4 100644 --- a/build.sbt +++ b/build.sbt @@ -93,7 +93,7 @@ val smithy4s = projectMatrix .nativePlatform(Seq(scala3)) .disablePlugins(AssemblyPlugin) .enablePlugins(Smithy4sCodegenPlugin) - .dependsOn(fs2) + .dependsOn(core) .dependsOn(smithy) .settings( name := "jsonrpclib-smithy4s", @@ -141,7 +141,7 @@ val exampleClient = projectMatrix val exampleSmithyShared = projectMatrix .in(file("modules") / "examples/smithyShared") .jvmPlatform(List(scala213)) - .dependsOn(smithy4s) + .dependsOn(smithy4s, fs2) .enablePlugins(Smithy4sCodegenPlugin) .settings( commonSettings, diff --git a/modules/core/src/main/scala/jsonrpclib/Monadic.scala b/modules/core/src/main/scala/jsonrpclib/Monadic.scala index 5168dd5..0d5a7f0 100644 --- a/modules/core/src/main/scala/jsonrpclib/Monadic.scala +++ b/modules/core/src/main/scala/jsonrpclib/Monadic.scala @@ -8,9 +8,13 @@ trait Monadic[F[_]] { def doPure[A](a: A): F[A] def doAttempt[A](fa: F[A]): F[Either[Throwable, A]] def doRaiseError[A](e: Throwable): F[A] + def doMap[A, B](fa: F[A])(f: A => B): F[B] = doFlatMap(fa)(a => doPure(f(a))) + def doVoid[A](fa: F[A]): F[Unit] = doMap(fa)(_ => ()) } object Monadic { + def apply[F[_]](implicit F: Monadic[F]): Monadic[F] = F + implicit def monadicFuture(implicit ec: ExecutionContext): Monadic[Future] = new Monadic[Future] { def doFlatMap[A, B](fa: Future[A])(f: A => Future[B]): Future[B] = fa.flatMap(f) @@ -19,5 +23,7 @@ object Monadic { def doAttempt[A](fa: Future[A]): Future[Either[Throwable, A]] = fa.map(Right(_)).recover(Left(_)) def doRaiseError[A](e: Throwable): Future[A] = Future.failed(e) + + override def doMap[A, B](fa: Future[A])(f: A => B): Future[B] = fa.map(f) } } diff --git a/modules/examples/client/src/main/scala/examples/client/ClientMain.scala b/modules/examples/client/src/main/scala/examples/client/ClientMain.scala index 5097f2d..1173094 100644 --- a/modules/examples/client/src/main/scala/examples/client/ClientMain.scala +++ b/modules/examples/client/src/main/scala/examples/client/ClientMain.scala @@ -32,7 +32,7 @@ object ClientMain extends IOApp.Simple { // Starting the server rp <- fs2.Stream.resource(Processes[IO].spawn(process.ProcessBuilder("java", "-jar", serverJar))) // Creating a channel that will be used to communicate to the server - fs2Channel <- FS2Channel[IO](cancelTemplate = cancelEndpoint.some) + fs2Channel <- FS2Channel.stream[IO](cancelTemplate = cancelEndpoint.some) _ <- Stream(()) .concurrently(fs2Channel.output.through(lsp.encodeMessages).through(rp.stdin)) .concurrently(rp.stdout.through(lsp.decodeMessages).through(fs2Channel.inputOrBounce)) diff --git a/modules/examples/server/src/main/scala/examples/server/ServerMain.scala b/modules/examples/server/src/main/scala/examples/server/ServerMain.scala index 72c9804..445274e 100644 --- a/modules/examples/server/src/main/scala/examples/server/ServerMain.scala +++ b/modules/examples/server/src/main/scala/examples/server/ServerMain.scala @@ -28,7 +28,8 @@ object ServerMain extends IOApp.Simple { def run: IO[Unit] = { // Using errorln as stdout is used by the RPC channel IO.consoleForIO.errorln("Starting server") >> - FS2Channel[IO](cancelTemplate = Some(cancelEndpoint)) + FS2Channel + .stream[IO](cancelTemplate = Some(cancelEndpoint)) .flatMap(_.withEndpointStream(increment)) // mounting an endpoint onto the channel .flatMap(channel => fs2.Stream diff --git a/modules/examples/smithyClient/src/main/scala/examples/smithy/client/ClientMain.scala b/modules/examples/smithyClient/src/main/scala/examples/smithy/client/ClientMain.scala index ee3b8c2..06871b2 100644 --- a/modules/examples/smithyClient/src/main/scala/examples/smithy/client/ClientMain.scala +++ b/modules/examples/smithyClient/src/main/scala/examples/smithy/client/ClientMain.scala @@ -32,11 +32,11 @@ object SmithyClientMain extends IOApp.Simple { // Starting the server rp <- ChildProcess.spawn[IO]("java", "-jar", serverJar) // Creating a channel that will be used to communicate to the server - fs2Channel <- FS2Channel[IO](cancelTemplate = cancelEndpoint.some) + fs2Channel <- FS2Channel.stream[IO](cancelTemplate = cancelEndpoint.some) // Mounting our implementation of the generated interface onto the channel _ <- fs2Channel.withEndpointsStream(ServerEndpoints(Client)) // Creating stubs to talk to the remote server - server: TestServer[IO] <- ClientStub.stream(test.TestServer, fs2Channel) + server: TestServer[IO] = ClientStub(test.TestServer, fs2Channel) _ <- Stream(()) .concurrently(fs2Channel.output.through(lsp.encodeMessages).through(rp.stdin)) .concurrently(rp.stdout.through(lsp.decodeMessages).through(fs2Channel.inputOrBounce)) diff --git a/modules/examples/smithyServer/src/main/scala/examples/smithy/server/ServerMain.scala b/modules/examples/smithyServer/src/main/scala/examples/smithy/server/ServerMain.scala index 79075bb..ed81d28 100644 --- a/modules/examples/smithyServer/src/main/scala/examples/smithy/server/ServerMain.scala +++ b/modules/examples/smithyServer/src/main/scala/examples/smithy/server/ServerMain.scala @@ -24,13 +24,11 @@ object ServerMain extends IOApp.Simple { def run: IO[Unit] = { val run = - FS2Channel[IO](cancelTemplate = Some(cancelEndpoint)) + FS2Channel + .stream[IO](cancelTemplate = Some(cancelEndpoint)) .flatMap { channel => - ClientStub - .stream(TestClient, channel) - .flatMap { testClient => - channel.withEndpointsStream(ServerEndpoints(new ServerImpl(testClient))) - } + val testClient = ClientStub(TestClient, channel) + channel.withEndpointsStream(ServerEndpoints(new ServerImpl(testClient))) } .flatMap { channel => fs2.Stream diff --git a/modules/fs2/src/main/scala/jsonrpclib/fs2/FS2Channel.scala b/modules/fs2/src/main/scala/jsonrpclib/fs2/FS2Channel.scala index 2be38ab..c2cfa78 100644 --- a/modules/fs2/src/main/scala/jsonrpclib/fs2/FS2Channel.scala +++ b/modules/fs2/src/main/scala/jsonrpclib/fs2/FS2Channel.scala @@ -70,9 +70,15 @@ object FS2Channel { } yield impl } + @deprecated("use stream or resource", "0.0.9") def apply[F[_]: Concurrent]( bufferSize: Int = 2048, cancelTemplate: Option[CancelTemplate] = None + ): Stream[F, FS2Channel[F]] = stream(bufferSize, cancelTemplate) + + def stream[F[_]: Concurrent]( + bufferSize: Int = 2048, + cancelTemplate: Option[CancelTemplate] = None ): Stream[F, FS2Channel[F]] = Stream.resource(resource(bufferSize, cancelTemplate)) private case class State[F[_]]( diff --git a/modules/fs2/src/main/scala/jsonrpclib/fs2/package.scala b/modules/fs2/src/main/scala/jsonrpclib/fs2/package.scala index c77c114..f36ab31 100644 --- a/modules/fs2/src/main/scala/jsonrpclib/fs2/package.scala +++ b/modules/fs2/src/main/scala/jsonrpclib/fs2/package.scala @@ -24,6 +24,10 @@ package object fs2 { def doAttempt[A](fa: F[A]): F[Either[Throwable, A]] = MonadThrow[F].attempt(fa) def doRaiseError[A](e: Throwable): F[A] = MonadThrow[F].raiseError(e) + + override def doMap[A, B](fa: F[A])(f: A => B): F[B] = Monad[F].map(fa)(f) + + override def doVoid[A](fa: F[A]): F[Unit] = Monad[F].void(fa) } } diff --git a/modules/fs2/src/test/scala/jsonrpclib/fs2/FS2ChannelSpec.scala b/modules/fs2/src/test/scala/jsonrpclib/fs2/FS2ChannelSpec.scala index 43b7c60..e6c94dc 100644 --- a/modules/fs2/src/test/scala/jsonrpclib/fs2/FS2ChannelSpec.scala +++ b/modules/fs2/src/test/scala/jsonrpclib/fs2/FS2ChannelSpec.scala @@ -30,8 +30,8 @@ object FS2ChannelSpec extends SimpleIOSuite { def setup(cancelTemplate: CancelTemplate, endpoints: Endpoint[IO]*) = setupAux(endpoints, Some(cancelTemplate)) def setupAux(endpoints: Seq[Endpoint[IO]], cancelTemplate: Option[CancelTemplate]): Stream[IO, ClientSideChannel] = { for { - serverSideChannel <- FS2Channel[IO](cancelTemplate = cancelTemplate) - clientSideChannel <- FS2Channel[IO](cancelTemplate = cancelTemplate) + serverSideChannel <- FS2Channel.stream[IO](cancelTemplate = cancelTemplate) + clientSideChannel <- FS2Channel.stream[IO](cancelTemplate = cancelTemplate) _ <- serverSideChannel.withEndpointsStream(endpoints) _ <- Stream(()) .concurrently(clientSideChannel.output.through(serverSideChannel.input)) diff --git a/modules/smithy4s/src/main/scala/jsonrpclib/smithy4sinterop/ClientStub.scala b/modules/smithy4s/src/main/scala/jsonrpclib/smithy4sinterop/ClientStub.scala index f6e56ea..ee8c71e 100644 --- a/modules/smithy4s/src/main/scala/jsonrpclib/smithy4sinterop/ClientStub.scala +++ b/modules/smithy4s/src/main/scala/jsonrpclib/smithy4sinterop/ClientStub.scala @@ -1,67 +1,34 @@ package jsonrpclib.smithy4sinterop import smithy4s.~> -import cats.MonadThrow -import jsonrpclib.fs2._ import smithy4s.Service import smithy4s.schema._ -import cats.effect.kernel.Async -import smithy4s.kinds.PolyFunction5 import smithy4s.ShapeId -import cats.syntax.all._ import smithy4s.json.Json import jsonrpclib.Codec._ import com.github.plokhotnyuk.jsoniter_scala.core._ +import jsonrpclib.Channel +import jsonrpclib.Monadic object ClientStub { - def apply[Alg[_[_, _, _, _, _]], F[_]](service: Service[Alg], channel: FS2Channel[F])(implicit - F: Async[F] - ): F[service.Impl[F]] = new ClientStub(service, channel).compile - - def stream[Alg[_[_, _, _, _, _]], F[_]](service: Service[Alg], channel: FS2Channel[F])(implicit - F: Async[F] - ): fs2.Stream[F, service.Impl[F]] = fs2.Stream.eval(new ClientStub(service, channel).compile) + def apply[Alg[_[_, _, _, _, _]], F[_]: Monadic](service: Service[Alg], channel: Channel[F]): service.Impl[F] = + new ClientStub(service, channel).compile } -private class ClientStub[Alg[_[_, _, _, _, _]], F[_]](val service: Service[Alg], channel: FS2Channel[F])(implicit - F: Async[F] -) { - - def compile: F[service.Impl[F]] = precompileAll.map { stubCache => - val interpreter = new service.FunctorInterpreter[F] { - def apply[I, E, O, SI, SO](op: service.Operation[I, E, O, SI, SO]): F[O] = { - val smithy4sEndpoint = service.endpoint(op) - val input = service.input(op) - (stubCache(smithy4sEndpoint): F[I => F[O]]).flatMap { stub => - stub(input) - } +private class ClientStub[Alg[_[_, _, _, _, _]], F[_]: Monadic](val service: Service[Alg], channel: Channel[F]) { + + def compile: service.Impl[F] = { + val interpreter = new service.FunctorEndpointCompiler[F] { + def apply[I, E, O, SI, SO](e: service.Endpoint[I, E, O, SI, SO]): I => F[O] = { + val shapeId = e.id + val spec = EndpointSpec.fromHints(e.hints).toRight(NotJsonRPCEndpoint(shapeId)).toTry.get + + jsonRPCStub(e, spec) } } - service.fromPolyFunction(interpreter) - } - private type Stub[I, E, O, SI, SO] = F[I => F[O]] - private val precompileAll: F[PolyFunction5[service.Endpoint, Stub]] = { - F.ref(Map.empty[ShapeId, Any]).flatMap { cache => - service.endpoints.toList - .traverse_ { ep => - val shapeId = ep.id - EndpointSpec.fromHints(ep.hints).liftTo[F](NotJsonRPCEndpoint(shapeId)).flatMap { epSpec => - val stub = jsonRPCStub(ep, epSpec) - cache.update(_ + (shapeId -> stub)) - } - } - .as { - new PolyFunction5[service.Endpoint, Stub] { - def apply[I, E, O, SI, SO](ep: service.Endpoint[I, E, O, SI, SO]): Stub[I, E, O, SI, SO] = { - cache.get.map { c => - c(ep.id).asInstanceOf[I => F[O]] - } - } - } - } - } + service.impl(interpreter) } private val jsoniterCodecGlobalCache = Json.jsoniter.createCache() @@ -80,7 +47,7 @@ private class ClientStub[Alg[_[_, _, _, _, _]], F[_]](val service: Service[Alg], endpointSpec match { case EndpointSpec.Notification(methodName) => val coerce = coerceUnit[O](smithy4sEndpoint.output) - channel.notificationStub[I](methodName).andThen(f => f *> coerce) + channel.notificationStub[I](methodName).andThen(f => Monadic[F].doFlatMap(f)(_ => coerce)) case EndpointSpec.Request(methodName) => channel.simpleStub[I, O](methodName) } @@ -92,8 +59,8 @@ private class ClientStub[Alg[_[_, _, _, _, _]], F[_]](val service: Service[Alg], private object CoerceUnitVisitor extends (Schema ~> F) { def apply[A](schema: Schema[A]): F[A] = schema match { case s @ Schema.StructSchema(_, _, _, make) if s.isUnit => - MonadThrow[F].unit.asInstanceOf[F[A]] - case _ => MonadThrow[F].raiseError[A](NotUnitReturnType) + Monadic[F].doPure(()).asInstanceOf[F[A]] + case _ => Monadic[F].doRaiseError[A](NotUnitReturnType) } } diff --git a/modules/smithy4s/src/main/scala/jsonrpclib/smithy4sinterop/EndpointSpec.scala b/modules/smithy4s/src/main/scala/jsonrpclib/smithy4sinterop/EndpointSpec.scala index 2e29930..4dd4386 100644 --- a/modules/smithy4s/src/main/scala/jsonrpclib/smithy4sinterop/EndpointSpec.scala +++ b/modules/smithy4s/src/main/scala/jsonrpclib/smithy4sinterop/EndpointSpec.scala @@ -2,8 +2,8 @@ package jsonrpclib.smithy4sinterop import smithy4s.Hints -sealed trait EndpointSpec -object EndpointSpec { +private[smithy4sinterop] sealed trait EndpointSpec +private[smithy4sinterop] object EndpointSpec { case class Notification(methodName: String) extends EndpointSpec case class Request(methodName: String) extends EndpointSpec diff --git a/modules/smithy4s/src/main/scala/jsonrpclib/smithy4sinterop/ServerEndpoints.scala b/modules/smithy4s/src/main/scala/jsonrpclib/smithy4sinterop/ServerEndpoints.scala index 39b476a..a6593f7 100644 --- a/modules/smithy4s/src/main/scala/jsonrpclib/smithy4sinterop/ServerEndpoints.scala +++ b/modules/smithy4s/src/main/scala/jsonrpclib/smithy4sinterop/ServerEndpoints.scala @@ -1,10 +1,7 @@ package jsonrpclib.smithy4sinterop import _root_.smithy4s.{Endpoint => Smithy4sEndpoint} -import cats.MonadThrow -import cats.syntax.all._ import jsonrpclib.Endpoint -import jsonrpclib.fs2._ import smithy4s.Service import smithy4s.kinds.FunctorAlgebra import smithy4s.kinds.FunctorInterpreter @@ -12,12 +9,13 @@ import smithy4s.json.Json import smithy4s.schema.Schema import jsonrpclib.Codec._ import com.github.plokhotnyuk.jsoniter_scala.core._ +import jsonrpclib.Monadic object ServerEndpoints { def apply[Alg[_[_, _, _, _, _]], F[_]]( impl: FunctorAlgebra[Alg, F] - )(implicit service: Service[Alg], F: MonadThrow[F]): List[Endpoint[F]] = { + )(implicit service: Service[Alg], F: Monadic[F]): List[Endpoint[F]] = { val interpreter: service.FunctorInterpreter[F] = service.toPolyFunction(impl) service.endpoints.toList.flatMap { smithy4sEndpoint => EndpointSpec @@ -35,7 +33,7 @@ object ServerEndpoints { Json.jsoniter.fromSchema(schema, jsoniterCodecGlobalCache) // TODO : codify errors at smithy level and handle them. - def jsonRPCEndpoint[F[_]: MonadThrow, Op[_, _, _, _, _], I, E, O, SI, SO]( + def jsonRPCEndpoint[F[_]: Monadic, Op[_, _, _, _, _], I, E, O, SI, SO]( smithy4sEndpoint: Smithy4sEndpoint[Op, I, E, O, SI, SO], endpointSpec: EndpointSpec, impl: FunctorInterpreter[Op, F] @@ -48,7 +46,7 @@ object ServerEndpoints { case EndpointSpec.Notification(methodName) => Endpoint[F](methodName).notification { (input: I) => val op = smithy4sEndpoint.wrap(input) - impl(op).void + Monadic[F].doVoid(impl(op)) } case EndpointSpec.Request(methodName) => Endpoint[F](methodName).simple { (input: I) => From cb4026244a20125e616130d7f1774d5250bba613 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Koz=C5=82owski?= Date: Wed, 7 May 2025 02:34:07 +0200 Subject: [PATCH 19/47] bump smithy4s --- project/plugins.sbt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/project/plugins.sbt b/project/plugins.sbt index 00f2eb5..399d7d0 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -14,6 +14,6 @@ addSbtPlugin("com.eed3si9n" % "sbt-assembly" % "2.3.1") addSbtPlugin("org.scalameta" % "sbt-scalafmt" % "2.5.4") -addSbtPlugin("com.disneystreaming.smithy4s" % "smithy4s-sbt-codegen" % "0.18.34") +addSbtPlugin("com.disneystreaming.smithy4s" % "smithy4s-sbt-codegen" % "0.18.35") addDependencyTreePlugin From be15b68d642ad75e79d2ae972550fc9a4b570dd2 Mon Sep 17 00:00:00 2001 From: ghostbuster91 Date: Thu, 8 May 2025 11:39:59 +0200 Subject: [PATCH 20/47] Remove half-baked FutureBaseChannel --- .../internals/FutureBaseChannel.scala | 41 ------------------- 1 file changed, 41 deletions(-) delete mode 100644 modules/core/src/main/scala/jsonrpclib/internals/FutureBaseChannel.scala diff --git a/modules/core/src/main/scala/jsonrpclib/internals/FutureBaseChannel.scala b/modules/core/src/main/scala/jsonrpclib/internals/FutureBaseChannel.scala deleted file mode 100644 index cb73e08..0000000 --- a/modules/core/src/main/scala/jsonrpclib/internals/FutureBaseChannel.scala +++ /dev/null @@ -1,41 +0,0 @@ -package jsonrpclib - -import jsonrpclib.internals._ - -import java.util.concurrent.atomic.AtomicLong -import scala.concurrent.ExecutionContext -import scala.concurrent.Future -import scala.concurrent.Promise -import scala.util.Try - -abstract class FutureBasedChannel(endpoints: List[Endpoint[Future]])(implicit ec: ExecutionContext) - extends MessageDispatcher[Future] { - - override def createPromise[A](callId: CallId): Future[(Try[A] => Future[Unit], () => Future[A])] = Future.successful { - val promise = Promise[A]() - val fulfill: Try[A] => Future[Unit] = (a: Try[A]) => Future.successful(promise.complete(a)) - val future: () => Future[A] = () => promise.future - (fulfill, future) - } - - protected def storePendingCall(callId: CallId, handle: OutputMessage => Future[Unit]): Future[Unit] = - Future.successful { val _ = pending.put(callId, handle) } - protected def removePendingCall(callId: CallId): Future[Option[OutputMessage => Future[Unit]]] = - Future.successful { Option(pending.remove(callId)) } - 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(_ => ()) - } - protected def nextCallId(): Future[CallId] = Future.successful(CallId.NumberId(nextID.incrementAndGet())) - - private[this] val endpointsMap: Map[String, Endpoint[Future]] = endpoints.map(ep => ep.method -> ep).toMap - private[this] val pending = new java.util.concurrent.ConcurrentHashMap[CallId, OutputMessage => Future[Unit]] - private[this] val nextID = new AtomicLong(0L) - // @volatile - // private[this] var closeReason: Throwable = _ - - def sendPayload(msg: Payload): Future[Unit] = ??? - def reportError(params: Option[Payload], error: ProtocolError, method: String): Future[Unit] = ??? - -} From 2ba2babb3d1b2ad74f21bb46696af281ceb4f8b1 Mon Sep 17 00:00:00 2001 From: ghostbuster91 Date: Thu, 8 May 2025 11:48:16 +0200 Subject: [PATCH 21/47] Revert "Remove half-baked FutureBaseChannel" This reverts commit be15b68d642ad75e79d2ae972550fc9a4b570dd2. --- .../internals/FutureBaseChannel.scala | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 modules/core/src/main/scala/jsonrpclib/internals/FutureBaseChannel.scala diff --git a/modules/core/src/main/scala/jsonrpclib/internals/FutureBaseChannel.scala b/modules/core/src/main/scala/jsonrpclib/internals/FutureBaseChannel.scala new file mode 100644 index 0000000..cb73e08 --- /dev/null +++ b/modules/core/src/main/scala/jsonrpclib/internals/FutureBaseChannel.scala @@ -0,0 +1,41 @@ +package jsonrpclib + +import jsonrpclib.internals._ + +import java.util.concurrent.atomic.AtomicLong +import scala.concurrent.ExecutionContext +import scala.concurrent.Future +import scala.concurrent.Promise +import scala.util.Try + +abstract class FutureBasedChannel(endpoints: List[Endpoint[Future]])(implicit ec: ExecutionContext) + extends MessageDispatcher[Future] { + + override def createPromise[A](callId: CallId): Future[(Try[A] => Future[Unit], () => Future[A])] = Future.successful { + val promise = Promise[A]() + val fulfill: Try[A] => Future[Unit] = (a: Try[A]) => Future.successful(promise.complete(a)) + val future: () => Future[A] = () => promise.future + (fulfill, future) + } + + protected def storePendingCall(callId: CallId, handle: OutputMessage => Future[Unit]): Future[Unit] = + Future.successful { val _ = pending.put(callId, handle) } + protected def removePendingCall(callId: CallId): Future[Option[OutputMessage => Future[Unit]]] = + Future.successful { Option(pending.remove(callId)) } + 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(_ => ()) + } + protected def nextCallId(): Future[CallId] = Future.successful(CallId.NumberId(nextID.incrementAndGet())) + + private[this] val endpointsMap: Map[String, Endpoint[Future]] = endpoints.map(ep => ep.method -> ep).toMap + private[this] val pending = new java.util.concurrent.ConcurrentHashMap[CallId, OutputMessage => Future[Unit]] + private[this] val nextID = new AtomicLong(0L) + // @volatile + // private[this] var closeReason: Throwable = _ + + def sendPayload(msg: Payload): Future[Unit] = ??? + def reportError(params: Option[Payload], error: ProtocolError, method: String): Future[Unit] = ??? + +} From 5359542294e8c74ad91a0ece2bdc83d0df856525 Mon Sep 17 00:00:00 2001 From: Kasper Kondzielski Date: Tue, 13 May 2025 15:00:41 +0200 Subject: [PATCH 22/47] feat: Replace jsoniter macros with circe (#83) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: ghostbuster91 Co-authored-by: Jakub Kozłowski --- build.sbt | 26 ++++------ .../src/main/scala/jsonrpclib/CallId.scala | 34 +++++-------- .../src/main/scala/jsonrpclib/Channel.scala | 2 + .../src/main/scala/jsonrpclib/Codec.scala | 35 ------------- .../src/main/scala/jsonrpclib/Endpoint.scala | 2 + .../main/scala/jsonrpclib/ErrorPayload.scala | 9 ++-- .../src/main/scala/jsonrpclib/Message.scala | 31 +++++------ .../src/main/scala/jsonrpclib/Payload.scala | 47 +++-------------- .../main/scala/jsonrpclib/StubTemplate.scala | 2 + .../internals/FutureBaseChannel.scala | 4 +- .../internals/MessageDispatcher.scala | 38 +++++++++----- .../jsonrpclib/internals/RawMessage.scala | 43 +++++++++++++--- .../test/scala/jsonrpclib/CallIdSpec.scala | 11 ++-- .../scala/jsonrpclib/RawMessageSpec.scala | 9 +++- .../scala/examples/client/ClientMain.scala | 6 +-- .../scala/examples/server/ServerMain.scala | 6 +-- .../scala/jsonrpclib/fs2/CancelTemplate.scala | 2 +- .../scala/jsonrpclib/fs2/FS2Channel.scala | 1 + .../src/main/scala/jsonrpclib/fs2/lsp.scala | 21 ++++---- .../scala/jsonrpclib/fs2/FS2ChannelSpec.scala | 8 +-- .../smithy4sinterop/CirceJson.scala | 51 +++++++++++++++++++ .../smithy4sinterop/ClientStub.scala | 13 ++--- .../smithy4sinterop/ServerEndpoints.scala | 14 ++--- 23 files changed, 211 insertions(+), 204 deletions(-) delete mode 100644 modules/core/src/main/scala/jsonrpclib/Codec.scala create mode 100644 modules/smithy4s/src/main/scala/jsonrpclib/smithy4sinterop/CirceJson.scala diff --git a/build.sbt b/build.sbt index e8d995d..a67087c 100644 --- a/build.sbt +++ b/build.sbt @@ -33,7 +33,7 @@ val commonSettings = Seq( "com.disneystreaming" %%% "weaver-cats" % "0.8.4" % Test ), mimaPreviousArtifacts := Set( - organization.value %%% name.value % "0.0.7" + // organization.value %%% name.value % "0.0.7" ), scalacOptions ++= { CrossVersion.partialVersion(scalaVersion.value) match { @@ -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" ) ) @@ -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 ) ) @@ -127,7 +128,6 @@ val smithy4s = projectMatrix commonSettings, mimaPreviousArtifacts := Set.empty, libraryDependencies ++= Seq( - "co.fs2" %%% "fs2-core" % fs2Version, "com.disneystreaming.smithy4s" %%% "smithy4s-json" % smithy4sVersion.value ), buildTimeProtocolDependency @@ -141,7 +141,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) @@ -161,7 +162,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) @@ -236,17 +238,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" ) diff --git a/modules/core/src/main/scala/jsonrpclib/CallId.scala b/modules/core/src/main/scala/jsonrpclib/CallId.scala index c70fd9f..dcf7dd6 100644 --- a/modules/core/src/main/scala/jsonrpclib/CallId.scala +++ b/modules/core/src/main/scala/jsonrpclib/CallId.scala @@ -1,7 +1,6 @@ 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 { @@ -9,24 +8,17 @@ object 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 codec: Codec[CallId] = Codec.from( + Decoder + .decodeOption(Decoder.decodeString.map(StringId(_): CallId).or(Decoder.decodeLong.map(NumberId(_): CallId))) + .map { + case None => NullId + case Some(v) => v + }, + { + case NumberId(n) => Json.fromLong(n) + case StringId(str) => Json.fromString(str) + case NullId => Json.Null } - - 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 - } + ) } diff --git a/modules/core/src/main/scala/jsonrpclib/Channel.scala b/modules/core/src/main/scala/jsonrpclib/Channel.scala index 6efcda6..ba533e4 100644 --- a/modules/core/src/main/scala/jsonrpclib/Channel.scala +++ b/modules/core/src/main/scala/jsonrpclib/Channel.scala @@ -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] diff --git a/modules/core/src/main/scala/jsonrpclib/Codec.scala b/modules/core/src/main/scala/jsonrpclib/Codec.scala deleted file mode 100644 index 1cc3059..0000000 --- a/modules/core/src/main/scala/jsonrpclib/Codec.scala +++ /dev/null @@ -1,35 +0,0 @@ -package jsonrpclib - -import com.github.plokhotnyuk.jsoniter_scala.core._ - -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) - - implicit def fromJsonCodec[A](implicit jsonCodec: JsonValueCodec[A]): Codec[A] = new Codec[A] { - def encode(a: A): Payload = { - Payload(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")) - } - } catch { case e: JsonReaderException => Left(ProtocolError.ParseError(e.getMessage())) } - } - } - - private val nullArray = "null".getBytes() - -} diff --git a/modules/core/src/main/scala/jsonrpclib/Endpoint.scala b/modules/core/src/main/scala/jsonrpclib/Endpoint.scala index f46267c..1d7197a 100644 --- a/modules/core/src/main/scala/jsonrpclib/Endpoint.scala +++ b/modules/core/src/main/scala/jsonrpclib/Endpoint.scala @@ -1,5 +1,7 @@ package jsonrpclib +import io.circe.Codec + sealed trait Endpoint[F[_]] { def method: String } diff --git a/modules/core/src/main/scala/jsonrpclib/ErrorPayload.scala b/modules/core/src/main/scala/jsonrpclib/ErrorPayload.scala index b0a2cc3..fddb5a2 100644 --- a/modules/core/src/main/scala/jsonrpclib/ErrorPayload.scala +++ b/modules/core/src/main/scala/jsonrpclib/ErrorPayload.scala @@ -1,7 +1,6 @@ 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" @@ -9,7 +8,9 @@ case class ErrorPayload(code: Int, message: String, data: Option[Payload]) exten 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) } diff --git a/modules/core/src/main/scala/jsonrpclib/Message.scala b/modules/core/src/main/scala/jsonrpclib/Message.scala index 10d50fa..ee7d643 100644 --- a/modules/core/src/main/scala/jsonrpclib/Message.scala +++ b/modules/core/src/main/scala/jsonrpclib/Message.scala @@ -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._ +import io.circe.Codec 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 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 codec: Codec[Message] = Codec.from( + { c => + c.as[RawMessage].flatMap(_.toMessage.left.map(e => io.circe.DecodingFailure(e.getMessage, c.history))) + }, + RawMessage.from(_).asJson + ) } diff --git a/modules/core/src/main/scala/jsonrpclib/Payload.scala b/modules/core/src/main/scala/jsonrpclib/Payload.scala index a423c2b..cc77767 100644 --- a/modules/core/src/main/scala/jsonrpclib/Payload.scala +++ b/modules/core/src/main/scala/jsonrpclib/Payload.scala @@ -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(_)) } diff --git a/modules/core/src/main/scala/jsonrpclib/StubTemplate.scala b/modules/core/src/main/scala/jsonrpclib/StubTemplate.scala index 36f0a17..17491e3 100644 --- a/modules/core/src/main/scala/jsonrpclib/StubTemplate.scala +++ b/modules/core/src/main/scala/jsonrpclib/StubTemplate.scala @@ -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] = diff --git a/modules/core/src/main/scala/jsonrpclib/internals/FutureBaseChannel.scala b/modules/core/src/main/scala/jsonrpclib/internals/FutureBaseChannel.scala index cb73e08..0dd6c15 100644 --- a/modules/core/src/main/scala/jsonrpclib/internals/FutureBaseChannel.scala +++ b/modules/core/src/main/scala/jsonrpclib/internals/FutureBaseChannel.scala @@ -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] { @@ -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())) diff --git a/modules/core/src/main/scala/jsonrpclib/internals/MessageDispatcher.scala b/modules/core/src/main/scala/jsonrpclib/internals/MessageDispatcher.scala index 6042597..f64a12d 100644 --- a/modules/core/src/main/scala/jsonrpclib/internals/MessageDispatcher.scala +++ b/modules/core/src/main/scala/jsonrpclib/internals/MessageDispatcher.scala @@ -6,6 +6,8 @@ import jsonrpclib.Endpoint.RequestResponseEndpoint import jsonrpclib.OutputMessage.ErrorMessage import jsonrpclib.OutputMessage.ResponseMessage import scala.util.Try +import io.circe.Codec +import io.circe.HCursor private[jsonrpclib] abstract class MessageDispatcher[F[_]](implicit F: Monadic[F]) extends Channel.MonadicChannel[F] { @@ -21,8 +23,8 @@ private[jsonrpclib] abstract class MessageDispatcher[F[_]](implicit F: Monadic[F protected def removePendingCall(callId: CallId): F[Option[OutputMessage => F[Unit]]] def notificationStub[In](method: String)(implicit inCodec: Codec[In]): In => F[Unit] = { (input: In) => - val encoded = inCodec.encode(input) - val message = InputMessage.NotificationMessage(method, Some(encoded)) + val encoded = inCodec(input) + val message = InputMessage.NotificationMessage(method, Some(Payload(encoded))) sendMessage(message) } @@ -30,9 +32,9 @@ private[jsonrpclib] abstract class MessageDispatcher[F[_]](implicit F: Monadic[F method: String )(implicit inCodec: Codec[In], errCodec: ErrorCodec[Err], outCodec: Codec[Out]): In => F[Either[Err, Out]] = { (input: In) => - val encoded = inCodec.encode(input) + val encoded = inCodec(input) doFlatMap(nextCallId()) { callId => - val message = InputMessage.RequestMessage(method, callId, Some(encoded)) + val message = InputMessage.RequestMessage(method, callId, Some(Payload(encoded))) doFlatMap(createPromise[Either[Err, Out]](callId)) { case (fulfill, future) => val pc = createPendingCall(errCodec, outCodec, fulfill) doFlatMap(storePendingCall(callId, pc))(_ => doFlatMap(sendMessage(message))(_ => future())) @@ -70,25 +72,33 @@ private[jsonrpclib] abstract class MessageDispatcher[F[_]](implicit F: Monadic[F private def executeInputMessage(input: InputMessage, endpoint: Endpoint[F]): F[Unit] = { (input, endpoint) match { - case (InputMessage.NotificationMessage(_, params), ep: NotificationEndpoint[F, in]) => - ep.inCodec.decode(params) match { + case (InputMessage.NotificationMessage(_, Some(params)), ep: NotificationEndpoint[F, in]) => + ep.inCodec(HCursor.fromJson(params.data)) match { case Right(value) => ep.run(input, value) - case Left(value) => reportError(params, value, ep.method) + case Left(value) => reportError(Some(params), ProtocolError.ParseError(value.getMessage), ep.method) } - case (InputMessage.RequestMessage(_, callId, params), ep: RequestResponseEndpoint[F, in, err, out]) => - ep.inCodec.decode(params) match { + case (InputMessage.RequestMessage(_, callId, Some(params)), ep: RequestResponseEndpoint[F, in, err, out]) => + ep.inCodec(HCursor.fromJson(params.data)) match { case Right(value) => doFlatMap(ep.run(input, value)) { case Right(data) => - val responseData = ep.outCodec.encode(data) - sendMessage(OutputMessage.ResponseMessage(callId, responseData)) + val responseData = ep.outCodec(data) + sendMessage(OutputMessage.ResponseMessage(callId, Payload(responseData))) case Left(error) => val errorPayload = ep.errCodec.encode(error) sendMessage(OutputMessage.ErrorMessage(callId, errorPayload)) } case Left(pError) => - sendProtocolError(callId, pError) + sendProtocolError(callId, ProtocolError.ParseError(pError.getMessage)) } + case (InputMessage.NotificationMessage(_, None), _: NotificationEndpoint[F, in]) => + val message = "Missing payload" + val pError = ProtocolError.InvalidRequest(message) + sendProtocolError(pError) + case (InputMessage.RequestMessage(_, _, None), _: RequestResponseEndpoint[F, in, err, out]) => + val message = "Missing payload" + val pError = ProtocolError.InvalidRequest(message) + sendProtocolError(pError) case (InputMessage.NotificationMessage(_, _), ep: RequestResponseEndpoint[F, in, err, out]) => val message = s"This ${ep.method} endpoint cannot process notifications, request is missing callId" val pError = ProtocolError.InvalidRequest(message) @@ -111,8 +121,8 @@ private[jsonrpclib] abstract class MessageDispatcher[F[_]](implicit F: Monadic[F case Left(_) => fulfill(scala.util.Failure(errorPayload)) case Right(value) => fulfill(scala.util.Success(Left(value))) } - case ResponseMessage(_, data) => - outCodec.decode(Some(data)) match { + case ResponseMessage(_, payload) => + outCodec(HCursor.fromJson(payload.data)) match { case Left(decodeError) => fulfill(scala.util.Failure(decodeError)) case Right(value) => fulfill(scala.util.Success(Right(value))) } diff --git a/modules/core/src/main/scala/jsonrpclib/internals/RawMessage.scala b/modules/core/src/main/scala/jsonrpclib/internals/RawMessage.scala index 7738dd3..62eaf0d 100644 --- a/modules/core/src/main/scala/jsonrpclib/internals/RawMessage.scala +++ b/modules/core/src/main/scala/jsonrpclib/internals/RawMessage.scala @@ -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, @@ -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) => @@ -53,7 +53,38 @@ 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] = { msg => + Json + .obj( + List( + "jsonrpc" -> msg.jsonrpc.asJson, + "method" -> msg.method.asJson, + "params" -> msg.params.asJson, + "error" -> msg.error.asJson, + "id" -> msg.id.asJson + ) ++ { + msg.result match { + case Some(Some(payload)) => List("result" -> payload.asJson) + case Some(None) => List("result" -> Json.Null) + case None => Nil + } + }: _* + ) + } + // 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) + } } diff --git a/modules/core/src/test/scala/jsonrpclib/CallIdSpec.scala b/modules/core/src/test/scala/jsonrpclib/CallIdSpec.scala index b227173..e56ec41 100644 --- a/modules/core/src/test/scala/jsonrpclib/CallIdSpec.scala +++ b/modules/core/src/test/scala/jsonrpclib/CallIdSpec.scala @@ -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") { @@ -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) } } diff --git a/modules/core/src/test/scala/jsonrpclib/RawMessageSpec.scala b/modules/core/src/test/scala/jsonrpclib/RawMessageSpec.scala index be5e41a..11b2e5d 100644 --- a/modules/core/src/test/scala/jsonrpclib/RawMessageSpec.scala +++ b/modules/core/src/test/scala/jsonrpclib/RawMessageSpec.scala @@ -5,15 +5,20 @@ import jsonrpclib.internals._ import com.github.plokhotnyuk.jsoniter_scala.core._ import jsonrpclib.CallId.NumberId import jsonrpclib.OutputMessage.ResponseMessage +import io.circe.Json +import com.github.plokhotnyuk.jsoniter_scala.circe.JsoniterScalaCodec._ object RawMessageSpec extends FunSuite { test("json parsing with null result") { // This is a perfectly valid response object, as result field has to be present, // but can be null: https://www.jsonrpc.org/specification#response_object - val rawMessage = readFromString[RawMessage](""" {"jsonrpc":"2.0","result":null,"id":3} """.trim) + val rawMessage = readFromString[Json](""" {"jsonrpc":"2.0","result":null,"id":3} """.trim) + .as[RawMessage] + .fold(throw _, identity) // This, on the other hand, is an invalid response message, as result field is missing - val invalidRawMessage = readFromString[RawMessage](""" {"jsonrpc":"2.0","id":3} """.trim) + val invalidRawMessage = + readFromString[Json](""" {"jsonrpc":"2.0","id":3} """.trim).as[RawMessage].fold(throw _, identity) assert.same( rawMessage, diff --git a/modules/examples/client/src/main/scala/examples/client/ClientMain.scala b/modules/examples/client/src/main/scala/examples/client/ClientMain.scala index 1173094..21b432d 100644 --- a/modules/examples/client/src/main/scala/examples/client/ClientMain.scala +++ b/modules/examples/client/src/main/scala/examples/client/ClientMain.scala @@ -2,8 +2,8 @@ package examples.client import cats.effect._ import cats.syntax.all._ -import com.github.plokhotnyuk.jsoniter_scala.core.JsonValueCodec -import com.github.plokhotnyuk.jsoniter_scala.macros.JsonCodecMaker +import io.circe.Codec +import io.circe.generic.semiauto._ import fs2.Stream import fs2.io._ import fs2.io.process.Processes @@ -18,7 +18,7 @@ object ClientMain extends IOApp.Simple { // Creating a datatype that'll serve as a request (and response) of an endpoint case class IntWrapper(value: Int) object IntWrapper { - implicit val jcodec: JsonValueCodec[IntWrapper] = JsonCodecMaker.make + implicit val codec: Codec[IntWrapper] = deriveCodec } type IOStream[A] = fs2.Stream[IO, A] diff --git a/modules/examples/server/src/main/scala/examples/server/ServerMain.scala b/modules/examples/server/src/main/scala/examples/server/ServerMain.scala index 445274e..8372ea0 100644 --- a/modules/examples/server/src/main/scala/examples/server/ServerMain.scala +++ b/modules/examples/server/src/main/scala/examples/server/ServerMain.scala @@ -4,8 +4,8 @@ import jsonrpclib.CallId import jsonrpclib.fs2._ import cats.effect._ import fs2.io._ -import com.github.plokhotnyuk.jsoniter_scala.core.JsonValueCodec -import com.github.plokhotnyuk.jsoniter_scala.macros.JsonCodecMaker +import io.circe.{Decoder, Encoder, Codec} +import io.circe.generic.semiauto._ import jsonrpclib.Endpoint object ServerMain extends IOApp.Simple { @@ -16,7 +16,7 @@ object ServerMain extends IOApp.Simple { // Creating a datatype that'll serve as a request (and response) of an endpoint case class IntWrapper(value: Int) object IntWrapper { - implicit val jcodec: JsonValueCodec[IntWrapper] = JsonCodecMaker.make + implicit val codec: Codec[IntWrapper] = deriveCodec } // Implementing an incrementation endpoint diff --git a/modules/fs2/src/main/scala/jsonrpclib/fs2/CancelTemplate.scala b/modules/fs2/src/main/scala/jsonrpclib/fs2/CancelTemplate.scala index ed0c426..cf904e1 100644 --- a/modules/fs2/src/main/scala/jsonrpclib/fs2/CancelTemplate.scala +++ b/modules/fs2/src/main/scala/jsonrpclib/fs2/CancelTemplate.scala @@ -1,6 +1,6 @@ package jsonrpclib.fs2 -import jsonrpclib.Codec +import io.circe.Codec import jsonrpclib.CallId /** A cancelation template that represents the RPC method by which cancelation diff --git a/modules/fs2/src/main/scala/jsonrpclib/fs2/FS2Channel.scala b/modules/fs2/src/main/scala/jsonrpclib/fs2/FS2Channel.scala index c2cfa78..a00f090 100644 --- a/modules/fs2/src/main/scala/jsonrpclib/fs2/FS2Channel.scala +++ b/modules/fs2/src/main/scala/jsonrpclib/fs2/FS2Channel.scala @@ -13,6 +13,7 @@ import cats.effect.std.Supervisor import cats.syntax.all._ import cats.effect.syntax.all._ import jsonrpclib.internals.MessageDispatcher +import io.circe.Codec import scala.util.Try import java.util.regex.Pattern diff --git a/modules/fs2/src/main/scala/jsonrpclib/fs2/lsp.scala b/modules/fs2/src/main/scala/jsonrpclib/fs2/lsp.scala index 5ef82c4..1084f17 100644 --- a/modules/fs2/src/main/scala/jsonrpclib/fs2/lsp.scala +++ b/modules/fs2/src/main/scala/jsonrpclib/fs2/lsp.scala @@ -5,27 +5,28 @@ import fs2.Chunk import fs2.Stream import fs2.Pipe import jsonrpclib.Payload -import jsonrpclib.Codec - +import io.circe.{Encoder, Decoder, HCursor} import java.nio.charset.Charset import java.nio.charset.StandardCharsets import jsonrpclib.Message import jsonrpclib.ProtocolError -import jsonrpclib.Payload.Data -import jsonrpclib.Payload.NullPayload import scala.annotation.tailrec +import com.github.plokhotnyuk.jsoniter_scala.core._ +import com.github.plokhotnyuk.jsoniter_scala.circe.JsoniterScalaCodec._ +import io.circe.Json + object lsp { def encodeMessages[F[_]]: Pipe[F, Message, Byte] = - (_: Stream[F, Message]).map(Codec.encode(_)).through(encodePayloads) + (_: Stream[F, Message]).map(Encoder[Message].apply(_)).map(Payload(_)).through(encodePayloads) def encodePayloads[F[_]]: Pipe[F, Payload, Byte] = (_: Stream[F, Payload]).map(writeChunk).flatMap(Stream.chunk(_)) def decodeMessages[F[_]: MonadThrow]: Pipe[F, Byte, Either[ProtocolError, Message]] = (_: Stream[F, Byte]).through(decodePayloads).map { payload => - Codec.decode[Message](Some(payload)) + Decoder[Message].apply(HCursor.fromJson(payload.data)).left.map(e => ProtocolError.ParseError(e.getMessage)) } /** Split a stream of bytes into payloads by extracting each frame based on information contained in the headers. @@ -39,20 +40,16 @@ object lsp { (ns, Chunk(maybeResult)) } .flatMap { - case Right(acc) => Stream.iterable(acc).map(c => Payload(c.toArray)) + case Right(acc) => Stream.iterable(acc).map(c => Payload(readFromArray[Json](c.toArray))) case Left(error) => Stream.raiseError[F](error) } private def writeChunk(payload: Payload): Chunk[Byte] = { - val bytes = payload match { - case Data(array) => array - case NullPayload => nullArray - } + val bytes = writeToArray(payload.data) val header = s"Content-Length: ${bytes.size}" + "\r\n" * 2 Chunk.array(header.getBytes()) ++ Chunk.array(bytes) } - private val nullArray = "null".getBytes() private val returnByte = '\r'.toByte private val newlineByte = '\n'.toByte diff --git a/modules/fs2/src/test/scala/jsonrpclib/fs2/FS2ChannelSpec.scala b/modules/fs2/src/test/scala/jsonrpclib/fs2/FS2ChannelSpec.scala index e6c94dc..2bc3695 100644 --- a/modules/fs2/src/test/scala/jsonrpclib/fs2/FS2ChannelSpec.scala +++ b/modules/fs2/src/test/scala/jsonrpclib/fs2/FS2ChannelSpec.scala @@ -2,11 +2,11 @@ package jsonrpclib.fs2 import cats.effect.IO import cats.syntax.all._ -import com.github.plokhotnyuk.jsoniter_scala.core.JsonValueCodec -import com.github.plokhotnyuk.jsoniter_scala.macros.JsonCodecMaker import fs2.Stream import jsonrpclib._ import weaver._ +import io.circe.Codec +import io.circe.generic.semiauto._ import scala.concurrent.duration._ @@ -14,12 +14,12 @@ object FS2ChannelSpec extends SimpleIOSuite { case class IntWrapper(int: Int) object IntWrapper { - implicit val jcodec: JsonValueCodec[IntWrapper] = JsonCodecMaker.make + implicit val codec: Codec[IntWrapper] = deriveCodec } case class CancelRequest(callId: CallId) object CancelRequest { - implicit val jcodec: JsonValueCodec[CancelRequest] = JsonCodecMaker.make + implicit val codec: Codec[CancelRequest] = deriveCodec } def testRes(name: TestName)(run: Stream[IO, Expectations]): Unit = diff --git a/modules/smithy4s/src/main/scala/jsonrpclib/smithy4sinterop/CirceJson.scala b/modules/smithy4s/src/main/scala/jsonrpclib/smithy4sinterop/CirceJson.scala new file mode 100644 index 0000000..38dd09a --- /dev/null +++ b/modules/smithy4s/src/main/scala/jsonrpclib/smithy4sinterop/CirceJson.scala @@ -0,0 +1,51 @@ +package jsonrpclib.smithy4sinterop + +import smithy4s.Document +import smithy4s.Schema +import smithy4s.codecs.PayloadPath + +import smithy4s.Document.{Decoder => _, _} +import io.circe._ + +private[jsonrpclib] object CirceJson { + + def fromSchema[A](implicit schema: Schema[A]): Codec[A] = Codec.from( + c => { + c.as[Json] + .map(fromJson) + .flatMap { d => + Document + .decode[A](d) + .left + .map(e => + DecodingFailure(DecodingFailure.Reason.CustomReason(e.getMessage), c.history ++ toCursorOps(e.path)) + ) + } + }, + a => documentToJson(Document.encode(a)) + ) + + private def toCursorOps(path: PayloadPath): List[CursorOp] = + path.segments.map { + case PayloadPath.Segment.Label(name) => CursorOp.DownField(name) + case PayloadPath.Segment.Index(i) => CursorOp.DownN(i) + } + + private val documentToJson: Document => Json = { + case DNull => Json.Null + case DString(value) => Json.fromString(value) + case DBoolean(value) => Json.fromBoolean(value) + case DNumber(value) => Json.fromBigDecimal(value) + case DArray(values) => Json.fromValues(values.map(documentToJson)) + case DObject(entries) => Json.fromFields(entries.view.mapValues(documentToJson)) + } + + private def fromJson(json: Json): Document = json.fold( + jsonNull = DNull, + jsonBoolean = DBoolean(_), + jsonNumber = n => DNumber(n.toBigDecimal.get), + jsonString = DString(_), + jsonArray = arr => DArray(arr.map(fromJson)), + jsonObject = obj => DObject(obj.toMap.view.mapValues(fromJson).toMap) + ) +} diff --git a/modules/smithy4s/src/main/scala/jsonrpclib/smithy4sinterop/ClientStub.scala b/modules/smithy4s/src/main/scala/jsonrpclib/smithy4sinterop/ClientStub.scala index ee8c71e..f947323 100644 --- a/modules/smithy4s/src/main/scala/jsonrpclib/smithy4sinterop/ClientStub.scala +++ b/modules/smithy4s/src/main/scala/jsonrpclib/smithy4sinterop/ClientStub.scala @@ -4,9 +4,7 @@ import smithy4s.~> import smithy4s.Service import smithy4s.schema._ import smithy4s.ShapeId -import smithy4s.json.Json -import jsonrpclib.Codec._ -import com.github.plokhotnyuk.jsoniter_scala.core._ +import io.circe.Codec import jsonrpclib.Channel import jsonrpclib.Monadic @@ -31,18 +29,13 @@ private class ClientStub[Alg[_[_, _, _, _, _]], F[_]: Monadic](val service: Serv service.impl(interpreter) } - private val jsoniterCodecGlobalCache = Json.jsoniter.createCache() - - private def deriveJsonCodec[A](schema: Schema[A]): JsonCodec[A] = - Json.jsoniter.fromSchema(schema, jsoniterCodecGlobalCache) - def jsonRPCStub[I, E, O, SI, SO]( smithy4sEndpoint: service.Endpoint[I, E, O, SI, SO], endpointSpec: EndpointSpec ): I => F[O] = { - implicit val inputCodec: JsonCodec[I] = deriveJsonCodec(smithy4sEndpoint.input) - implicit val outputCodec: JsonCodec[O] = deriveJsonCodec(smithy4sEndpoint.output) + implicit val inputCodec: Codec[I] = CirceJson.fromSchema(smithy4sEndpoint.input) + implicit val outputCodec: Codec[O] = CirceJson.fromSchema(smithy4sEndpoint.output) endpointSpec match { case EndpointSpec.Notification(methodName) => diff --git a/modules/smithy4s/src/main/scala/jsonrpclib/smithy4sinterop/ServerEndpoints.scala b/modules/smithy4s/src/main/scala/jsonrpclib/smithy4sinterop/ServerEndpoints.scala index a6593f7..9e8971d 100644 --- a/modules/smithy4s/src/main/scala/jsonrpclib/smithy4sinterop/ServerEndpoints.scala +++ b/modules/smithy4s/src/main/scala/jsonrpclib/smithy4sinterop/ServerEndpoints.scala @@ -5,11 +5,8 @@ import jsonrpclib.Endpoint import smithy4s.Service import smithy4s.kinds.FunctorAlgebra import smithy4s.kinds.FunctorInterpreter -import smithy4s.json.Json -import smithy4s.schema.Schema -import jsonrpclib.Codec._ -import com.github.plokhotnyuk.jsoniter_scala.core._ import jsonrpclib.Monadic +import io.circe.Codec object ServerEndpoints { @@ -27,11 +24,6 @@ object ServerEndpoints { } } - private val jsoniterCodecGlobalCache = Json.jsoniter.createCache() - - private def deriveJsonCodec[A](schema: Schema[A]): JsonCodec[A] = - Json.jsoniter.fromSchema(schema, jsoniterCodecGlobalCache) - // TODO : codify errors at smithy level and handle them. def jsonRPCEndpoint[F[_]: Monadic, Op[_, _, _, _, _], I, E, O, SI, SO]( smithy4sEndpoint: Smithy4sEndpoint[Op, I, E, O, SI, SO], @@ -39,8 +31,8 @@ object ServerEndpoints { impl: FunctorInterpreter[Op, F] ): Endpoint[F] = { - implicit val inputCodec: JsonCodec[I] = deriveJsonCodec(smithy4sEndpoint.input) - implicit val outputCodec: JsonCodec[O] = deriveJsonCodec(smithy4sEndpoint.output) + implicit val inputCodec: Codec[I] = CirceJson.fromSchema(smithy4sEndpoint.input) + implicit val outputCodec: Codec[O] = CirceJson.fromSchema(smithy4sEndpoint.output) endpointSpec match { case EndpointSpec.Notification(methodName) => From e46c197a86cfad614a23c5c1eb867c7cf815c91c Mon Sep 17 00:00:00 2001 From: ghostbuster91 Date: Tue, 13 May 2025 20:21:21 +0200 Subject: [PATCH 23/47] Replace ChildProcess with fs2.Process --- .../src/main/scala/jsonrpclib/CallId.scala | 2 +- .../src/main/scala/jsonrpclib/Message.scala | 1 - .../examples/smithy/client/ChildProcess.scala | 61 ------------------- .../examples/smithy/client/ClientMain.scala | 8 ++- 4 files changed, 8 insertions(+), 64 deletions(-) delete mode 100644 modules/examples/smithyClient/src/main/scala/examples/smithy/client/ChildProcess.scala diff --git a/modules/core/src/main/scala/jsonrpclib/CallId.scala b/modules/core/src/main/scala/jsonrpclib/CallId.scala index dcf7dd6..146315f 100644 --- a/modules/core/src/main/scala/jsonrpclib/CallId.scala +++ b/modules/core/src/main/scala/jsonrpclib/CallId.scala @@ -1,6 +1,6 @@ package jsonrpclib -import io.circe.{Decoder, Encoder, Json, Codec} +import io.circe.{Decoder, Json, Codec} sealed trait CallId object CallId { diff --git a/modules/core/src/main/scala/jsonrpclib/Message.scala b/modules/core/src/main/scala/jsonrpclib/Message.scala index ee7d643..e4364b4 100644 --- a/modules/core/src/main/scala/jsonrpclib/Message.scala +++ b/modules/core/src/main/scala/jsonrpclib/Message.scala @@ -1,6 +1,5 @@ package jsonrpclib -import io.circe.{Decoder, Encoder} import io.circe.syntax._ import io.circe.Codec diff --git a/modules/examples/smithyClient/src/main/scala/examples/smithy/client/ChildProcess.scala b/modules/examples/smithyClient/src/main/scala/examples/smithy/client/ChildProcess.scala deleted file mode 100644 index 154e66d..0000000 --- a/modules/examples/smithyClient/src/main/scala/examples/smithy/client/ChildProcess.scala +++ /dev/null @@ -1,61 +0,0 @@ -package examples.smithy.client - -import fs2.Stream -import cats.effect._ -import cats.syntax.all._ -import scala.jdk.CollectionConverters._ -import java.io.OutputStream - -trait ChildProcess[F[_]] { - def stdin: fs2.Pipe[F, Byte, Unit] - def stdout: Stream[F, Byte] - def stderr: Stream[F, Byte] -} - -object ChildProcess { - - def spawn[F[_]: Async](command: String*): Stream[F, ChildProcess[F]] = - Stream.resource(startRes(command)) - - val readBufferSize = 512 - - private def startRes[F[_]: Async](command: Seq[String]) = Resource - .make { - Async[F].interruptible(new java.lang.ProcessBuilder(command.asJava).start()) - } { p => - Sync[F].interruptible(p.destroy()) - } - .map { p => - val done = Async[F].fromCompletableFuture(Sync[F].delay(p.onExit())) - new ChildProcess[F] { - def stdin: fs2.Pipe[F, Byte, Unit] = - writeOutputStreamFlushingChunks[F](Sync[F].interruptible(p.getOutputStream())) - - def stdout: fs2.Stream[F, Byte] = fs2.io - .readInputStream[F](Sync[F].interruptible(p.getInputStream()), chunkSize = readBufferSize) - - def stderr: fs2.Stream[F, Byte] = fs2.io - .readInputStream[F](Sync[F].blocking(p.getErrorStream()), chunkSize = readBufferSize) - // Avoids broken pipe - we cut off when the program ends. - // Users can decide what to do with the error logs using the exitCode value - .interruptWhen(done.void.attempt) - } - } - - /** Adds a flush after each chunk - */ - def writeOutputStreamFlushingChunks[F[_]]( - fos: F[OutputStream], - closeAfterUse: Boolean = true - )(implicit F: Sync[F]): fs2.Pipe[F, Byte, Nothing] = - s => { - def useOs(os: OutputStream): Stream[F, Nothing] = - s.chunks.foreach(c => F.interruptible(os.write(c.toArray)) >> F.blocking(os.flush())) - - val os = - if (closeAfterUse) Stream.bracket(fos)(os => F.blocking(os.close())) - else Stream.eval(fos) - os.flatMap(os => useOs(os) ++ Stream.exec(F.blocking(os.flush()))) - } - -} diff --git a/modules/examples/smithyClient/src/main/scala/examples/smithy/client/ClientMain.scala b/modules/examples/smithyClient/src/main/scala/examples/smithy/client/ClientMain.scala index 06871b2..5098fc1 100644 --- a/modules/examples/smithyClient/src/main/scala/examples/smithy/client/ClientMain.scala +++ b/modules/examples/smithyClient/src/main/scala/examples/smithy/client/ClientMain.scala @@ -7,6 +7,7 @@ import jsonrpclib.CallId import jsonrpclib.fs2._ import jsonrpclib.smithy4sinterop.ClientStub import jsonrpclib.smithy4sinterop.ServerEndpoints +import fs2.io.process.Processes import test._ object SmithyClientMain extends IOApp.Simple { @@ -30,7 +31,12 @@ object SmithyClientMain extends IOApp.Simple { _ <- log("Starting client") serverJar <- sys.env.get("SERVER_JAR").liftTo[IOStream](new Exception("SERVER_JAR env var does not exist")) // Starting the server - rp <- ChildProcess.spawn[IO]("java", "-jar", serverJar) + rp <- Stream.resource( + Processes[IO] + .spawn( + fs2.io.process.ProcessBuilder("java", "-jar", serverJar) + ) + ) // Creating a channel that will be used to communicate to the server fs2Channel <- FS2Channel.stream[IO](cancelTemplate = cancelEndpoint.some) // Mounting our implementation of the generated interface onto the channel From 6c4b437db0aec88c567a84b84c7fcccd512515ee Mon Sep 17 00:00:00 2001 From: ghostbuster91 Date: Tue, 13 May 2025 20:44:04 +0200 Subject: [PATCH 24/47] Add common smithy traits into protocol definition --- build.sbt | 5 +++- .../META-INF/smithy/jsonrpclib.smithy | 10 +++++++ project/Dependencies.scala | 8 ++++++ project/SmithyTraitCodegen.scala | 24 ++++++++++++----- project/SmithyTraitCodegenPlugin.scala | 26 +++++++++++++++++-- 5 files changed, 64 insertions(+), 9 deletions(-) create mode 100644 project/Dependencies.scala diff --git a/build.sbt b/build.sbt index a67087c..e915b7e 100644 --- a/build.sbt +++ b/build.sbt @@ -96,7 +96,10 @@ val smithy = projectMatrix .enablePlugins(SmithyTraitCodegenPlugin) .settings( name := "jsonrpclib-smithy", - commonJvmSettings + commonJvmSettings, + smithyTraitCodegenDependencies := List(Dependencies.alloy.core), + smithyTraitCodegenJavaPackage := "jsonrpclib", + smithyTraitCodegenNamespace := "jsonrpclib" ) lazy val buildTimeProtocolDependency = diff --git a/modules/smithy/src/main/resources/META-INF/smithy/jsonrpclib.smithy b/modules/smithy/src/main/resources/META-INF/smithy/jsonrpclib.smithy index 5d4e6f3..93cbac2 100644 --- a/modules/smithy/src/main/resources/META-INF/smithy/jsonrpclib.smithy +++ b/modules/smithy/src/main/resources/META-INF/smithy/jsonrpclib.smithy @@ -7,6 +7,16 @@ namespace jsonrpclib @protocolDefinition(traits: [ jsonRequest jsonNotification + smithy.api#jsonName + smithy.api#length + smithy.api#pattern + smithy.api#range + smithy.api#required + smithy.api#timestampFormat + alloy#uuidFormat + alloy#discriminated + alloy#nullable + alloy#untagged ]) @trait(selector: "service") structure jsonRPC { diff --git a/project/Dependencies.scala b/project/Dependencies.scala new file mode 100644 index 0000000..47311b2 --- /dev/null +++ b/project/Dependencies.scala @@ -0,0 +1,8 @@ +import sbt.* + +object Dependencies { + val alloy = new { + val version = "0.3.20" + val core = "com.disneystreaming.alloy" % "alloy-core" % version + } +} diff --git a/project/SmithyTraitCodegen.scala b/project/SmithyTraitCodegen.scala index 3341cba..551d51d 100644 --- a/project/SmithyTraitCodegen.scala +++ b/project/SmithyTraitCodegen.scala @@ -18,11 +18,17 @@ object SmithyTraitCodegen { import BasicJsonProtocol.* - case class Args(targetDir: os.Path, smithySourcesDir: PathRef, dependencies: List[PathRef]) + case class Args( + javaPackage: String, + smithyNamespace: String, + targetDir: os.Path, + smithySourcesDir: PathRef, + dependencies: List[PathRef] + ) object Args { // format: off - private type ArgsDeconstructed = os.Path :*: PathRef :*: List[PathRef] :*: LNil + private type ArgsDeconstructed = String :*: String :*: os.Path :*: PathRef :*: List[PathRef] :*: LNil // format: on private implicit val pathFormat: JsonFormat[os.Path] = @@ -31,17 +37,23 @@ object SmithyTraitCodegen { implicit val argsIso = LList.iso[Args, ArgsDeconstructed]( { args: Args => - ("targetDir", args.targetDir) :*: + ("javaPackage", args.javaPackage) :*: + ("smithyNamespace", args.smithyNamespace) :*: + ("targetDir", args.targetDir) :*: ("smithySourcesDir", args.smithySourcesDir) :*: ("dependencies", args.dependencies) :*: LNil }, { - case (_, targetDir) :*: + case (_, javaPackage) :*: + (_, smithyNamespace) :*: + (_, targetDir) :*: (_, smithySourcesDir) :*: (_, dependencies) :*: LNil => Args( + javaPackage = javaPackage, + smithyNamespace = smithyNamespace, targetDir = targetDir, smithySourcesDir = smithySourcesDir, dependencies = dependencies @@ -100,8 +112,8 @@ object SmithyTraitCodegen { .settings( ObjectNode .builder() - .withMember("package", "jsonrpclib") - .withMember("namespace", "jsonrpclib") + .withMember("package", args.javaPackage) + .withMember("namespace", args.smithyNamespace) .withMember("header", ArrayNode.builder.build()) .withMember("excludeTags", ArrayNode.builder.withValue("nocodegen").build()) .build() diff --git a/project/SmithyTraitCodegenPlugin.scala b/project/SmithyTraitCodegenPlugin.scala index 7a47261..78903f4 100644 --- a/project/SmithyTraitCodegenPlugin.scala +++ b/project/SmithyTraitCodegenPlugin.scala @@ -13,16 +13,38 @@ object SmithyTraitCodegenPlugin extends AutoPlugin { override def trigger: PluginTrigger = noTrigger override def requires: Plugins = JvmPlugin + object autoImport { + val smithyTraitCodegenJavaPackage = + settingKey[String]("The java target package where the generated smithy traits will be created") + val smithyTraitCodegenNamespace = settingKey[String]("The smithy namespace where the traits are defined") + val smithyTraitCodegenDependencies = settingKey[List[ModuleID]]("Dependencies to be added into codegen model") + } + import autoImport.* + override def projectSettings: Seq[Setting[?]] = Seq( Keys.generateSmithyTraits := Def.task { import sbt.util.CacheImplicits.* val s = (Compile / streams).value val logger = sLog.value + + val report = update.value + val dependencies = smithyTraitCodegenDependencies.value + val jars = + dependencies.flatMap(m => + report.matching(moduleFilter(organization = m.organization, name = m.name, revision = m.revision)) + ) + require( + jars.size == dependencies.size, + "Not all dependencies required for smithy-trait-codegen have been found" + ) + val args = SmithyTraitCodegen.Args( + javaPackage = smithyTraitCodegenJavaPackage.value, + smithyNamespace = smithyTraitCodegenNamespace.value, targetDir = os.Path((Compile / target).value), smithySourcesDir = PathRef((Compile / resourceDirectory).value / "META-INF" / "smithy"), - dependencies = List.empty + dependencies = jars.map(PathRef(_)).toList ) val cachedCodegen = Tracked.inputChanged[SmithyTraitCodegen.Args, SmithyTraitCodegen.Output]( @@ -54,7 +76,7 @@ object SmithyTraitCodegenPlugin extends AutoPlugin { val codegenOutput = (Compile / Keys.generateSmithyTraits).value cleanCopy(source = codegenOutput.metaDir, target = (Compile / resourceManaged).value) }.taskValue, - libraryDependencies += "software.amazon.smithy" % "smithy-model" % "1.56.0" + libraryDependencies ++= smithyTraitCodegenDependencies.value ) private def cleanCopy(source: File, target: File) = { From 830a6b001646e580547b14f9557966f96838bea9 Mon Sep 17 00:00:00 2001 From: ghostbuster91 Date: Wed, 14 May 2025 22:06:41 +0200 Subject: [PATCH 25/47] Better way to coerce unit --- .../src/main/scala/jsonrpclib/smithy4sinterop/ClientStub.scala | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/smithy4s/src/main/scala/jsonrpclib/smithy4sinterop/ClientStub.scala b/modules/smithy4s/src/main/scala/jsonrpclib/smithy4sinterop/ClientStub.scala index f947323..76adf2c 100644 --- a/modules/smithy4s/src/main/scala/jsonrpclib/smithy4sinterop/ClientStub.scala +++ b/modules/smithy4s/src/main/scala/jsonrpclib/smithy4sinterop/ClientStub.scala @@ -52,7 +52,7 @@ private class ClientStub[Alg[_[_, _, _, _, _]], F[_]: Monadic](val service: Serv private object CoerceUnitVisitor extends (Schema ~> F) { def apply[A](schema: Schema[A]): F[A] = schema match { case s @ Schema.StructSchema(_, _, _, make) if s.isUnit => - Monadic[F].doPure(()).asInstanceOf[F[A]] + Monadic[F].doPure(make(IndexedSeq.empty)) case _ => Monadic[F].doRaiseError[A](NotUnitReturnType) } } From c070f65c2d6380bfda9c63e363fd8141541bee6b Mon Sep 17 00:00:00 2001 From: Kasper Kondzielski Date: Thu, 15 May 2025 19:33:06 +0200 Subject: [PATCH 26/47] Errors smithy4s (#86) --- build.sbt | 22 ++- .../src/main/scala/jsonrpclib/Channel.scala | 12 +- .../src/main/scala/jsonrpclib/Endpoint.scala | 43 +++-- .../main/scala/jsonrpclib/ErrorCodec.scala | 9 +- .../src/main/scala/jsonrpclib/Monadic.scala | 15 ++ .../main/scala/jsonrpclib/PolyFunction.scala | 5 + .../internals/FutureBaseChannel.scala | 1 - .../internals/MessageDispatcher.scala | 27 +-- .../src/main/scala/jsonrpclib/package.scala | 1 - .../{CirceJson.scala => CirceJsonCodec.scala} | 2 +- .../smithy4sinterop/ClientStub.scala | 4 +- .../smithy4sinterop/ServerEndpoints.scala | 45 ++++- .../smithy4sTests/src/main/smithy/spec.smithy | 52 +++++ .../smithy4sinterop/TestClientSpec.scala | 68 +++++++ .../smithy4sinterop/TestServerSpec.scala | 181 ++++++++++++++++++ 15 files changed, 438 insertions(+), 49 deletions(-) create mode 100644 modules/core/src/main/scala/jsonrpclib/PolyFunction.scala rename modules/smithy4s/src/main/scala/jsonrpclib/smithy4sinterop/{CirceJson.scala => CirceJsonCodec.scala} (97%) create mode 100644 modules/smithy4sTests/src/main/smithy/spec.smithy create mode 100644 modules/smithy4sTests/src/test/scala/jsonrpclib/smithy4sinterop/TestClientSpec.scala create mode 100644 modules/smithy4sTests/src/test/scala/jsonrpclib/smithy4sinterop/TestServerSpec.scala diff --git a/build.sbt b/build.sbt index e915b7e..7e8852b 100644 --- a/build.sbt +++ b/build.sbt @@ -16,7 +16,7 @@ inThisBuild( ) val scala213 = "2.13.16" -val scala3 = "3.3.5" +val scala3 = "3.3.6" val jdkVersion = 11 val allScalaVersions = List(scala213, scala3) val jvmScalaVersions = allScalaVersions @@ -40,7 +40,7 @@ val commonSettings = Seq( case Some((2, _)) => Seq(s"-target:jvm-$jdkVersion") case _ => Seq(s"-java-output-version:$jdkVersion") } - } + }, ) val commonJvmSettings = Seq( @@ -136,6 +136,23 @@ val smithy4s = projectMatrix buildTimeProtocolDependency ) +val smithy4sTests = projectMatrix + .in(file("modules") / "smithy4sTests") + .jvmPlatform(jvmScalaVersions, commonJvmSettings) + .jsPlatform(jsScalaVersions) + .nativePlatform(Seq(scala3)) + .disablePlugins(AssemblyPlugin) + .enablePlugins(Smithy4sCodegenPlugin) + .dependsOn(smithy4s, fs2 % Test) + .settings( + commonSettings, + publish / skip := true, + libraryDependencies ++= Seq( + "io.circe" %%% "circe-generic" % "0.14.7" + ), + buildTimeProtocolDependency + ) + val exampleServer = projectMatrix .in(file("modules") / "examples/server") .jvmPlatform(List(scala213), commonJvmSettings) @@ -235,6 +252,7 @@ val root = project exampleClient, smithy, smithy4s, + smithy4sTests, exampleSmithyShared, exampleSmithyServer, exampleSmithyClient diff --git a/modules/core/src/main/scala/jsonrpclib/Channel.scala b/modules/core/src/main/scala/jsonrpclib/Channel.scala index ba533e4..24d9d74 100644 --- a/modules/core/src/main/scala/jsonrpclib/Channel.scala +++ b/modules/core/src/main/scala/jsonrpclib/Channel.scala @@ -1,14 +1,16 @@ package jsonrpclib -import io.circe.Codec +import jsonrpclib.ErrorCodec.errorPayloadCodec +import io.circe.Encoder +import io.circe.Decoder trait Channel[F[_]] { def mountEndpoint(endpoint: Endpoint[F]): F[Unit] def unmountEndpoint(method: String): F[Unit] - def notificationStub[In: Codec](method: String): In => F[Unit] - def simpleStub[In: Codec, Out: Codec](method: String): In => F[Out] - def stub[In: Codec, Err: ErrorCodec, Out: Codec](method: String): In => F[Either[Err, Out]] + def notificationStub[In: Encoder](method: String): In => F[Unit] + def simpleStub[In: Encoder, Out: Decoder](method: String): In => F[Out] + def stub[In: Encoder, Err: ErrorDecoder, Out: Decoder](method: String): In => F[Either[Err, Out]] def stub[In, Err, Out](template: StubTemplate[In, Err, Out]): In => F[Either[Err, Out]] } @@ -27,7 +29,7 @@ object Channel { (in: In) => F.doFlatMap(stub(in))(unit => F.doPure(Right(unit))) } - final def simpleStub[In: Codec, Out: Codec](method: String): In => F[Out] = { + final def simpleStub[In: Encoder, Out: Decoder](method: String): In => F[Out] = { val s = stub[In, ErrorPayload, Out](method) (in: In) => F.doFlatMap(s(in)) { diff --git a/modules/core/src/main/scala/jsonrpclib/Endpoint.scala b/modules/core/src/main/scala/jsonrpclib/Endpoint.scala index 1d7197a..d9d0910 100644 --- a/modules/core/src/main/scala/jsonrpclib/Endpoint.scala +++ b/modules/core/src/main/scala/jsonrpclib/Endpoint.scala @@ -1,9 +1,13 @@ package jsonrpclib -import io.circe.Codec +import jsonrpclib.ErrorCodec.errorPayloadCodec +import io.circe.Decoder +import io.circe.Encoder sealed trait Endpoint[F[_]] { def method: String + + def mapK[G[_]](f: PolyFunction[F, G]): Endpoint[G] } object Endpoint { @@ -16,17 +20,17 @@ object Endpoint { class PartiallyAppliedEndpoint[F[_]](method: MethodPattern) { def apply[In, Err, Out]( run: In => F[Either[Err, Out]] - )(implicit inCodec: Codec[In], errCodec: ErrorCodec[Err], outCodec: Codec[Out]): Endpoint[F] = - RequestResponseEndpoint(method, (_: InputMessage, in: In) => run(in), inCodec, errCodec, outCodec) + )(implicit inCodec: Decoder[In], errEncoder: ErrorEncoder[Err], outCodec: Encoder[Out]): Endpoint[F] = + RequestResponseEndpoint(method, (_: InputMessage, in: In) => run(in), inCodec, errEncoder, outCodec) def full[In, Err, Out]( run: (InputMessage, In) => F[Either[Err, Out]] - )(implicit inCodec: Codec[In], errCodec: ErrorCodec[Err], outCodec: Codec[Out]): Endpoint[F] = - RequestResponseEndpoint(method, run, inCodec, errCodec, outCodec) + )(implicit inCodec: Decoder[In], errEncoder: ErrorEncoder[Err], outCodec: Encoder[Out]): Endpoint[F] = + RequestResponseEndpoint(method, run, inCodec, errEncoder, outCodec) def simple[In, Out]( run: In => F[Out] - )(implicit F: Monadic[F], inCodec: Codec[In], outCodec: Codec[Out]) = + )(implicit F: Monadic[F], inCodec: Decoder[In], outCodec: Encoder[Out]) = apply[In, ErrorPayload, Out](in => F.doFlatMap(F.doAttempt(run(in))) { case Left(error) => F.doPure(Left(ErrorPayload(0, error.getMessage(), None))) @@ -34,26 +38,33 @@ object Endpoint { } ) - def notification[In](run: In => F[Unit])(implicit inCodec: Codec[In]): Endpoint[F] = + def notification[In](run: In => F[Unit])(implicit inCodec: Decoder[In]): Endpoint[F] = NotificationEndpoint(method, (_: InputMessage, in: In) => run(in), inCodec) - def notificationFull[In](run: (InputMessage, In) => F[Unit])(implicit inCodec: Codec[In]): Endpoint[F] = + def notificationFull[In](run: (InputMessage, In) => F[Unit])(implicit inCodec: Decoder[In]): Endpoint[F] = NotificationEndpoint(method, run, inCodec) } - final case class NotificationEndpoint[F[_], In]( + private[jsonrpclib] final case class NotificationEndpoint[F[_], In]( method: MethodPattern, run: (InputMessage, In) => F[Unit], - inCodec: Codec[In] - ) extends Endpoint[F] + inCodec: Decoder[In] + ) extends Endpoint[F] { + + def mapK[G[_]](f: PolyFunction[F, G]): Endpoint[G] = + NotificationEndpoint[G, In](method, (msg, in) => f(run(msg, in)), inCodec) + } - final case class RequestResponseEndpoint[F[_], In, Err, Out]( + private[jsonrpclib] final case class RequestResponseEndpoint[F[_], In, Err, Out]( method: Method, run: (InputMessage, In) => F[Either[Err, Out]], - inCodec: Codec[In], - errCodec: ErrorCodec[Err], - outCodec: Codec[Out] - ) extends Endpoint[F] + inCodec: Decoder[In], + errEncoder: ErrorEncoder[Err], + outCodec: Encoder[Out] + ) extends Endpoint[F] { + def mapK[G[_]](f: PolyFunction[F, G]): Endpoint[G] = + RequestResponseEndpoint[G, In, Err, Out](method, (msg, in) => f(run(msg, in)), inCodec, errEncoder, outCodec) + } } diff --git a/modules/core/src/main/scala/jsonrpclib/ErrorCodec.scala b/modules/core/src/main/scala/jsonrpclib/ErrorCodec.scala index f150c1f..8af58e9 100644 --- a/modules/core/src/main/scala/jsonrpclib/ErrorCodec.scala +++ b/modules/core/src/main/scala/jsonrpclib/ErrorCodec.scala @@ -1,12 +1,15 @@ package jsonrpclib -trait ErrorCodec[E] { - +trait ErrorEncoder[E] { def encode(a: E): ErrorPayload - def decode(error: ErrorPayload): Either[ProtocolError, E] +} +trait ErrorDecoder[E] { + def decode(error: ErrorPayload): Either[ProtocolError, E] } +trait ErrorCodec[E] extends ErrorDecoder[E] with ErrorEncoder[E] + object ErrorCodec { implicit val errorPayloadCodec: ErrorCodec[ErrorPayload] = new ErrorCodec[ErrorPayload] { def encode(a: ErrorPayload): ErrorPayload = a diff --git a/modules/core/src/main/scala/jsonrpclib/Monadic.scala b/modules/core/src/main/scala/jsonrpclib/Monadic.scala index 0d5a7f0..a42aaa7 100644 --- a/modules/core/src/main/scala/jsonrpclib/Monadic.scala +++ b/modules/core/src/main/scala/jsonrpclib/Monadic.scala @@ -26,4 +26,19 @@ object Monadic { override def doMap[A, B](fa: Future[A])(f: A => B): Future[B] = fa.map(f) } + + object syntax { + implicit class MonadicOps[F[_], A](fa: F[A]) { + def flatMap[B](f: A => F[B])(implicit m: Monadic[F]): F[B] = m.doFlatMap(fa)(f) + def map[B](f: A => B)(implicit m: Monadic[F]): F[B] = m.doMap(fa)(f) + def attempt[B](implicit m: Monadic[F]): F[Either[Throwable, A]] = m.doAttempt(fa) + def void(implicit m: Monadic[F]): F[Unit] = m.doVoid(fa) + } + implicit class MonadicOpsPure[A](a: A) { + def pure[F[_]](implicit m: Monadic[F]): F[A] = m.doPure(a) + } + implicit class MonadicOpsThrowable(t: Throwable) { + def raiseError[F[_], A](implicit m: Monadic[F]): F[A] = m.doRaiseError(t) + } + } } diff --git a/modules/core/src/main/scala/jsonrpclib/PolyFunction.scala b/modules/core/src/main/scala/jsonrpclib/PolyFunction.scala new file mode 100644 index 0000000..3942a26 --- /dev/null +++ b/modules/core/src/main/scala/jsonrpclib/PolyFunction.scala @@ -0,0 +1,5 @@ +package jsonrpclib + +trait PolyFunction[F[_], G[_]] { self => + def apply[A0](fa: => F[A0]): G[A0] +} diff --git a/modules/core/src/main/scala/jsonrpclib/internals/FutureBaseChannel.scala b/modules/core/src/main/scala/jsonrpclib/internals/FutureBaseChannel.scala index 0dd6c15..3b50fd5 100644 --- a/modules/core/src/main/scala/jsonrpclib/internals/FutureBaseChannel.scala +++ b/modules/core/src/main/scala/jsonrpclib/internals/FutureBaseChannel.scala @@ -7,7 +7,6 @@ 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) diff --git a/modules/core/src/main/scala/jsonrpclib/internals/MessageDispatcher.scala b/modules/core/src/main/scala/jsonrpclib/internals/MessageDispatcher.scala index f64a12d..5950890 100644 --- a/modules/core/src/main/scala/jsonrpclib/internals/MessageDispatcher.scala +++ b/modules/core/src/main/scala/jsonrpclib/internals/MessageDispatcher.scala @@ -6,8 +6,9 @@ import jsonrpclib.Endpoint.RequestResponseEndpoint import jsonrpclib.OutputMessage.ErrorMessage import jsonrpclib.OutputMessage.ResponseMessage import scala.util.Try -import io.circe.Codec import io.circe.HCursor +import io.circe.Encoder +import io.circe.Decoder private[jsonrpclib] abstract class MessageDispatcher[F[_]](implicit F: Monadic[F]) extends Channel.MonadicChannel[F] { @@ -22,7 +23,7 @@ private[jsonrpclib] abstract class MessageDispatcher[F[_]](implicit F: Monadic[F protected def storePendingCall(callId: CallId, handle: OutputMessage => F[Unit]): F[Unit] protected def removePendingCall(callId: CallId): F[Option[OutputMessage => F[Unit]]] - def notificationStub[In](method: String)(implicit inCodec: Codec[In]): In => F[Unit] = { (input: In) => + def notificationStub[In](method: String)(implicit inCodec: Encoder[In]): In => F[Unit] = { (input: In) => val encoded = inCodec(input) val message = InputMessage.NotificationMessage(method, Some(Payload(encoded))) sendMessage(message) @@ -30,13 +31,13 @@ private[jsonrpclib] abstract class MessageDispatcher[F[_]](implicit F: Monadic[F def stub[In, Err, Out]( method: String - )(implicit inCodec: Codec[In], errCodec: ErrorCodec[Err], outCodec: Codec[Out]): In => F[Either[Err, Out]] = { + )(implicit inCodec: Encoder[In], errDecoder: ErrorDecoder[Err], outCodec: Decoder[Out]): In => F[Either[Err, Out]] = { (input: In) => val encoded = inCodec(input) doFlatMap(nextCallId()) { callId => val message = InputMessage.RequestMessage(method, callId, Some(Payload(encoded))) doFlatMap(createPromise[Either[Err, Out]](callId)) { case (fulfill, future) => - val pc = createPendingCall(errCodec, outCodec, fulfill) + val pc = createPendingCall(errDecoder, outCodec, fulfill) doFlatMap(storePendingCall(callId, pc))(_ => doFlatMap(sendMessage(message))(_ => future())) } } @@ -80,13 +81,17 @@ private[jsonrpclib] abstract class MessageDispatcher[F[_]](implicit F: Monadic[F case (InputMessage.RequestMessage(_, callId, Some(params)), ep: RequestResponseEndpoint[F, in, err, out]) => ep.inCodec(HCursor.fromJson(params.data)) match { case Right(value) => - doFlatMap(ep.run(input, value)) { - case Right(data) => + doFlatMap(doAttempt(ep.run(input, value))) { + case Right(Right(data)) => val responseData = ep.outCodec(data) sendMessage(OutputMessage.ResponseMessage(callId, Payload(responseData))) - case Left(error) => - val errorPayload = ep.errCodec.encode(error) + case Right(Left(error)) => + val errorPayload = ep.errEncoder.encode(error) sendMessage(OutputMessage.ErrorMessage(callId, errorPayload)) + case Left(err) => + sendMessage( + OutputMessage.ErrorMessage(callId, ErrorPayload(0, s"ServerInternalError: ${err.getMessage}", None)) + ) } case Left(pError) => sendProtocolError(callId, ProtocolError.ParseError(pError.getMessage)) @@ -111,13 +116,13 @@ private[jsonrpclib] abstract class MessageDispatcher[F[_]](implicit F: Monadic[F } private def createPendingCall[Err, Out]( - errCodec: ErrorCodec[Err], - outCodec: Codec[Out], + errDecoder: ErrorDecoder[Err], + outCodec: Decoder[Out], fulfill: Try[Either[Err, Out]] => F[Unit] ): OutputMessage => F[Unit] = { (message: OutputMessage) => message match { case ErrorMessage(_, errorPayload) => - errCodec.decode(errorPayload) match { + errDecoder.decode(errorPayload) match { case Left(_) => fulfill(scala.util.Failure(errorPayload)) case Right(value) => fulfill(scala.util.Success(Left(value))) } diff --git a/modules/core/src/main/scala/jsonrpclib/package.scala b/modules/core/src/main/scala/jsonrpclib/package.scala index 9093575..5c0f070 100644 --- a/modules/core/src/main/scala/jsonrpclib/package.scala +++ b/modules/core/src/main/scala/jsonrpclib/package.scala @@ -2,5 +2,4 @@ package object jsonrpclib { type ErrorCode = Int type ErrorMessage = String - } diff --git a/modules/smithy4s/src/main/scala/jsonrpclib/smithy4sinterop/CirceJson.scala b/modules/smithy4s/src/main/scala/jsonrpclib/smithy4sinterop/CirceJsonCodec.scala similarity index 97% rename from modules/smithy4s/src/main/scala/jsonrpclib/smithy4sinterop/CirceJson.scala rename to modules/smithy4s/src/main/scala/jsonrpclib/smithy4sinterop/CirceJsonCodec.scala index 38dd09a..0c242b6 100644 --- a/modules/smithy4s/src/main/scala/jsonrpclib/smithy4sinterop/CirceJson.scala +++ b/modules/smithy4s/src/main/scala/jsonrpclib/smithy4sinterop/CirceJsonCodec.scala @@ -7,7 +7,7 @@ import smithy4s.codecs.PayloadPath import smithy4s.Document.{Decoder => _, _} import io.circe._ -private[jsonrpclib] object CirceJson { +object CirceJsonCodec { def fromSchema[A](implicit schema: Schema[A]): Codec[A] = Codec.from( c => { diff --git a/modules/smithy4s/src/main/scala/jsonrpclib/smithy4sinterop/ClientStub.scala b/modules/smithy4s/src/main/scala/jsonrpclib/smithy4sinterop/ClientStub.scala index 76adf2c..8827e56 100644 --- a/modules/smithy4s/src/main/scala/jsonrpclib/smithy4sinterop/ClientStub.scala +++ b/modules/smithy4s/src/main/scala/jsonrpclib/smithy4sinterop/ClientStub.scala @@ -34,8 +34,8 @@ private class ClientStub[Alg[_[_, _, _, _, _]], F[_]: Monadic](val service: Serv endpointSpec: EndpointSpec ): I => F[O] = { - implicit val inputCodec: Codec[I] = CirceJson.fromSchema(smithy4sEndpoint.input) - implicit val outputCodec: Codec[O] = CirceJson.fromSchema(smithy4sEndpoint.output) + implicit val inputCodec: Codec[I] = CirceJsonCodec.fromSchema(smithy4sEndpoint.input) + implicit val outputCodec: Codec[O] = CirceJsonCodec.fromSchema(smithy4sEndpoint.output) endpointSpec match { case EndpointSpec.Notification(methodName) => diff --git a/modules/smithy4s/src/main/scala/jsonrpclib/smithy4sinterop/ServerEndpoints.scala b/modules/smithy4s/src/main/scala/jsonrpclib/smithy4sinterop/ServerEndpoints.scala index 9e8971d..d773b3b 100644 --- a/modules/smithy4s/src/main/scala/jsonrpclib/smithy4sinterop/ServerEndpoints.scala +++ b/modules/smithy4s/src/main/scala/jsonrpclib/smithy4sinterop/ServerEndpoints.scala @@ -6,7 +6,12 @@ import smithy4s.Service import smithy4s.kinds.FunctorAlgebra import smithy4s.kinds.FunctorInterpreter import jsonrpclib.Monadic +import jsonrpclib.Payload +import jsonrpclib.ErrorPayload import io.circe.Codec +import jsonrpclib.Monadic.syntax._ +import jsonrpclib.ErrorEncoder +import smithy4s.schema.ErrorSchema object ServerEndpoints { @@ -24,27 +29,53 @@ object ServerEndpoints { } } - // TODO : codify errors at smithy level and handle them. def jsonRPCEndpoint[F[_]: Monadic, Op[_, _, _, _, _], I, E, O, SI, SO]( smithy4sEndpoint: Smithy4sEndpoint[Op, I, E, O, SI, SO], endpointSpec: EndpointSpec, impl: FunctorInterpreter[Op, F] ): Endpoint[F] = { - implicit val inputCodec: Codec[I] = CirceJson.fromSchema(smithy4sEndpoint.input) - implicit val outputCodec: Codec[O] = CirceJson.fromSchema(smithy4sEndpoint.output) + implicit val inputCodec: Codec[I] = CirceJsonCodec.fromSchema(smithy4sEndpoint.input) + implicit val outputCodec: Codec[O] = CirceJsonCodec.fromSchema(smithy4sEndpoint.output) + + def errorResponse(throwable: Throwable): F[E] = throwable match { + case smithy4sEndpoint.Error((_, e)) => e.pure + case e: Throwable => e.raiseError + } endpointSpec match { case EndpointSpec.Notification(methodName) => Endpoint[F](methodName).notification { (input: I) => val op = smithy4sEndpoint.wrap(input) - Monadic[F].doVoid(impl(op)) + impl(op).void } case EndpointSpec.Request(methodName) => - Endpoint[F](methodName).simple { (input: I) => - val op = smithy4sEndpoint.wrap(input) - impl(op) + smithy4sEndpoint.error match { + case None => + Endpoint[F](methodName).simple[I, O] { (input: I) => + val op = smithy4sEndpoint.wrap(input) + impl(op) + } + case Some(errorSchema) => + implicit val errorCodec: ErrorEncoder[E] = errorCodecFromSchema(errorSchema) + Endpoint[F](methodName).apply[I, E, O] { (input: I) => + val op = smithy4sEndpoint.wrap(input) + impl(op).attempt.flatMap { + case Left(err) => errorResponse(err).map(r => Left(r): Either[E, O]) + case Right(success) => (Right(success): Either[E, O]).pure + } + } } } } + + private def errorCodecFromSchema[A](s: ErrorSchema[A]): ErrorEncoder[A] = { + val circeCodec = CirceJsonCodec.fromSchema(s.schema) + (a: A) => + ErrorPayload( + 0, + Option(s.unliftError(a).getMessage()).getOrElse("JSONRPC-smithy4s application error"), + Some(Payload(circeCodec.apply(a))) + ) + } } diff --git a/modules/smithy4sTests/src/main/smithy/spec.smithy b/modules/smithy4sTests/src/main/smithy/spec.smithy new file mode 100644 index 0000000..12ac63a --- /dev/null +++ b/modules/smithy4sTests/src/main/smithy/spec.smithy @@ -0,0 +1,52 @@ +$version: "2.0" + +namespace test + +use jsonrpclib#jsonNotification +use jsonrpclib#jsonRPC +use jsonrpclib#jsonRequest + +@jsonRPC +service TestServer { + operations: [Greet, Ping] +} + +@jsonRPC +service TestClient { + operations: [Pong] +} + +@jsonRequest("greet") +operation Greet { + input := { + @required + name: String + } + output := { + @required + message: String + } + errors: [NotWelcomeError] +} + +@error("client") +structure NotWelcomeError { + @required + msg: String +} + +@jsonNotification("ping") +operation Ping { + input := { + @required + ping: String + } +} + +@jsonNotification("pong") +operation Pong { + input := { + @required + pong: String + } +} diff --git a/modules/smithy4sTests/src/test/scala/jsonrpclib/smithy4sinterop/TestClientSpec.scala b/modules/smithy4sTests/src/test/scala/jsonrpclib/smithy4sinterop/TestClientSpec.scala new file mode 100644 index 0000000..0e911d4 --- /dev/null +++ b/modules/smithy4sTests/src/test/scala/jsonrpclib/smithy4sinterop/TestClientSpec.scala @@ -0,0 +1,68 @@ +package jsonrpclib.smithy4sinterop + +import cats.effect.IO +import fs2.Stream +import jsonrpclib._ +import test.TestServer +import weaver._ +import cats.syntax.all._ + +import scala.concurrent.duration._ +import jsonrpclib.fs2._ +import test.GreetOutput +import io.circe.Encoder +import test.GreetInput +import io.circe.Decoder +import test.PingInput +import _root_.fs2.concurrent.SignallingRef + +object TestClientSpec extends SimpleIOSuite { + def testRes(name: TestName)(run: Stream[IO, Expectations]): Unit = + test(name)(run.compile.lastOrError.timeout(10.second)) + + type ClientSideChannel = FS2Channel[IO] + def setup(endpoints: Endpoint[IO]*) = setupAux(endpoints, None) + def setup(cancelTemplate: CancelTemplate, endpoints: Endpoint[IO]*) = setupAux(endpoints, Some(cancelTemplate)) + def setupAux(endpoints: Seq[Endpoint[IO]], cancelTemplate: Option[CancelTemplate]): Stream[IO, ClientSideChannel] = { + for { + serverSideChannel <- FS2Channel.stream[IO](cancelTemplate = cancelTemplate) + clientSideChannel <- FS2Channel.stream[IO](cancelTemplate = cancelTemplate) + _ <- serverSideChannel.withEndpointsStream(endpoints) + _ <- Stream(()) + .concurrently(clientSideChannel.output.through(serverSideChannel.input)) + .concurrently(serverSideChannel.output.through(clientSideChannel.input)) + } yield { + clientSideChannel + } + } + + testRes("Round trip") { + implicit val greetInputDecoder: Decoder[GreetInput] = CirceJsonCodec.fromSchema + implicit val greetOutputEncoder: Encoder[GreetOutput] = CirceJsonCodec.fromSchema + val endpoint: Endpoint[IO] = + Endpoint[IO]("greet").simple[GreetInput, GreetOutput](in => IO(GreetOutput(s"Hello ${in.name}"))) + + for { + clientSideChannel <- setup(endpoint) + clientStub = ClientStub(TestServer, clientSideChannel) + result <- clientStub.greet("Bob").toStream + } yield { + expect.same(result.message, "Hello Bob") + } + } + + testRes("Sending notification") { + implicit val pingInputDecoder: Decoder[PingInput] = CirceJsonCodec.fromSchema + + for { + ref <- SignallingRef[IO, Option[PingInput]](none).toStream + endpoint: Endpoint[IO] = Endpoint[IO]("ping").notification[PingInput](p => ref.set(p.some)) + clientSideChannel <- setup(endpoint) + clientStub = ClientStub(TestServer, clientSideChannel) + _ <- clientStub.ping("hello").toStream + result <- ref.discrete.dropWhile(_.isEmpty).take(1) + } yield { + expect.same(result, Some(PingInput("hello"))) + } + } +} diff --git a/modules/smithy4sTests/src/test/scala/jsonrpclib/smithy4sinterop/TestServerSpec.scala b/modules/smithy4sTests/src/test/scala/jsonrpclib/smithy4sinterop/TestServerSpec.scala new file mode 100644 index 0000000..be66690 --- /dev/null +++ b/modules/smithy4sTests/src/test/scala/jsonrpclib/smithy4sinterop/TestServerSpec.scala @@ -0,0 +1,181 @@ +package jsonrpclib.smithy4sinterop + +import cats.effect.IO +import fs2.Stream +import test.TestServer +import test.TestClient +import weaver._ +import smithy4s.kinds.FunctorAlgebra +import cats.syntax.all._ + +import scala.concurrent.duration._ +import jsonrpclib.fs2._ +import test.GreetOutput +import io.circe.Encoder +import test.GreetInput +import test.NotWelcomeError +import io.circe.Decoder +import smithy4s.Service +import jsonrpclib.Monadic +import test.PingInput +import fs2.concurrent.SignallingRef +import test.TestServerOperation +import test.TestServerOperation.GreetError +import jsonrpclib.ErrorPayload +import jsonrpclib.Payload + +object TestServerSpec extends SimpleIOSuite { + def testRes(name: TestName)(run: Stream[IO, Expectations]): Unit = + test(name)(run.compile.lastOrError.timeout(10.second)) + + type ClientSideChannel = FS2Channel[IO] + + class ServerImpl(client: TestClient[IO]) extends TestServer[IO] { + def greet(name: String): IO[GreetOutput] = IO.pure(GreetOutput(s"Hello $name")) + + def ping(ping: String): IO[Unit] = { + client.pong(s"Returned to sender: $ping") + } + } + + class Client(ref: SignallingRef[IO, Option[String]]) extends TestClient[IO] { + def pong(pong: String): IO[Unit] = ref.set(Some(pong)) + } + + trait AlgebraWrapper { + type Alg[_[_, _, _, _, _]] + + def algebra: FunctorAlgebra[Alg, IO] + def service: Service[Alg] + } + + object AlgebraWrapper { + def apply[A[_[_, _, _, _, _]]](alg: FunctorAlgebra[A, IO])(implicit srv: Service[A]): AlgebraWrapper = + new AlgebraWrapper { + type Alg[F[_, _, _, _, _]] = A[F] + + val algebra = alg + val service = srv + } + } + + def setup(mkServer: FS2Channel[IO] => AlgebraWrapper) = + setupAux(None, mkServer.andThen(Seq(_)), _ => Seq.empty) + + def setup(mkServer: FS2Channel[IO] => AlgebraWrapper, mkClient: FS2Channel[IO] => AlgebraWrapper) = + setupAux(None, mkServer.andThen(Seq(_)), mkClient.andThen(Seq(_))) + + def setup[Alg[_[_, _, _, _, _]]]( + cancelTemplate: CancelTemplate, + mkServer: FS2Channel[IO] => Seq[AlgebraWrapper], + mkClient: FS2Channel[IO] => Seq[AlgebraWrapper] + ) = setupAux(Some(cancelTemplate), mkServer, mkClient) + + def setupAux[Alg[_[_, _, _, _, _]]]( + cancelTemplate: Option[CancelTemplate], + mkServer: FS2Channel[IO] => Seq[AlgebraWrapper], + mkClient: FS2Channel[IO] => Seq[AlgebraWrapper] + ): Stream[IO, ClientSideChannel] = { + for { + serverSideChannel <- FS2Channel.stream[IO](cancelTemplate = cancelTemplate) + clientSideChannel <- FS2Channel.stream[IO](cancelTemplate = cancelTemplate) + serverChannelWithEndpoints <- serverSideChannel.withEndpointsStream(mkServer(serverSideChannel).flatMap { p => + ServerEndpoints(p.algebra)(p.service, Monadic[IO]) + }) + clientChannelWithEndpoints <- clientSideChannel.withEndpointsStream(mkClient(clientSideChannel).flatMap { p => + ServerEndpoints(p.algebra)(p.service, Monadic[IO]) + }) + _ <- Stream(()) + .concurrently(clientChannelWithEndpoints.output.through(serverChannelWithEndpoints.input)) + .concurrently(serverChannelWithEndpoints.output.through(clientChannelWithEndpoints.input)) + } yield { + clientSideChannel + } + } + + testRes("Round trip") { + implicit val greetInputEncoder: Encoder[GreetInput] = CirceJsonCodec.fromSchema + implicit val greetOutputDecoder: Decoder[GreetOutput] = CirceJsonCodec.fromSchema + + for { + clientSideChannel <- setup(channel => { + val testClient = ClientStub(TestClient, channel) + AlgebraWrapper(new ServerImpl(testClient)) + }) + remoteFunction = clientSideChannel.simpleStub[GreetInput, GreetOutput]("greet") + result <- remoteFunction(GreetInput("Bob")).toStream + } yield { + expect.same(result.message, "Hello Bob") + } + } + + testRes("notification both ways") { + implicit val greetInputEncoder: Encoder[PingInput] = CirceJsonCodec.fromSchema + + for { + ref <- SignallingRef[IO, Option[String]](none).toStream + clientSideChannel <- setup( + channel => { + val testClient = ClientStub(TestClient, channel) + AlgebraWrapper(new ServerImpl(testClient)) + }, + _ => AlgebraWrapper(new Client(ref)) + ) + remoteFunction = clientSideChannel.notificationStub[PingInput]("ping") + _ <- remoteFunction(PingInput("hi server")).toStream + result <- ref.discrete.dropWhile(_.isEmpty).take(1) + } yield { + expect.same(result, "Returned to sender: hi server".some) + } + } + + testRes("server returns known error") { + implicit val greetInputEncoder: Encoder[GreetInput] = CirceJsonCodec.fromSchema + implicit val greetOutputDecoder: Decoder[GreetOutput] = CirceJsonCodec.fromSchema + implicit val greetErrorEncoder: Encoder[TestServerOperation.GreetError] = CirceJsonCodec.fromSchema + + for { + clientSideChannel <- setup(_ => { + AlgebraWrapper(new TestServer[IO] { + override def greet(name: String): IO[GreetOutput] = IO.raiseError(NotWelcomeError(s"$name is not welcome")) + + override def ping(ping: String): IO[Unit] = ??? + }) + }) + remoteFunction = clientSideChannel.simpleStub[GreetInput, GreetOutput]("greet") + result <- remoteFunction(GreetInput("Alice")).attempt.toStream + } yield { + matches(result) { case Left(t: ErrorPayload) => + expect.same(t.code, 0) && + expect.same(t.message, "test.NotWelcomeError(Alice is not welcome)") && + expect.same( + t.data, + Payload(greetErrorEncoder.apply(GreetError.notWelcomeError(NotWelcomeError(s"Alice is not welcome")))).some + ) + } + } + } + + testRes("server returns unknown error") { + implicit val greetInputEncoder: Encoder[GreetInput] = CirceJsonCodec.fromSchema + implicit val greetOutputDecoder: Decoder[GreetOutput] = CirceJsonCodec.fromSchema + + for { + clientSideChannel <- setup(_ => { + AlgebraWrapper(new TestServer[IO] { + override def greet(name: String): IO[GreetOutput] = IO.raiseError(new RuntimeException("some other error")) + + override def ping(ping: String): IO[Unit] = ??? + }) + }) + remoteFunction = clientSideChannel.simpleStub[GreetInput, GreetOutput]("greet") + result <- remoteFunction(GreetInput("Alice")).attempt.toStream + } yield { + matches(result) { case Left(t: ErrorPayload) => + expect.same(t.code, 0) && + expect.same(t.message, "ServerInternalError: some other error") && + expect.same(t.data, none) + } + } + } +} From bec1dca648187f72b8d699f5866b3ba8c271daaf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Koz=C5=82owski?= Date: Fri, 16 May 2025 09:35:58 +0200 Subject: [PATCH 27/47] Filter out optionals by default (#87) --- .../jsonrpclib/internals/RawMessage.scala | 2 +- .../scala/jsonrpclib/RawMessageSpec.scala | 39 ++++++++++++++++++- 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/modules/core/src/main/scala/jsonrpclib/internals/RawMessage.scala b/modules/core/src/main/scala/jsonrpclib/internals/RawMessage.scala index 62eaf0d..4ef588c 100644 --- a/modules/core/src/main/scala/jsonrpclib/internals/RawMessage.scala +++ b/modules/core/src/main/scala/jsonrpclib/internals/RawMessage.scala @@ -63,7 +63,7 @@ private[jsonrpclib] object RawMessage { "params" -> msg.params.asJson, "error" -> msg.error.asJson, "id" -> msg.id.asJson - ) ++ { + ).filterNot(_._2.isNull) ++ { msg.result match { case Some(Some(payload)) => List("result" -> payload.asJson) case Some(None) => List("result" -> Json.Null) diff --git a/modules/core/src/test/scala/jsonrpclib/RawMessageSpec.scala b/modules/core/src/test/scala/jsonrpclib/RawMessageSpec.scala index 11b2e5d..c1f3306 100644 --- a/modules/core/src/test/scala/jsonrpclib/RawMessageSpec.scala +++ b/modules/core/src/test/scala/jsonrpclib/RawMessageSpec.scala @@ -7,12 +7,13 @@ import jsonrpclib.CallId.NumberId import jsonrpclib.OutputMessage.ResponseMessage import io.circe.Json import com.github.plokhotnyuk.jsoniter_scala.circe.JsoniterScalaCodec._ +import io.circe.syntax._ object RawMessageSpec extends FunSuite { test("json parsing with null result") { // This is a perfectly valid response object, as result field has to be present, // but can be null: https://www.jsonrpc.org/specification#response_object - val rawMessage = readFromString[Json](""" {"jsonrpc":"2.0","result":null,"id":3} """.trim) + val rawMessage = readFromString[Json](""" {"jsonrpc":"2.0","id":3,"result":null}""".trim) .as[RawMessage] .fold(throw _, identity) @@ -31,4 +32,40 @@ object RawMessageSpec extends FunSuite { ) && assert(invalidRawMessage.toMessage.isLeft, invalidRawMessage.toMessage.toString) } + + test("request message serialization") { + val input: Message = InputMessage.RequestMessage("my/method", CallId.NumberId(1), None) + val expected = """{"jsonrpc":"2.0","method":"my/method","id":1}""" + val result = writeToString(input.asJson) + + assert(result == expected, s"Expected: $expected, got: $result") + } + + test("notification message serialization") { + val input: Message = InputMessage.NotificationMessage("my/method", None) + val expected = """{"jsonrpc":"2.0","method":"my/method"}""" + val result = writeToString(input.asJson) + + assert(result == expected, s"Expected: $expected, got: $result") + } + + test("response message serialization") { + val input: Message = OutputMessage.ResponseMessage(CallId.NumberId(1), Payload.NullPayload) + val expected = """{"jsonrpc":"2.0","id":1,"result":null}""" + val result = writeToString(input.asJson) + + assert(result == expected, s"Expected: $expected, got: $result") + } + + test("error message serialization") { + val input: Message = OutputMessage.ErrorMessage( + CallId.NumberId(1), + ErrorPayload(-32603, "Internal error", None) + ) + val expected = """{"jsonrpc":"2.0","error":{"code":-32603,"message":"Internal error","data":null},"id":1}""" + val result = writeToString(input.asJson) + + assert(result == expected, s"Expected: $expected, got: $result") + } + } From 246ba779db54862e52eb45262481f2cdbd6fa80d Mon Sep 17 00:00:00 2001 From: ghostbuster91 Date: Fri, 16 May 2025 10:17:08 +0200 Subject: [PATCH 28/47] Accessing endpoints from multiple servers --- .../smithy4sTests/src/main/smithy/spec.smithy | 17 +++++++++ .../smithy4sinterop/TestServerSpec.scala | 35 +++++++++++++++++++ 2 files changed, 52 insertions(+) diff --git a/modules/smithy4sTests/src/main/smithy/spec.smithy b/modules/smithy4sTests/src/main/smithy/spec.smithy index 12ac63a..eb4d1b7 100644 --- a/modules/smithy4sTests/src/main/smithy/spec.smithy +++ b/modules/smithy4sTests/src/main/smithy/spec.smithy @@ -50,3 +50,20 @@ operation Pong { pong: String } } + +@jsonRPC +service WeatherService { + operations: [GetWeather] +} + +@jsonRequest("getWeather") +operation GetWeather { + input := { + @required + city: String + } + output := { + @required + weather: String + } +} diff --git a/modules/smithy4sTests/src/test/scala/jsonrpclib/smithy4sinterop/TestServerSpec.scala b/modules/smithy4sTests/src/test/scala/jsonrpclib/smithy4sinterop/TestServerSpec.scala index be66690..92523a5 100644 --- a/modules/smithy4sTests/src/test/scala/jsonrpclib/smithy4sinterop/TestServerSpec.scala +++ b/modules/smithy4sTests/src/test/scala/jsonrpclib/smithy4sinterop/TestServerSpec.scala @@ -4,6 +4,7 @@ import cats.effect.IO import fs2.Stream import test.TestServer import test.TestClient +import test.WeatherService import weaver._ import smithy4s.kinds.FunctorAlgebra import cats.syntax.all._ @@ -13,6 +14,8 @@ import jsonrpclib.fs2._ import test.GreetOutput import io.circe.Encoder import test.GreetInput +import test.GetWeatherOutput +import test.GetWeatherInput import test.NotWelcomeError import io.circe.Decoder import smithy4s.Service @@ -178,4 +181,36 @@ object TestServerSpec extends SimpleIOSuite { } } } + + testRes("accessing endpoints from multiple servers") { + class WeatherServiceImpl() extends WeatherService[IO] { + override def getWeather(city: String): IO[GetWeatherOutput] = IO(GetWeatherOutput("sunny")) + } + + for { + clientSideChannel <- setupAux( + None, + channel => { + val testClient = ClientStub(TestClient, channel) + Seq(AlgebraWrapper(new ServerImpl(testClient)), AlgebraWrapper(new WeatherServiceImpl())) + }, + _ => Seq.empty + ) + greetResult <- { + implicit val inputEncoder: Encoder[GreetInput] = CirceJsonCodec.fromSchema + implicit val outputDecoder: Decoder[GreetOutput] = CirceJsonCodec.fromSchema + val remoteFunction = clientSideChannel.simpleStub[GreetInput, GreetOutput]("greet") + remoteFunction(GreetInput("Bob")).toStream + } + getWeatherResult <- { + implicit val inputEncoder: Encoder[GetWeatherInput] = CirceJsonCodec.fromSchema + implicit val outputDecoder: Decoder[GetWeatherOutput] = CirceJsonCodec.fromSchema + val remoteFunction = clientSideChannel.simpleStub[GetWeatherInput, GetWeatherOutput]("getWeather") + remoteFunction(GetWeatherInput("Warsaw")).toStream + } + } yield { + expect.same(greetResult.message, "Hello Bob") && + expect.same(getWeatherResult.weather, "sunny") + } + } } From 903ab822dce01b4cddc82c18d413cbd01390aaa6 Mon Sep 17 00:00:00 2001 From: ghostbuster91 Date: Fri, 16 May 2025 10:19:59 +0200 Subject: [PATCH 29/47] chore: sort and group imports with scalafmt --- .scalafmt.conf | 16 +++++++++ .../src/main/scala/jsonrpclib/CallId.scala | 4 ++- .../src/main/scala/jsonrpclib/Channel.scala | 4 +-- .../src/main/scala/jsonrpclib/Endpoint.scala | 2 +- .../main/scala/jsonrpclib/ErrorPayload.scala | 3 +- .../src/main/scala/jsonrpclib/Monadic.scala | 2 +- .../src/main/scala/jsonrpclib/Payload.scala | 4 ++- .../internals/FutureBaseChannel.scala | 2 +- .../internals/MessageDispatcher.scala | 7 ++-- .../jsonrpclib/internals/RawMessage.scala | 4 ++- .../test/scala/jsonrpclib/CallIdSpec.scala | 6 ++-- .../scala/jsonrpclib/RawMessageSpec.scala | 10 +++--- .../jsonrpclib/internals/HeaderSpec.scala | 7 ++-- .../scala/examples/client/ClientMain.scala | 8 ++--- .../scala/examples/server/ServerMain.scala | 8 +++-- .../examples/smithy/client/ClientMain.scala | 4 +-- .../examples/smithy/server/ServerMain.scala | 6 ++-- .../scala/jsonrpclib/fs2/FS2Channel.scala | 19 +++++----- .../src/main/scala/jsonrpclib/fs2/lsp.scala | 18 +++++----- .../main/scala/jsonrpclib/fs2/package.scala | 9 ++--- .../scala/jsonrpclib/fs2/FS2ChannelSpec.scala | 4 +-- .../smithy4sinterop/CirceJsonCodec.scala | 7 ++-- .../smithy4sinterop/ClientStub.scala | 8 ++--- .../smithy4sinterop/ServerEndpoints.scala | 17 ++++----- .../smithy4sinterop/TestClientSpec.scala | 15 ++++---- .../smithy4sinterop/TestServerSpec.scala | 36 +++++++++---------- 26 files changed, 131 insertions(+), 99 deletions(-) diff --git a/.scalafmt.conf b/.scalafmt.conf index d417d12..86c9f3a 100644 --- a/.scalafmt.conf +++ b/.scalafmt.conf @@ -1,6 +1,22 @@ version = "3.8.0" runner.dialect = scala213 maxColumn = 120 + +rewrite { + rules = [ + ExpandImportSelectors, + Imports + ] + + imports { + groups = [ + ["[a-z].*"], + ["java\\..*", "scala\\..*"] + ] + sort = original + } +} + fileOverride { "glob:**/fs2/src/**" { runner.dialect = scala213source3 diff --git a/modules/core/src/main/scala/jsonrpclib/CallId.scala b/modules/core/src/main/scala/jsonrpclib/CallId.scala index 146315f..918145a 100644 --- a/modules/core/src/main/scala/jsonrpclib/CallId.scala +++ b/modules/core/src/main/scala/jsonrpclib/CallId.scala @@ -1,6 +1,8 @@ package jsonrpclib -import io.circe.{Decoder, Json, Codec} +import io.circe.Codec +import io.circe.Decoder +import io.circe.Json sealed trait CallId object CallId { diff --git a/modules/core/src/main/scala/jsonrpclib/Channel.scala b/modules/core/src/main/scala/jsonrpclib/Channel.scala index 24d9d74..3d75d96 100644 --- a/modules/core/src/main/scala/jsonrpclib/Channel.scala +++ b/modules/core/src/main/scala/jsonrpclib/Channel.scala @@ -1,8 +1,8 @@ package jsonrpclib -import jsonrpclib.ErrorCodec.errorPayloadCodec -import io.circe.Encoder import io.circe.Decoder +import io.circe.Encoder +import jsonrpclib.ErrorCodec.errorPayloadCodec trait Channel[F[_]] { def mountEndpoint(endpoint: Endpoint[F]): F[Unit] diff --git a/modules/core/src/main/scala/jsonrpclib/Endpoint.scala b/modules/core/src/main/scala/jsonrpclib/Endpoint.scala index d9d0910..245e520 100644 --- a/modules/core/src/main/scala/jsonrpclib/Endpoint.scala +++ b/modules/core/src/main/scala/jsonrpclib/Endpoint.scala @@ -1,8 +1,8 @@ package jsonrpclib -import jsonrpclib.ErrorCodec.errorPayloadCodec import io.circe.Decoder import io.circe.Encoder +import jsonrpclib.ErrorCodec.errorPayloadCodec sealed trait Endpoint[F[_]] { def method: String diff --git a/modules/core/src/main/scala/jsonrpclib/ErrorPayload.scala b/modules/core/src/main/scala/jsonrpclib/ErrorPayload.scala index fddb5a2..b40ab72 100644 --- a/modules/core/src/main/scala/jsonrpclib/ErrorPayload.scala +++ b/modules/core/src/main/scala/jsonrpclib/ErrorPayload.scala @@ -1,6 +1,7 @@ package jsonrpclib -import io.circe.{Decoder, Encoder} +import io.circe.Decoder +import io.circe.Encoder case class ErrorPayload(code: Int, message: String, data: Option[Payload]) extends Throwable { override def getMessage(): String = s"JsonRPC Error $code: $message" diff --git a/modules/core/src/main/scala/jsonrpclib/Monadic.scala b/modules/core/src/main/scala/jsonrpclib/Monadic.scala index a42aaa7..dd1e6ee 100644 --- a/modules/core/src/main/scala/jsonrpclib/Monadic.scala +++ b/modules/core/src/main/scala/jsonrpclib/Monadic.scala @@ -1,7 +1,7 @@ package jsonrpclib -import scala.concurrent.Future import scala.concurrent.ExecutionContext +import scala.concurrent.Future trait Monadic[F[_]] { def doFlatMap[A, B](fa: F[A])(f: A => F[B]): F[B] diff --git a/modules/core/src/main/scala/jsonrpclib/Payload.scala b/modules/core/src/main/scala/jsonrpclib/Payload.scala index cc77767..ba2c19a 100644 --- a/modules/core/src/main/scala/jsonrpclib/Payload.scala +++ b/modules/core/src/main/scala/jsonrpclib/Payload.scala @@ -1,6 +1,8 @@ package jsonrpclib -import io.circe.{Decoder, Encoder, Json} +import io.circe.Decoder +import io.circe.Encoder +import io.circe.Json case class Payload(data: Json) { def stripNull: Option[Payload] = Option(Payload(data)).filter(p => !p.data.isNull) diff --git a/modules/core/src/main/scala/jsonrpclib/internals/FutureBaseChannel.scala b/modules/core/src/main/scala/jsonrpclib/internals/FutureBaseChannel.scala index 3b50fd5..c5e3df3 100644 --- a/modules/core/src/main/scala/jsonrpclib/internals/FutureBaseChannel.scala +++ b/modules/core/src/main/scala/jsonrpclib/internals/FutureBaseChannel.scala @@ -1,5 +1,6 @@ package jsonrpclib +import io.circe.Encoder import jsonrpclib.internals._ import java.util.concurrent.atomic.AtomicLong @@ -7,7 +8,6 @@ import scala.concurrent.ExecutionContext import scala.concurrent.Future import scala.concurrent.Promise import scala.util.Try -import io.circe.Encoder abstract class FutureBasedChannel(endpoints: List[Endpoint[Future]])(implicit ec: ExecutionContext) extends MessageDispatcher[Future] { diff --git a/modules/core/src/main/scala/jsonrpclib/internals/MessageDispatcher.scala b/modules/core/src/main/scala/jsonrpclib/internals/MessageDispatcher.scala index 5950890..fdda32b 100644 --- a/modules/core/src/main/scala/jsonrpclib/internals/MessageDispatcher.scala +++ b/modules/core/src/main/scala/jsonrpclib/internals/MessageDispatcher.scala @@ -1,14 +1,15 @@ package jsonrpclib package internals +import io.circe.Decoder +import io.circe.Encoder +import io.circe.HCursor import jsonrpclib.Endpoint.NotificationEndpoint import jsonrpclib.Endpoint.RequestResponseEndpoint import jsonrpclib.OutputMessage.ErrorMessage import jsonrpclib.OutputMessage.ResponseMessage + import scala.util.Try -import io.circe.HCursor -import io.circe.Encoder -import io.circe.Decoder private[jsonrpclib] abstract class MessageDispatcher[F[_]](implicit F: Monadic[F]) extends Channel.MonadicChannel[F] { diff --git a/modules/core/src/main/scala/jsonrpclib/internals/RawMessage.scala b/modules/core/src/main/scala/jsonrpclib/internals/RawMessage.scala index 4ef588c..5ee6337 100644 --- a/modules/core/src/main/scala/jsonrpclib/internals/RawMessage.scala +++ b/modules/core/src/main/scala/jsonrpclib/internals/RawMessage.scala @@ -1,8 +1,10 @@ package jsonrpclib package internals -import io.circe.{Decoder, Encoder, Json} import io.circe.syntax._ +import io.circe.Decoder +import io.circe.Encoder +import io.circe.Json private[jsonrpclib] case class RawMessage( jsonrpc: String, diff --git a/modules/core/src/test/scala/jsonrpclib/CallIdSpec.scala b/modules/core/src/test/scala/jsonrpclib/CallIdSpec.scala index e56ec41..2116150 100644 --- a/modules/core/src/test/scala/jsonrpclib/CallIdSpec.scala +++ b/modules/core/src/test/scala/jsonrpclib/CallIdSpec.scala @@ -1,10 +1,10 @@ package jsonrpclib -import weaver._ +import cats.syntax.all._ +import com.github.plokhotnyuk.jsoniter_scala.circe.JsoniterScalaCodec._ import com.github.plokhotnyuk.jsoniter_scala.core._ import io.circe.Json -import com.github.plokhotnyuk.jsoniter_scala.circe.JsoniterScalaCodec._ -import cats.syntax.all._ +import weaver._ object CallIdSpec extends FunSuite { test("json parsing") { diff --git a/modules/core/src/test/scala/jsonrpclib/RawMessageSpec.scala b/modules/core/src/test/scala/jsonrpclib/RawMessageSpec.scala index c1f3306..1fe0f41 100644 --- a/modules/core/src/test/scala/jsonrpclib/RawMessageSpec.scala +++ b/modules/core/src/test/scala/jsonrpclib/RawMessageSpec.scala @@ -1,13 +1,13 @@ package jsonrpclib -import weaver._ -import jsonrpclib.internals._ +import com.github.plokhotnyuk.jsoniter_scala.circe.JsoniterScalaCodec._ import com.github.plokhotnyuk.jsoniter_scala.core._ +import io.circe.syntax._ +import io.circe.Json +import jsonrpclib.internals._ import jsonrpclib.CallId.NumberId import jsonrpclib.OutputMessage.ResponseMessage -import io.circe.Json -import com.github.plokhotnyuk.jsoniter_scala.circe.JsoniterScalaCodec._ -import io.circe.syntax._ +import weaver._ object RawMessageSpec extends FunSuite { test("json parsing with null result") { diff --git a/modules/core/src/test/scalajvm-native/jsonrpclib/internals/HeaderSpec.scala b/modules/core/src/test/scalajvm-native/jsonrpclib/internals/HeaderSpec.scala index e58bd59..88d629e 100644 --- a/modules/core/src/test/scalajvm-native/jsonrpclib/internals/HeaderSpec.scala +++ b/modules/core/src/test/scalajvm-native/jsonrpclib/internals/HeaderSpec.scala @@ -1,11 +1,12 @@ package jsonrpclib.internals +import jsonrpclib.ProtocolError import weaver._ -import java.io.ByteArrayInputStream + import java.io.BufferedReader -import java.io.InputStreamReader -import jsonrpclib.ProtocolError +import java.io.ByteArrayInputStream import java.io.IOException +import java.io.InputStreamReader import java.io.UncheckedIOException object HeaderSpec extends FunSuite { diff --git a/modules/examples/client/src/main/scala/examples/client/ClientMain.scala b/modules/examples/client/src/main/scala/examples/client/ClientMain.scala index 21b432d..a424ad5 100644 --- a/modules/examples/client/src/main/scala/examples/client/ClientMain.scala +++ b/modules/examples/client/src/main/scala/examples/client/ClientMain.scala @@ -2,13 +2,13 @@ package examples.client import cats.effect._ import cats.syntax.all._ -import io.circe.Codec -import io.circe.generic.semiauto._ -import fs2.Stream import fs2.io._ import fs2.io.process.Processes -import jsonrpclib.CallId +import fs2.Stream +import io.circe.generic.semiauto._ +import io.circe.Codec import jsonrpclib.fs2._ +import jsonrpclib.CallId object ClientMain extends IOApp.Simple { diff --git a/modules/examples/server/src/main/scala/examples/server/ServerMain.scala b/modules/examples/server/src/main/scala/examples/server/ServerMain.scala index 8372ea0..786a278 100644 --- a/modules/examples/server/src/main/scala/examples/server/ServerMain.scala +++ b/modules/examples/server/src/main/scala/examples/server/ServerMain.scala @@ -1,11 +1,13 @@ package examples.server -import jsonrpclib.CallId -import jsonrpclib.fs2._ import cats.effect._ import fs2.io._ -import io.circe.{Decoder, Encoder, Codec} import io.circe.generic.semiauto._ +import io.circe.Codec +import io.circe.Decoder +import io.circe.Encoder +import jsonrpclib.fs2._ +import jsonrpclib.CallId import jsonrpclib.Endpoint object ServerMain extends IOApp.Simple { diff --git a/modules/examples/smithyClient/src/main/scala/examples/smithy/client/ClientMain.scala b/modules/examples/smithyClient/src/main/scala/examples/smithy/client/ClientMain.scala index 5098fc1..06016f4 100644 --- a/modules/examples/smithyClient/src/main/scala/examples/smithy/client/ClientMain.scala +++ b/modules/examples/smithyClient/src/main/scala/examples/smithy/client/ClientMain.scala @@ -2,12 +2,12 @@ package examples.smithy.client import cats.effect._ import cats.syntax.all._ +import fs2.io.process.Processes import fs2.Stream -import jsonrpclib.CallId import jsonrpclib.fs2._ import jsonrpclib.smithy4sinterop.ClientStub import jsonrpclib.smithy4sinterop.ServerEndpoints -import fs2.io.process.Processes +import jsonrpclib.CallId import test._ object SmithyClientMain extends IOApp.Simple { diff --git a/modules/examples/smithyServer/src/main/scala/examples/smithy/server/ServerMain.scala b/modules/examples/smithyServer/src/main/scala/examples/smithy/server/ServerMain.scala index ed81d28..d410ad3 100644 --- a/modules/examples/smithyServer/src/main/scala/examples/smithy/server/ServerMain.scala +++ b/modules/examples/smithyServer/src/main/scala/examples/smithy/server/ServerMain.scala @@ -1,12 +1,12 @@ package examples.smithy.server -import jsonrpclib.CallId -import jsonrpclib.fs2._ import cats.effect._ import fs2.io._ -import test._ // smithy4s-generated package +import jsonrpclib.fs2._ import jsonrpclib.smithy4sinterop.ClientStub import jsonrpclib.smithy4sinterop.ServerEndpoints +import jsonrpclib.CallId +import test._ // smithy4s-generated package object ServerMain extends IOApp.Simple { diff --git a/modules/fs2/src/main/scala/jsonrpclib/fs2/FS2Channel.scala b/modules/fs2/src/main/scala/jsonrpclib/fs2/FS2Channel.scala index a00f090..f8bbd97 100644 --- a/modules/fs2/src/main/scala/jsonrpclib/fs2/FS2Channel.scala +++ b/modules/fs2/src/main/scala/jsonrpclib/fs2/FS2Channel.scala @@ -1,22 +1,23 @@ package jsonrpclib package fs2 -import _root_.fs2.Pipe -import _root_.fs2.Stream +import cats.effect.kernel._ +import cats.effect.std.Supervisor +import cats.effect.syntax.all._ +import cats.effect.Fiber +import cats.syntax.all._ import cats.Applicative import cats.Functor import cats.Monad import cats.MonadThrow -import cats.effect.Fiber -import cats.effect.kernel._ -import cats.effect.std.Supervisor -import cats.syntax.all._ -import cats.effect.syntax.all._ -import jsonrpclib.internals.MessageDispatcher import io.circe.Codec +import jsonrpclib.internals.MessageDispatcher -import scala.util.Try import java.util.regex.Pattern +import scala.util.Try + +import _root_.fs2.Pipe +import _root_.fs2.Stream trait FS2Channel[F[_]] extends Channel[F] { diff --git a/modules/fs2/src/main/scala/jsonrpclib/fs2/lsp.scala b/modules/fs2/src/main/scala/jsonrpclib/fs2/lsp.scala index 1084f17..03d7f48 100644 --- a/modules/fs2/src/main/scala/jsonrpclib/fs2/lsp.scala +++ b/modules/fs2/src/main/scala/jsonrpclib/fs2/lsp.scala @@ -1,21 +1,23 @@ package jsonrpclib.fs2 import cats.MonadThrow +import com.github.plokhotnyuk.jsoniter_scala.circe.JsoniterScalaCodec._ +import com.github.plokhotnyuk.jsoniter_scala.core._ import fs2.Chunk -import fs2.Stream import fs2.Pipe +import fs2.Stream +import io.circe.Decoder +import io.circe.Encoder +import io.circe.HCursor +import io.circe.Json +import jsonrpclib.Message import jsonrpclib.Payload -import io.circe.{Encoder, Decoder, HCursor} +import jsonrpclib.ProtocolError + import java.nio.charset.Charset import java.nio.charset.StandardCharsets -import jsonrpclib.Message -import jsonrpclib.ProtocolError import scala.annotation.tailrec -import com.github.plokhotnyuk.jsoniter_scala.core._ -import com.github.plokhotnyuk.jsoniter_scala.circe.JsoniterScalaCodec._ -import io.circe.Json - object lsp { def encodeMessages[F[_]]: Pipe[F, Message, Byte] = diff --git a/modules/fs2/src/main/scala/jsonrpclib/fs2/package.scala b/modules/fs2/src/main/scala/jsonrpclib/fs2/package.scala index f36ab31..cdee83e 100644 --- a/modules/fs2/src/main/scala/jsonrpclib/fs2/package.scala +++ b/modules/fs2/src/main/scala/jsonrpclib/fs2/package.scala @@ -1,10 +1,11 @@ package jsonrpclib -import _root_.fs2.Stream -import cats.MonadThrow -import cats.Monad -import cats.effect.kernel.Resource import cats.effect.kernel.MonadCancel +import cats.effect.kernel.Resource +import cats.Monad +import cats.MonadThrow + +import _root_.fs2.Stream package object fs2 { diff --git a/modules/fs2/src/test/scala/jsonrpclib/fs2/FS2ChannelSpec.scala b/modules/fs2/src/test/scala/jsonrpclib/fs2/FS2ChannelSpec.scala index 2bc3695..ffffce5 100644 --- a/modules/fs2/src/test/scala/jsonrpclib/fs2/FS2ChannelSpec.scala +++ b/modules/fs2/src/test/scala/jsonrpclib/fs2/FS2ChannelSpec.scala @@ -3,10 +3,10 @@ package jsonrpclib.fs2 import cats.effect.IO import cats.syntax.all._ import fs2.Stream +import io.circe.generic.semiauto._ +import io.circe.Codec import jsonrpclib._ import weaver._ -import io.circe.Codec -import io.circe.generic.semiauto._ import scala.concurrent.duration._ diff --git a/modules/smithy4s/src/main/scala/jsonrpclib/smithy4sinterop/CirceJsonCodec.scala b/modules/smithy4s/src/main/scala/jsonrpclib/smithy4sinterop/CirceJsonCodec.scala index 0c242b6..7d1c2eb 100644 --- a/modules/smithy4s/src/main/scala/jsonrpclib/smithy4sinterop/CirceJsonCodec.scala +++ b/modules/smithy4s/src/main/scala/jsonrpclib/smithy4sinterop/CirceJsonCodec.scala @@ -1,11 +1,10 @@ package jsonrpclib.smithy4sinterop -import smithy4s.Document -import smithy4s.Schema +import io.circe._ import smithy4s.codecs.PayloadPath - +import smithy4s.Document import smithy4s.Document.{Decoder => _, _} -import io.circe._ +import smithy4s.Schema object CirceJsonCodec { diff --git a/modules/smithy4s/src/main/scala/jsonrpclib/smithy4sinterop/ClientStub.scala b/modules/smithy4s/src/main/scala/jsonrpclib/smithy4sinterop/ClientStub.scala index 8827e56..d7c4521 100644 --- a/modules/smithy4s/src/main/scala/jsonrpclib/smithy4sinterop/ClientStub.scala +++ b/modules/smithy4s/src/main/scala/jsonrpclib/smithy4sinterop/ClientStub.scala @@ -1,12 +1,12 @@ package jsonrpclib.smithy4sinterop -import smithy4s.~> -import smithy4s.Service -import smithy4s.schema._ -import smithy4s.ShapeId import io.circe.Codec import jsonrpclib.Channel import jsonrpclib.Monadic +import smithy4s.~> +import smithy4s.schema._ +import smithy4s.Service +import smithy4s.ShapeId object ClientStub { diff --git a/modules/smithy4s/src/main/scala/jsonrpclib/smithy4sinterop/ServerEndpoints.scala b/modules/smithy4s/src/main/scala/jsonrpclib/smithy4sinterop/ServerEndpoints.scala index d773b3b..a233390 100644 --- a/modules/smithy4s/src/main/scala/jsonrpclib/smithy4sinterop/ServerEndpoints.scala +++ b/modules/smithy4s/src/main/scala/jsonrpclib/smithy4sinterop/ServerEndpoints.scala @@ -1,17 +1,18 @@ package jsonrpclib.smithy4sinterop -import _root_.smithy4s.{Endpoint => Smithy4sEndpoint} +import io.circe.Codec import jsonrpclib.Endpoint -import smithy4s.Service -import smithy4s.kinds.FunctorAlgebra -import smithy4s.kinds.FunctorInterpreter -import jsonrpclib.Monadic -import jsonrpclib.Payload +import jsonrpclib.ErrorEncoder import jsonrpclib.ErrorPayload -import io.circe.Codec +import jsonrpclib.Monadic import jsonrpclib.Monadic.syntax._ -import jsonrpclib.ErrorEncoder +import jsonrpclib.Payload +import smithy4s.kinds.FunctorAlgebra +import smithy4s.kinds.FunctorInterpreter import smithy4s.schema.ErrorSchema +import smithy4s.Service + +import _root_.smithy4s.{Endpoint => Smithy4sEndpoint} object ServerEndpoints { diff --git a/modules/smithy4sTests/src/test/scala/jsonrpclib/smithy4sinterop/TestClientSpec.scala b/modules/smithy4sTests/src/test/scala/jsonrpclib/smithy4sinterop/TestClientSpec.scala index 0e911d4..47152f4 100644 --- a/modules/smithy4sTests/src/test/scala/jsonrpclib/smithy4sinterop/TestClientSpec.scala +++ b/modules/smithy4sTests/src/test/scala/jsonrpclib/smithy4sinterop/TestClientSpec.scala @@ -1,19 +1,20 @@ package jsonrpclib.smithy4sinterop import cats.effect.IO +import cats.syntax.all._ import fs2.Stream +import io.circe.Decoder +import io.circe.Encoder import jsonrpclib._ +import jsonrpclib.fs2._ +import test.GreetInput +import test.GreetOutput +import test.PingInput import test.TestServer import weaver._ -import cats.syntax.all._ import scala.concurrent.duration._ -import jsonrpclib.fs2._ -import test.GreetOutput -import io.circe.Encoder -import test.GreetInput -import io.circe.Decoder -import test.PingInput + import _root_.fs2.concurrent.SignallingRef object TestClientSpec extends SimpleIOSuite { diff --git a/modules/smithy4sTests/src/test/scala/jsonrpclib/smithy4sinterop/TestServerSpec.scala b/modules/smithy4sTests/src/test/scala/jsonrpclib/smithy4sinterop/TestServerSpec.scala index 92523a5..7e1c1a5 100644 --- a/modules/smithy4sTests/src/test/scala/jsonrpclib/smithy4sinterop/TestServerSpec.scala +++ b/modules/smithy4sTests/src/test/scala/jsonrpclib/smithy4sinterop/TestServerSpec.scala @@ -1,31 +1,31 @@ package jsonrpclib.smithy4sinterop import cats.effect.IO -import fs2.Stream -import test.TestServer -import test.TestClient -import test.WeatherService -import weaver._ -import smithy4s.kinds.FunctorAlgebra import cats.syntax.all._ - -import scala.concurrent.duration._ -import jsonrpclib.fs2._ -import test.GreetOutput +import fs2.concurrent.SignallingRef +import fs2.Stream +import io.circe.Decoder import io.circe.Encoder -import test.GreetInput -import test.GetWeatherOutput +import jsonrpclib.fs2._ +import jsonrpclib.ErrorPayload +import jsonrpclib.Monadic +import jsonrpclib.Payload +import smithy4s.kinds.FunctorAlgebra +import smithy4s.Service import test.GetWeatherInput +import test.GetWeatherOutput +import test.GreetInput +import test.GreetOutput import test.NotWelcomeError -import io.circe.Decoder -import smithy4s.Service -import jsonrpclib.Monadic import test.PingInput -import fs2.concurrent.SignallingRef +import test.TestClient +import test.TestServer import test.TestServerOperation import test.TestServerOperation.GreetError -import jsonrpclib.ErrorPayload -import jsonrpclib.Payload +import test.WeatherService +import weaver._ + +import scala.concurrent.duration._ object TestServerSpec extends SimpleIOSuite { def testRes(name: TestName)(run: Stream[IO, Expectations]): Unit = From 3821de847d6e32fe506b8ca7cf956017e9d827af Mon Sep 17 00:00:00 2001 From: ghostbuster91 Date: Fri, 16 May 2025 11:01:13 +0200 Subject: [PATCH 30/47] Add some scaladocs --- .../src/main/scala/jsonrpclib/Endpoint.scala | 19 ++++++++++++ .../main/scala/jsonrpclib/PolyFunction.scala | 7 +++++ .../scala/jsonrpclib/fs2/FS2Channel.scala | 21 ++++++++++++++ .../smithy4sinterop/CirceJsonCodec.scala | 5 ++++ .../smithy4sinterop/ClientStub.scala | 13 +++++++++ .../smithy4sinterop/ServerEndpoints.scala | 29 ++++++++++++++++++- 6 files changed, 93 insertions(+), 1 deletion(-) diff --git a/modules/core/src/main/scala/jsonrpclib/Endpoint.scala b/modules/core/src/main/scala/jsonrpclib/Endpoint.scala index 245e520..9a228c9 100644 --- a/modules/core/src/main/scala/jsonrpclib/Endpoint.scala +++ b/modules/core/src/main/scala/jsonrpclib/Endpoint.scala @@ -4,9 +4,28 @@ import io.circe.Decoder import io.circe.Encoder import jsonrpclib.ErrorCodec.errorPayloadCodec +/** Represents a JSON-RPC method handler that can be invoked by the server. + * + * An `Endpoint[F]` defines how to decode input from a JSON-RPC message, execute some effectful logic, and optionally + * return a response. + * + * The endpoint's `method` field is used to match incoming JSON-RPC requests. + */ sealed trait Endpoint[F[_]] { + + /** The JSON-RPC method name this endpoint responds to. Used for dispatching incoming requests. */ def method: String + /** Transforms the effect type of this endpoint using the provided `PolyFunction`. + * + * This allows reinterpreting the endpoint’s logic in a different effect context (e.g., from `IO` to `Kleisli[IO, + * Ctx, *]`, or from `F` to `EitherT[F, E, *]`). + * + * @param f + * A polymorphic function that transforms `F[_]` into `G[_]` + * @return + * A new `Endpoint[G]` with the same behavior but in a new effect type + */ def mapK[G[_]](f: PolyFunction[F, G]): Endpoint[G] } diff --git a/modules/core/src/main/scala/jsonrpclib/PolyFunction.scala b/modules/core/src/main/scala/jsonrpclib/PolyFunction.scala index 3942a26..637c50f 100644 --- a/modules/core/src/main/scala/jsonrpclib/PolyFunction.scala +++ b/modules/core/src/main/scala/jsonrpclib/PolyFunction.scala @@ -1,5 +1,12 @@ package jsonrpclib +/** A polymorphic natural transformation from `F[_]` to `G[_]`. + * + * @tparam F + * Source effect type + * @tparam G + * Target effect type + */ trait PolyFunction[F[_], G[_]] { self => def apply[A0](fa: => F[A0]): G[A0] } diff --git a/modules/fs2/src/main/scala/jsonrpclib/fs2/FS2Channel.scala b/modules/fs2/src/main/scala/jsonrpclib/fs2/FS2Channel.scala index f8bbd97..7468826 100644 --- a/modules/fs2/src/main/scala/jsonrpclib/fs2/FS2Channel.scala +++ b/modules/fs2/src/main/scala/jsonrpclib/fs2/FS2Channel.scala @@ -19,6 +19,13 @@ import scala.util.Try import _root_.fs2.Pipe import _root_.fs2.Stream +/** A JSON-RPC communication channel built on top of `fs2.Stream`. + * + * `FS2Channel[F]` enables streaming JSON-RPC messages into and out of an effectful system. It provides methods to + * register handlers (`Endpoint[F]`) for specific method names. + * + * This is the primary server-side integration point for using JSON-RPC over FS2. + */ trait FS2Channel[F[_]] extends Channel[F] { def input: Pipe[F, Message, Unit] @@ -49,6 +56,20 @@ trait FS2Channel[F[_]] extends Channel[F] { object FS2Channel { + /** Creates a new `FS2Channel[F]` as a managed resource with a configurable buffer size for bidirectional message + * processing. + * + * Optionally, a `CancelTemplate` can be provided to support client-initiated cancellation of inflight requests via a + * dedicated cancellation endpoint. + * + * @param bufferSize + * Size of the internal outbound message queue (default: 2048) + * @param cancelTemplate + * Optional handler that defines how to decode and handle cancellation requests + * + * @return + * A `Resource` that manages the lifecycle of the channel and its internal supervisor + */ def resource[F[_]: Concurrent]( bufferSize: Int = 2048, cancelTemplate: Option[CancelTemplate] = None diff --git a/modules/smithy4s/src/main/scala/jsonrpclib/smithy4sinterop/CirceJsonCodec.scala b/modules/smithy4s/src/main/scala/jsonrpclib/smithy4sinterop/CirceJsonCodec.scala index 7d1c2eb..681c8e3 100644 --- a/modules/smithy4s/src/main/scala/jsonrpclib/smithy4sinterop/CirceJsonCodec.scala +++ b/modules/smithy4s/src/main/scala/jsonrpclib/smithy4sinterop/CirceJsonCodec.scala @@ -8,6 +8,11 @@ import smithy4s.Schema object CirceJsonCodec { + /** Creates a Circe `Codec[A]` from a Smithy4s `Schema[A]`. + * + * This enables encoding values of type `A` to JSON and decoding JSON back into `A`, using the structure defined by + * the Smithy schema. + */ def fromSchema[A](implicit schema: Schema[A]): Codec[A] = Codec.from( c => { c.as[Json] diff --git a/modules/smithy4s/src/main/scala/jsonrpclib/smithy4sinterop/ClientStub.scala b/modules/smithy4s/src/main/scala/jsonrpclib/smithy4sinterop/ClientStub.scala index d7c4521..de08cfd 100644 --- a/modules/smithy4s/src/main/scala/jsonrpclib/smithy4sinterop/ClientStub.scala +++ b/modules/smithy4s/src/main/scala/jsonrpclib/smithy4sinterop/ClientStub.scala @@ -10,6 +10,19 @@ import smithy4s.ShapeId object ClientStub { + /** Creates a JSON-RPC client implementation for a Smithy service. + * + * Given a Smithy `Service[Alg]` and a JSON-RPC communication `Channel[F]`, this constructs a fully functional client + * that translates method calls into JSON-RPC messages sent over the channel. + * + * Usage: + * {{{ + * val stub: MyService[IO] = ClientStub(myService, myChannel) + * val response: IO[String] = stub.hello("world") + * }}} + * + * Supports both standard request-response and fire-and-forget notification endpoints. + */ def apply[Alg[_[_, _, _, _, _]], F[_]: Monadic](service: Service[Alg], channel: Channel[F]): service.Impl[F] = new ClientStub(service, channel).compile } diff --git a/modules/smithy4s/src/main/scala/jsonrpclib/smithy4sinterop/ServerEndpoints.scala b/modules/smithy4s/src/main/scala/jsonrpclib/smithy4sinterop/ServerEndpoints.scala index a233390..40e1076 100644 --- a/modules/smithy4s/src/main/scala/jsonrpclib/smithy4sinterop/ServerEndpoints.scala +++ b/modules/smithy4s/src/main/scala/jsonrpclib/smithy4sinterop/ServerEndpoints.scala @@ -16,6 +16,19 @@ import _root_.smithy4s.{Endpoint => Smithy4sEndpoint} object ServerEndpoints { + /** Creates JSON-RPC server endpoints from a Smithy service implementation. + * + * Given a Smithy `FunctorAlgebra[Alg, F]`, this extracts all operations and compiles them into JSON-RPC + * `Endpoint[F]` handlers that can be mounted on a communication channel (e.g. `FS2Channel`). + * + * Supports both standard request-response and notification-style endpoints, as well as Smithy-modeled errors. + * + * Usage: + * {{{ + * val endpoints = ServerEndpoints(new ServerImpl) + * channel.withEndpoints(endpoints) + * }}} + */ def apply[Alg[_[_, _, _, _, _]], F[_]]( impl: FunctorAlgebra[Alg, F] )(implicit service: Service[Alg], F: Monadic[F]): List[Endpoint[F]] = { @@ -30,7 +43,21 @@ object ServerEndpoints { } } - def jsonRPCEndpoint[F[_]: Monadic, Op[_, _, _, _, _], I, E, O, SI, SO]( + /** Constructs a JSON-RPC endpoint from a Smithy endpoint definition. + * + * Translates a single Smithy4s endpoint into a JSON-RPC `Endpoint[F]`, based on the method name and interaction type + * described in `EndpointSpec`. + * + * @param smithy4sEndpoint + * The Smithy4s endpoint to expose over JSON-RPC + * @param endpointSpec + * JSON-RPC method name and interaction hints + * @param impl + * Interpreter that executes the Smithy operation in `F` + * @return + * A JSON-RPC-compatible `Endpoint[F]` + */ + private def jsonRPCEndpoint[F[_]: Monadic, Op[_, _, _, _, _], I, E, O, SI, SO]( smithy4sEndpoint: Smithy4sEndpoint[Op, I, E, O, SI, SO], endpointSpec: EndpointSpec, impl: FunctorInterpreter[Op, F] From fdb23b43a4302c5649b5d90874bbc43d5e0defc9 Mon Sep 17 00:00:00 2001 From: ghostbuster91 Date: Fri, 16 May 2025 11:08:31 +0200 Subject: [PATCH 31/47] internal error when processing notification should not break the server --- .../smithy4sinterop/TestServerSpec.scala | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/modules/smithy4sTests/src/test/scala/jsonrpclib/smithy4sinterop/TestServerSpec.scala b/modules/smithy4sTests/src/test/scala/jsonrpclib/smithy4sinterop/TestServerSpec.scala index 7e1c1a5..8b9cf1b 100644 --- a/modules/smithy4sTests/src/test/scala/jsonrpclib/smithy4sinterop/TestServerSpec.scala +++ b/modules/smithy4sTests/src/test/scala/jsonrpclib/smithy4sinterop/TestServerSpec.scala @@ -132,6 +132,34 @@ object TestServerSpec extends SimpleIOSuite { } } + testRes("internal error when processing notification should not break the server") { + implicit val greetInputEncoder: Encoder[PingInput] = CirceJsonCodec.fromSchema + + for { + ref <- SignallingRef[IO, Option[String]](none).toStream + clientSideChannel <- setup( + channel => { + val testClient = ClientStub(TestClient, channel) + AlgebraWrapper(new TestServer[IO] { + override def greet(name: String): IO[GreetOutput] = ??? + + override def ping(ping: String): IO[Unit] = { + if (ping == "fail") IO.raiseError(new RuntimeException("throwing internal error on demand")) + else testClient.pong("pong") + } + }) + }, + _ => AlgebraWrapper(new Client(ref)) + ) + remoteFunction = clientSideChannel.notificationStub[PingInput]("ping") + _ <- remoteFunction(PingInput("fail")).toStream + _ <- remoteFunction(PingInput("ping")).toStream + result <- ref.discrete.dropWhile(_.isEmpty).take(1) + } yield { + expect.same(result, "pong".some) + } + } + testRes("server returns known error") { implicit val greetInputEncoder: Encoder[GreetInput] = CirceJsonCodec.fromSchema implicit val greetOutputDecoder: Decoder[GreetOutput] = CirceJsonCodec.fromSchema From 9395f051372f952392054cc1c1083d3ffab02862 Mon Sep 17 00:00:00 2001 From: Kasper Kondzielski Date: Sat, 17 May 2025 14:05:26 +0200 Subject: [PATCH 32/47] Add smithy validations (#88) --- build.sbt | 15 ++- .../JsonNotificationOutputValidatorSpec.scala | 57 ++++++++ .../JsonRpcOperationValidatorSpec.scala | 72 ++++++++++ .../test/scala/jsonrpclib/ModelUtils.scala | 27 ++++ ...niqueJsonRpcMethodNamesValidatorSpec.scala | 123 ++++++++++++++++++ .../JsonNotificationOutputValidator.java | 32 +++++ .../validation/JsonRpcOperationValidator.java | 38 ++++++ .../UniqueJsonRpcMethodNamesValidator.java | 61 +++++++++ ...e.amazon.smithy.model.validation.Validator | 3 + .../META-INF/smithy/jsonrpclib.smithy | 4 +- .../src/main/smithy/spec.smithy | 0 .../smithy4sinterop/TestClientSpec.scala | 0 .../smithy4sinterop/TestServerSpec.scala | 2 +- 13 files changed, 430 insertions(+), 4 deletions(-) create mode 100644 modules/smithy-tests/src/test/scala/jsonrpclib/JsonNotificationOutputValidatorSpec.scala create mode 100644 modules/smithy-tests/src/test/scala/jsonrpclib/JsonRpcOperationValidatorSpec.scala create mode 100644 modules/smithy-tests/src/test/scala/jsonrpclib/ModelUtils.scala create mode 100644 modules/smithy-tests/src/test/scala/jsonrpclib/UniqueJsonRpcMethodNamesValidatorSpec.scala create mode 100644 modules/smithy/src/main/java/jsonrpclib/validation/JsonNotificationOutputValidator.java create mode 100644 modules/smithy/src/main/java/jsonrpclib/validation/JsonRpcOperationValidator.java create mode 100644 modules/smithy/src/main/java/jsonrpclib/validation/UniqueJsonRpcMethodNamesValidator.java create mode 100644 modules/smithy/src/main/resources/META-INF/services/software.amazon.smithy.model.validation.Validator rename modules/{smithy4sTests => smithy4s-tests}/src/main/smithy/spec.smithy (100%) rename modules/{smithy4sTests => smithy4s-tests}/src/test/scala/jsonrpclib/smithy4sinterop/TestClientSpec.scala (100%) rename modules/{smithy4sTests => smithy4s-tests}/src/test/scala/jsonrpclib/smithy4sinterop/TestServerSpec.scala (99%) diff --git a/build.sbt b/build.sbt index 7e8852b..2565ea9 100644 --- a/build.sbt +++ b/build.sbt @@ -102,6 +102,18 @@ val smithy = projectMatrix smithyTraitCodegenNamespace := "jsonrpclib" ) +val smithyTests = projectMatrix + .in(file("modules/smithy-tests")) + .jvmPlatform(Seq(scala213)) + .dependsOn(smithy) + .settings( + publish / skip := true, + libraryDependencies ++= Seq( + "com.disneystreaming" %%% "weaver-cats" % "0.8.4" % Test + ) + ) + .disablePlugins(MimaPlugin) + lazy val buildTimeProtocolDependency = /** By default, smithy4sInternalDependenciesAsJars doesn't contain the jars in the "smithy4s" configuration. We have * to add them manually - this is the equivalent of a "% Smithy4s"-scoped dependency. @@ -137,7 +149,7 @@ val smithy4s = projectMatrix ) val smithy4sTests = projectMatrix - .in(file("modules") / "smithy4sTests") + .in(file("modules") / "smithy4s-tests") .jvmPlatform(jvmScalaVersions, commonJvmSettings) .jsPlatform(jsScalaVersions) .nativePlatform(Seq(scala3)) @@ -251,6 +263,7 @@ val root = project exampleServer, exampleClient, smithy, + smithyTests, smithy4s, smithy4sTests, exampleSmithyShared, diff --git a/modules/smithy-tests/src/test/scala/jsonrpclib/JsonNotificationOutputValidatorSpec.scala b/modules/smithy-tests/src/test/scala/jsonrpclib/JsonNotificationOutputValidatorSpec.scala new file mode 100644 index 0000000..e93d5b9 --- /dev/null +++ b/modules/smithy-tests/src/test/scala/jsonrpclib/JsonNotificationOutputValidatorSpec.scala @@ -0,0 +1,57 @@ +package jsonrpclib + +import jsonrpclib.ModelUtils.assembleModel +import jsonrpclib.ModelUtils.eventsWithoutLocations +import software.amazon.smithy.model.shapes.ShapeId +import software.amazon.smithy.model.validation.Severity +import software.amazon.smithy.model.validation.ValidationEvent +import weaver._ + +object JsonNotificationOutputValidatorSpec extends FunSuite { + test("no error when a @jsonNotification operation has unit output") { + assembleModel( + """$version: "2" + |namespace test + | + |use jsonrpclib#jsonNotification + | + |@jsonNotification("notify") + |operation NotifySomething { + |} + |""".stripMargin + ) + success + } + test("return an error when a @jsonNotification operation does not have unit output") { + val events = eventsWithoutLocations( + assembleModel( + """$version: "2" + |namespace test + | + |use jsonrpclib#jsonNotification + | + |@jsonNotification("notify") + |operation NotifySomething { + | output:={ + | message: String + | } + |} + | + |""".stripMargin + ) + ) + + val expected = ValidationEvent + .builder() + .id("JsonNotificationOutput") + .shapeId(ShapeId.fromParts("test", "NotifySomething")) + .severity(Severity.ERROR) + .message( + "Operation marked as @jsonNotification must not return anything, but found `test#NotifySomethingOutput`." + ) + .build() + + assert(events.contains(expected)) + } + +} diff --git a/modules/smithy-tests/src/test/scala/jsonrpclib/JsonRpcOperationValidatorSpec.scala b/modules/smithy-tests/src/test/scala/jsonrpclib/JsonRpcOperationValidatorSpec.scala new file mode 100644 index 0000000..99daed7 --- /dev/null +++ b/modules/smithy-tests/src/test/scala/jsonrpclib/JsonRpcOperationValidatorSpec.scala @@ -0,0 +1,72 @@ +package jsonrpclib + +import jsonrpclib.ModelUtils.assembleModel +import jsonrpclib.ModelUtils.eventsWithoutLocations +import software.amazon.smithy.model.shapes.ShapeId +import software.amazon.smithy.model.validation.Severity +import software.amazon.smithy.model.validation.ValidationEvent +import weaver._ + +object JsonRpcOperationValidatorSpec extends FunSuite { + test("no error when all operations in @jsonRPC service are properly annotated") { + assembleModel( + """$version: "2" + |namespace test + | + |use jsonrpclib#jsonRPC + |use jsonrpclib#jsonRequest + |use jsonrpclib#jsonNotification + | + |@jsonRPC + |service MyService { + | operations: [OpA, OpB] + |} + | + |@jsonRequest("methodA") + |operation OpA {} + | + |@jsonNotification("methodB") + |operation OpB { + | output: unit + |} + |""".stripMargin + ) + success + } + + test("return an error when a @jsonRPC service has an operation without @jsonRequest or @jsonNotification") { + val events = eventsWithoutLocations( + assembleModel( + """$version: "2" + |namespace test + | + |use jsonrpclib#jsonRPC + |use jsonrpclib#jsonRequest + | + |@jsonRPC + |service MyService { + | operations: [GoodOp, BadOp] + |} + | + |@jsonRequest("good") + |operation GoodOp {} + | + |operation BadOp {} // ❌ missing jsonRequest or jsonNotification + |""".stripMargin + ) + ) + + val expected = + ValidationEvent + .builder() + .id("JsonRpcOperation") + .shapeId(ShapeId.fromParts("test", "BadOp")) + .severity(Severity.ERROR) + .message( + "Operation is part of service `test#MyService` marked with @jsonRPC but is missing @jsonRequest or @jsonNotification." + ) + .build() + + assert(events.contains(expected)) + } +} diff --git a/modules/smithy-tests/src/test/scala/jsonrpclib/ModelUtils.scala b/modules/smithy-tests/src/test/scala/jsonrpclib/ModelUtils.scala new file mode 100644 index 0000000..07ca7a4 --- /dev/null +++ b/modules/smithy-tests/src/test/scala/jsonrpclib/ModelUtils.scala @@ -0,0 +1,27 @@ +package jsonrpclib + +import software.amazon.smithy.model.validation.ValidatedResult +import software.amazon.smithy.model.validation.ValidationEvent +import software.amazon.smithy.model.Model +import software.amazon.smithy.model.SourceLocation + +import scala.jdk.CollectionConverters._ + +private object ModelUtils { + + def assembleModel(text: String): ValidatedResult[Model] = { + Model + .assembler() + .discoverModels() + .addUnparsedModel( + "test.smithy", + text + ) + .assemble() + } + + def eventsWithoutLocations(result: ValidatedResult[?]): List[ValidationEvent] = { + if (!result.isBroken) sys.error("Expected a broken result") + result.getValidationEvents.asScala.toList.map(e => e.toBuilder.sourceLocation(SourceLocation.NONE).build()) + } +} diff --git a/modules/smithy-tests/src/test/scala/jsonrpclib/UniqueJsonRpcMethodNamesValidatorSpec.scala b/modules/smithy-tests/src/test/scala/jsonrpclib/UniqueJsonRpcMethodNamesValidatorSpec.scala new file mode 100644 index 0000000..f39b053 --- /dev/null +++ b/modules/smithy-tests/src/test/scala/jsonrpclib/UniqueJsonRpcMethodNamesValidatorSpec.scala @@ -0,0 +1,123 @@ +package jsonrpclib + +import jsonrpclib.ModelUtils.assembleModel +import jsonrpclib.ModelUtils.eventsWithoutLocations +import software.amazon.smithy.model.shapes.ShapeId +import software.amazon.smithy.model.validation.Severity +import software.amazon.smithy.model.validation.ValidationEvent +import weaver._ + +object UniqueJsonRpcMethodNamesValidatorSpec extends FunSuite { + test("no error when all jsonRpc method names are unique within a service") { + + assembleModel( + """$version: "2" + |namespace test + | + |use jsonrpclib#jsonRPC + |use jsonrpclib#jsonRequest + |use jsonrpclib#jsonNotification + | + |@jsonRPC + |service MyService { + | operations: [OpA, OpB] + |} + | + |@jsonRequest("foo") + |operation OpA {} + | + |@jsonNotification("bar") + |operation OpB {} + |""".stripMargin + ).unwrap() + + success + } + test("return an error when two operations use the same jsonRpc method name in a service") { + val events = eventsWithoutLocations( + assembleModel( + """$version: "2" + |namespace test + | + |use jsonrpclib#jsonRPC + |use jsonrpclib#jsonRequest + |use jsonrpclib#jsonNotification + | + |@jsonRPC + |service MyService { + | operations: [OpA, OpB] + |} + | + |@jsonRequest("foo") + |operation OpA {} + | + |@jsonNotification("foo") + |operation OpB {} // duplicate method name "foo" + |""".stripMargin + ) + ) + + val expected = ValidationEvent + .builder() + .id("UniqueJsonRpcMethodNames") + .shapeId(ShapeId.fromParts("test", "MyService")) + .severity(Severity.ERROR) + .message( + "Duplicate JSON-RPC method name `foo` in service `test#MyService`. It is used by: test#OpA, test#OpB" + ) + .build() + + assert(events.contains(expected)) + } + + test("no error if two services use the same operation") { + assembleModel( + """$version: "2" + |namespace test + | + |use jsonrpclib#jsonRPC + |use jsonrpclib#jsonRequest + |use jsonrpclib#jsonNotification + | + |@jsonRPC + |service MyService { + | operations: [OpA] + |} + | + |@jsonRPC + |service MyOtherService { + | operations: [OpA] + |} + | + |@jsonRequest("foo") + |operation OpA {} + | + |""".stripMargin + ).unwrap() + success + } + + test("no error if two services use the same operation") { + assembleModel( + """$version: "2" + |namespace test + | + |use jsonrpclib#jsonRequest + |use jsonrpclib#jsonNotification + | + | + |service NonJsonRpcService { + | operations: [OpA] + |} + | + |@jsonRequest("foo") + |operation OpA {} + | + |@jsonNotification("foo") + |operation OpB {} // duplicate method name "foo" + |""".stripMargin + ).unwrap() + success + } + +} diff --git a/modules/smithy/src/main/java/jsonrpclib/validation/JsonNotificationOutputValidator.java b/modules/smithy/src/main/java/jsonrpclib/validation/JsonNotificationOutputValidator.java new file mode 100644 index 0000000..1a47a46 --- /dev/null +++ b/modules/smithy/src/main/java/jsonrpclib/validation/JsonNotificationOutputValidator.java @@ -0,0 +1,32 @@ +package jsonrpclib.validation; + +import jsonrpclib.JsonNotificationTrait; +import software.amazon.smithy.model.Model; +import software.amazon.smithy.model.shapes.ShapeId; +import software.amazon.smithy.model.validation.AbstractValidator; +import software.amazon.smithy.model.validation.ValidationEvent; + +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +/** + * Validates that operations marked with @jsonNotification don't have any + * output. + */ +public class JsonNotificationOutputValidator extends AbstractValidator { + + @Override + public List validate(Model model) { + return model.getShapesWithTrait(JsonNotificationTrait.ID).stream().flatMap(op -> { + ShapeId outputShapeId = op.asOperationShape().orElseThrow().getOutputShape(); + var outputShape = model.expectShape(outputShapeId); + if (outputShape.asStructureShape().map(s -> !s.members().isEmpty()).orElse(true)) { + return Stream.of(error(op, String.format( + "Operation marked as @jsonNotification must not return anything, but found `%s`.", outputShapeId))); + } else { + return Stream.empty(); + } + }).collect(Collectors.toUnmodifiableList()); + } +} diff --git a/modules/smithy/src/main/java/jsonrpclib/validation/JsonRpcOperationValidator.java b/modules/smithy/src/main/java/jsonrpclib/validation/JsonRpcOperationValidator.java new file mode 100644 index 0000000..4e3a298 --- /dev/null +++ b/modules/smithy/src/main/java/jsonrpclib/validation/JsonRpcOperationValidator.java @@ -0,0 +1,38 @@ +package jsonrpclib.validation; + +import jsonrpclib.JsonNotificationTrait; +import jsonrpclib.JsonRPCTrait; +import jsonrpclib.JsonRequestTrait; +import software.amazon.smithy.model.Model; +import software.amazon.smithy.model.shapes.ServiceShape; +import software.amazon.smithy.model.shapes.Shape; +import software.amazon.smithy.model.validation.AbstractValidator; +import software.amazon.smithy.model.validation.ValidationEvent; + +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +public class JsonRpcOperationValidator extends AbstractValidator { + + @Override + public List validate(Model model) { + return model.getServiceShapes().stream() + .filter(service -> service.hasTrait(JsonRPCTrait.class)) + .flatMap(service -> validateService(model, service)) + .collect(Collectors.toList()); + } + + private Stream validateService(Model model, ServiceShape service) { + return service.getAllOperations().stream() + .map(model::expectShape) + .filter(op -> !hasJsonRpcMethod(op)) + .map(op -> error(op, String.format( + "Operation is part of service `%s` marked with @jsonRPC but is missing @jsonRequest or @jsonNotification.", service.getId()))); + } + + private boolean hasJsonRpcMethod(Shape op) { + return op.hasTrait(JsonRequestTrait.ID) || op.hasTrait(JsonNotificationTrait.ID); + } +} + diff --git a/modules/smithy/src/main/java/jsonrpclib/validation/UniqueJsonRpcMethodNamesValidator.java b/modules/smithy/src/main/java/jsonrpclib/validation/UniqueJsonRpcMethodNamesValidator.java new file mode 100644 index 0000000..592ab2d --- /dev/null +++ b/modules/smithy/src/main/java/jsonrpclib/validation/UniqueJsonRpcMethodNamesValidator.java @@ -0,0 +1,61 @@ +package jsonrpclib.validation; + +import jsonrpclib.JsonNotificationTrait; +import jsonrpclib.JsonRPCTrait; +import jsonrpclib.JsonRequestTrait; +import software.amazon.smithy.model.Model; +import software.amazon.smithy.model.shapes.OperationShape; +import software.amazon.smithy.model.shapes.ServiceShape; +import software.amazon.smithy.model.traits.StringTrait; +import software.amazon.smithy.model.validation.AbstractValidator; +import software.amazon.smithy.model.validation.ValidationEvent; + +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +public class UniqueJsonRpcMethodNamesValidator extends AbstractValidator { + + @Override + public List validate(Model model) { + return model.getShapesWithTrait(JsonRPCTrait.class).stream() + .flatMap(service -> validateService(service.asServiceShape().orElseThrow(), model)) + .collect(Collectors.toList()); + } + + private Stream validateService(ServiceShape service, Model model) { + Map> methodsToOps = service.getAllOperations().stream() + .map(model::expectShape) + .map(shape -> shape.asOperationShape().orElseThrow()) + .flatMap(op -> getJsonRpcMethodName(op).map(name -> Map.entry(name, op)).stream()) + .collect(Collectors.groupingBy( + Map.Entry::getKey, + Collectors.mapping(Map.Entry::getValue, Collectors.toList()) + )); + + // Emit a validation error for each method name that occurs more than once + return methodsToOps.entrySet().stream() + .filter(entry -> entry.getValue().size() > 1) + .flatMap(entry -> entry.getValue().stream() + .map(op -> + error(service, String.format( + "Duplicate JSON-RPC method name `%s` in service `%s`. It is used by: %s", + entry.getKey(), + service.getId(), + entry.getValue().stream() + .map(OperationShape::getId) + .map(Object::toString) + .collect(Collectors.joining(", ")) + ))) + ); + } + + private Optional getJsonRpcMethodName(OperationShape operation) { + return operation.getTrait(JsonRequestTrait.class) + .map(StringTrait::getValue) + .or(() -> operation.getTrait(JsonNotificationTrait.class).map(StringTrait::getValue)); + } +} + diff --git a/modules/smithy/src/main/resources/META-INF/services/software.amazon.smithy.model.validation.Validator b/modules/smithy/src/main/resources/META-INF/services/software.amazon.smithy.model.validation.Validator new file mode 100644 index 0000000..18410f9 --- /dev/null +++ b/modules/smithy/src/main/resources/META-INF/services/software.amazon.smithy.model.validation.Validator @@ -0,0 +1,3 @@ +jsonrpclib.validation.JsonNotificationOutputValidator +jsonrpclib.validation.UniqueJsonRpcMethodNamesValidator +jsonrpclib.validation.JsonRpcOperationValidator \ No newline at end of file diff --git a/modules/smithy/src/main/resources/META-INF/smithy/jsonrpclib.smithy b/modules/smithy/src/main/resources/META-INF/smithy/jsonrpclib.smithy index 93cbac2..8969df0 100644 --- a/modules/smithy/src/main/resources/META-INF/smithy/jsonrpclib.smithy +++ b/modules/smithy/src/main/resources/META-INF/smithy/jsonrpclib.smithy @@ -24,10 +24,10 @@ structure jsonRPC { /// Identifies an operation that abides by request/response semantics /// https://www.jsonrpc.org/specification#request_object -@trait(selector: "operation") +@trait(selector: "operation", conflicts: [jsonNotification]) string jsonRequest /// Identifies an operation that abides by fire-and-forget semantics /// see https://www.jsonrpc.org/specification#notification -@trait(selector: "operation") +@trait(selector: "operation", conflicts: [jsonRequest]) string jsonNotification diff --git a/modules/smithy4sTests/src/main/smithy/spec.smithy b/modules/smithy4s-tests/src/main/smithy/spec.smithy similarity index 100% rename from modules/smithy4sTests/src/main/smithy/spec.smithy rename to modules/smithy4s-tests/src/main/smithy/spec.smithy diff --git a/modules/smithy4sTests/src/test/scala/jsonrpclib/smithy4sinterop/TestClientSpec.scala b/modules/smithy4s-tests/src/test/scala/jsonrpclib/smithy4sinterop/TestClientSpec.scala similarity index 100% rename from modules/smithy4sTests/src/test/scala/jsonrpclib/smithy4sinterop/TestClientSpec.scala rename to modules/smithy4s-tests/src/test/scala/jsonrpclib/smithy4sinterop/TestClientSpec.scala diff --git a/modules/smithy4sTests/src/test/scala/jsonrpclib/smithy4sinterop/TestServerSpec.scala b/modules/smithy4s-tests/src/test/scala/jsonrpclib/smithy4sinterop/TestServerSpec.scala similarity index 99% rename from modules/smithy4sTests/src/test/scala/jsonrpclib/smithy4sinterop/TestServerSpec.scala rename to modules/smithy4s-tests/src/test/scala/jsonrpclib/smithy4sinterop/TestServerSpec.scala index 8b9cf1b..2d81d7f 100644 --- a/modules/smithy4sTests/src/test/scala/jsonrpclib/smithy4sinterop/TestServerSpec.scala +++ b/modules/smithy4s-tests/src/test/scala/jsonrpclib/smithy4sinterop/TestServerSpec.scala @@ -92,7 +92,7 @@ object TestServerSpec extends SimpleIOSuite { .concurrently(clientChannelWithEndpoints.output.through(serverChannelWithEndpoints.input)) .concurrently(serverChannelWithEndpoints.output.through(clientChannelWithEndpoints.input)) } yield { - clientSideChannel + clientChannelWithEndpoints } } From a76d283adbc8b1c54cdbef18f485d5ab9178db23 Mon Sep 17 00:00:00 2001 From: ghostbuster91 Date: Mon, 26 May 2025 14:15:23 +0200 Subject: [PATCH 33/47] Fix RawMessage decoder --- .../src/main/scala/jsonrpclib/internals/RawMessage.scala | 7 ++++--- .../core/src/test/scala/jsonrpclib/RawMessageSpec.scala | 9 +++++++++ 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/modules/core/src/main/scala/jsonrpclib/internals/RawMessage.scala b/modules/core/src/main/scala/jsonrpclib/internals/RawMessage.scala index 5ee6337..1b40daf 100644 --- a/modules/core/src/main/scala/jsonrpclib/internals/RawMessage.scala +++ b/modules/core/src/main/scala/jsonrpclib/internals/RawMessage.scala @@ -84,9 +84,10 @@ private[jsonrpclib] object RawMessage { 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) + c.downField("result") + .success + .map(_.as[Option[Payload]].map(Some(_))) + .getOrElse(Right(None)) } yield RawMessage(jsonrpc, method, resultOpt, error, params, id) } } diff --git a/modules/core/src/test/scala/jsonrpclib/RawMessageSpec.scala b/modules/core/src/test/scala/jsonrpclib/RawMessageSpec.scala index 1fe0f41..0771b79 100644 --- a/modules/core/src/test/scala/jsonrpclib/RawMessageSpec.scala +++ b/modules/core/src/test/scala/jsonrpclib/RawMessageSpec.scala @@ -57,6 +57,15 @@ object RawMessageSpec extends FunSuite { assert(result == expected, s"Expected: $expected, got: $result") } + test("response message serialization with nested results") { + val input: Message = + OutputMessage.ResponseMessage(CallId.NumberId(1), Payload(Json.obj("result" -> Json.fromInt(1)))) + val expected = """{"jsonrpc":"2.0","id":1,"result":{"result":1}}""" + val result = writeToString(input.asJson) + + assert(result == expected, s"Expected: $expected, got: $result") + } + test("error message serialization") { val input: Message = OutputMessage.ErrorMessage( CallId.NumberId(1), From c3546cc1edb0045093703b87d710dd688fce5cfe Mon Sep 17 00:00:00 2001 From: Kasper Kondzielski Date: Wed, 28 May 2025 18:47:56 +0200 Subject: [PATCH 34/47] feat: Add jsonPayload trait (#89) * feat: Add jsonPayload trait * Remove useless comments * Add tests for JsonPayloadValidator * Transform errors as well * Use traitValidator --------- Co-authored-by: ghostbuster91 --- .../jsonrpclib/JsonPayloadValidatorSpec.scala | 99 +++++++++++++++++++ ...e.amazon.smithy.model.validation.Validator | 2 +- .../META-INF/smithy/jsonrpclib.smithy | 14 +++ .../src/main/smithy/spec.smithy | 31 ++++++ .../smithy4sinterop/TestClientSpec.scala | 20 +++- .../smithy4sinterop/TestServerSpec.scala | 31 +++--- .../smithy4sinterop/ClientStub.scala | 2 +- .../JsonPayloadTransformation.scala | 21 ++++ .../JsonRpcTransformations.scala | 33 +++++++ .../smithy4sinterop/ServerEndpoints.scala | 5 +- project/plugins.sbt | 2 +- 11 files changed, 240 insertions(+), 20 deletions(-) create mode 100644 modules/smithy-tests/src/test/scala/jsonrpclib/JsonPayloadValidatorSpec.scala create mode 100644 modules/smithy4s/src/main/scala/jsonrpclib/smithy4sinterop/JsonPayloadTransformation.scala create mode 100644 modules/smithy4s/src/main/scala/jsonrpclib/smithy4sinterop/JsonRpcTransformations.scala diff --git a/modules/smithy-tests/src/test/scala/jsonrpclib/JsonPayloadValidatorSpec.scala b/modules/smithy-tests/src/test/scala/jsonrpclib/JsonPayloadValidatorSpec.scala new file mode 100644 index 0000000..e406833 --- /dev/null +++ b/modules/smithy-tests/src/test/scala/jsonrpclib/JsonPayloadValidatorSpec.scala @@ -0,0 +1,99 @@ +package jsonrpclib + +import jsonrpclib.ModelUtils.assembleModel +import jsonrpclib.ModelUtils.eventsWithoutLocations +import software.amazon.smithy.model.shapes.ShapeId +import software.amazon.smithy.model.validation.Severity +import software.amazon.smithy.model.validation.ValidationEvent +import weaver._ + +object JsonPayloadValidatorSpec extends FunSuite { + test("no error when jsonPayload is used on the input, output or error structure's member") { + + assembleModel( + """$version: "2" + |namespace test + | + |use jsonrpclib#jsonRPC + |use jsonrpclib#jsonRequest + |use jsonrpclib#jsonPayload + | + |@jsonRPC + |service MyService { + | operations: [OpA] + |} + | + |@jsonRequest("foo") + |operation OpA { + | input: OpInput + | output: OpOutput + | errors: [OpError] + |} + | + |structure OpInput { + | @jsonPayload + | data: String + |} + | + |structure OpOutput { + | @jsonPayload + | data: String + |} + | + |@error("client") + |structure OpError { + | @jsonPayload + | data: String + |} + | + |""".stripMargin + ).unwrap() + + success + } + test("return an error when jsonPayload is used in a nested structure") { + val events = eventsWithoutLocations( + assembleModel( + """$version: "2" + |namespace test + | + |use jsonrpclib#jsonRPC + |use jsonrpclib#jsonRequest + |use jsonrpclib#jsonPayload + | + |@jsonRPC + |service MyService { + | operations: [OpA] + |} + | + |@jsonRequest("foo") + |operation OpA { + | input: OpInput + |} + | + |structure OpInput { + | data: NestedStructure + |} + | + |structure NestedStructure { + | @jsonPayload + | data: String + |} + |""".stripMargin + ) + ) + + val expected = ValidationEvent + .builder() + .id("jsonPayload.OnlyTopLevel") + .shapeId(ShapeId.fromParts("test", "NestedStructure", "data")) + .severity(Severity.ERROR) + .message( + "Found an incompatible shape when validating the constraints of the `jsonrpclib#jsonPayload` trait attached to `test#NestedStructure$data`: jsonPayload can only be used on the top level of an operation input/output/error." + ) + .build() + + assert(events.contains(expected)) + } + +} diff --git a/modules/smithy/src/main/resources/META-INF/services/software.amazon.smithy.model.validation.Validator b/modules/smithy/src/main/resources/META-INF/services/software.amazon.smithy.model.validation.Validator index 18410f9..712ec39 100644 --- a/modules/smithy/src/main/resources/META-INF/services/software.amazon.smithy.model.validation.Validator +++ b/modules/smithy/src/main/resources/META-INF/services/software.amazon.smithy.model.validation.Validator @@ -1,3 +1,3 @@ jsonrpclib.validation.JsonNotificationOutputValidator jsonrpclib.validation.UniqueJsonRpcMethodNamesValidator -jsonrpclib.validation.JsonRpcOperationValidator \ No newline at end of file +jsonrpclib.validation.JsonRpcOperationValidator diff --git a/modules/smithy/src/main/resources/META-INF/smithy/jsonrpclib.smithy b/modules/smithy/src/main/resources/META-INF/smithy/jsonrpclib.smithy index 8969df0..015d53e 100644 --- a/modules/smithy/src/main/resources/META-INF/smithy/jsonrpclib.smithy +++ b/modules/smithy/src/main/resources/META-INF/smithy/jsonrpclib.smithy @@ -7,6 +7,7 @@ namespace jsonrpclib @protocolDefinition(traits: [ jsonRequest jsonNotification + jsonPayload smithy.api#jsonName smithy.api#length smithy.api#pattern @@ -31,3 +32,16 @@ string jsonRequest /// see https://www.jsonrpc.org/specification#notification @trait(selector: "operation", conflicts: [jsonRequest]) string jsonNotification + + +/// Binds a single structure member to the payload of a jsonrpc message. +/// Just like @httpPayload, but for jsonRPC. +@trait(selector: "structure > member", structurallyExclusive: "member") +@traitValidators({ + "jsonPayload.OnlyTopLevel": { + message: "jsonPayload can only be used on the top level of an operation input/output/error.", + severity: "ERROR", + selector: "$allowedShapes(:root(operation -[input, output, error]-> structure > member)) :not(:in(${allowedShapes}))" + } +}) +structure jsonPayload {} diff --git a/modules/smithy4s-tests/src/main/smithy/spec.smithy b/modules/smithy4s-tests/src/main/smithy/spec.smithy index eb4d1b7..e88d378 100644 --- a/modules/smithy4s-tests/src/main/smithy/spec.smithy +++ b/modules/smithy4s-tests/src/main/smithy/spec.smithy @@ -5,6 +5,7 @@ namespace test use jsonrpclib#jsonNotification use jsonrpclib#jsonRPC use jsonrpclib#jsonRequest +use jsonrpclib#jsonPayload @jsonRPC service TestServer { @@ -29,6 +30,36 @@ operation Greet { errors: [NotWelcomeError] } + +@jsonRPC +service TestServerWithPayload { + operations: [GreetWithPayload] +} + +@jsonRequest("greetWithPayload") +operation GreetWithPayload { + input := { + @required + @jsonPayload + payload: GreetInputPayload + } + output := { + @required + @jsonPayload + payload: GreetOutputPayload + } +} + +structure GreetInputPayload { + @required + name: String +} + +structure GreetOutputPayload { + @required + message: String +} + @error("client") structure NotWelcomeError { @required diff --git a/modules/smithy4s-tests/src/test/scala/jsonrpclib/smithy4sinterop/TestClientSpec.scala b/modules/smithy4s-tests/src/test/scala/jsonrpclib/smithy4sinterop/TestClientSpec.scala index 47152f4..3592771 100644 --- a/modules/smithy4s-tests/src/test/scala/jsonrpclib/smithy4sinterop/TestClientSpec.scala +++ b/modules/smithy4s-tests/src/test/scala/jsonrpclib/smithy4sinterop/TestClientSpec.scala @@ -7,10 +7,7 @@ import io.circe.Decoder import io.circe.Encoder import jsonrpclib._ import jsonrpclib.fs2._ -import test.GreetInput -import test.GreetOutput -import test.PingInput -import test.TestServer +import test._ import weaver._ import scala.concurrent.duration._ @@ -66,4 +63,19 @@ object TestClientSpec extends SimpleIOSuite { expect.same(result, Some(PingInput("hello"))) } } + + testRes("Round trip with jsonPayload") { + implicit val greetInputDecoder: Decoder[GreetInput] = CirceJsonCodec.fromSchema + implicit val greetOutputEncoder: Encoder[GreetOutput] = CirceJsonCodec.fromSchema + val endpoint: Endpoint[IO] = + Endpoint[IO]("greetWithPayload").simple[GreetInput, GreetOutput](in => IO(GreetOutput(s"Hello ${in.name}"))) + + for { + clientSideChannel <- setup(endpoint) + clientStub = ClientStub(TestServerWithPayload, clientSideChannel) + result <- clientStub.greetWithPayload(GreetInputPayload("Bob")).toStream + } yield { + expect.same(result.payload.message, "Hello Bob") + } + } } diff --git a/modules/smithy4s-tests/src/test/scala/jsonrpclib/smithy4sinterop/TestServerSpec.scala b/modules/smithy4s-tests/src/test/scala/jsonrpclib/smithy4sinterop/TestServerSpec.scala index 2d81d7f..88ae345 100644 --- a/modules/smithy4s-tests/src/test/scala/jsonrpclib/smithy4sinterop/TestServerSpec.scala +++ b/modules/smithy4s-tests/src/test/scala/jsonrpclib/smithy4sinterop/TestServerSpec.scala @@ -12,17 +12,8 @@ import jsonrpclib.Monadic import jsonrpclib.Payload import smithy4s.kinds.FunctorAlgebra import smithy4s.Service -import test.GetWeatherInput -import test.GetWeatherOutput -import test.GreetInput -import test.GreetOutput -import test.NotWelcomeError -import test.PingInput -import test.TestClient -import test.TestServer -import test.TestServerOperation -import test.TestServerOperation.GreetError -import test.WeatherService +import test._ +import test.TestServerOperation._ import weaver._ import scala.concurrent.duration._ @@ -241,4 +232,22 @@ object TestServerSpec extends SimpleIOSuite { expect.same(getWeatherResult.weather, "sunny") } } + + testRes("Round trip with jsonPayload") { + implicit val greetInputEncoder: Encoder[GreetInput] = CirceJsonCodec.fromSchema + implicit val greetOutputDecoder: Decoder[GreetOutput] = CirceJsonCodec.fromSchema + + object ServerImpl extends TestServerWithPayload[IO] { + def greetWithPayload(payload: GreetInputPayload): IO[GreetWithPayloadOutput] = + IO.pure(GreetWithPayloadOutput(GreetOutputPayload(s"Hello ${payload.name}"))) + } + + for { + clientSideChannel <- setup(_ => AlgebraWrapper(ServerImpl)) + remoteFunction = clientSideChannel.simpleStub[GreetInput, GreetOutput]("greetWithPayload") + result <- remoteFunction(GreetInput("Bob")).toStream + } yield { + expect.same(result.message, "Hello Bob") + } + } } diff --git a/modules/smithy4s/src/main/scala/jsonrpclib/smithy4sinterop/ClientStub.scala b/modules/smithy4s/src/main/scala/jsonrpclib/smithy4sinterop/ClientStub.scala index de08cfd..4f2de95 100644 --- a/modules/smithy4s/src/main/scala/jsonrpclib/smithy4sinterop/ClientStub.scala +++ b/modules/smithy4s/src/main/scala/jsonrpclib/smithy4sinterop/ClientStub.scala @@ -24,7 +24,7 @@ object ClientStub { * Supports both standard request-response and fire-and-forget notification endpoints. */ def apply[Alg[_[_, _, _, _, _]], F[_]: Monadic](service: Service[Alg], channel: Channel[F]): service.Impl[F] = - new ClientStub(service, channel).compile + new ClientStub(JsonRpcTransformations.apply(service), channel).compile } private class ClientStub[Alg[_[_, _, _, _, _]], F[_]: Monadic](val service: Service[Alg], channel: Channel[F]) { diff --git a/modules/smithy4s/src/main/scala/jsonrpclib/smithy4sinterop/JsonPayloadTransformation.scala b/modules/smithy4s/src/main/scala/jsonrpclib/smithy4sinterop/JsonPayloadTransformation.scala new file mode 100644 index 0000000..0a1a1ca --- /dev/null +++ b/modules/smithy4s/src/main/scala/jsonrpclib/smithy4sinterop/JsonPayloadTransformation.scala @@ -0,0 +1,21 @@ +package jsonrpclib.smithy4sinterop + +import jsonrpclib.JsonPayload +import smithy4s.~> +import smithy4s.Schema +import smithy4s.Schema.StructSchema + +private[jsonrpclib] object JsonPayloadTransformation extends (Schema ~> Schema) { + + def apply[A0](fa: Schema[A0]): Schema[A0] = + fa match { + case struct: StructSchema[b] => + struct.fields + .collectFirst { + case field if field.hints.has[JsonPayload] => + field.schema.biject[b]((f: Any) => struct.make(Vector(f)))(field.get) + } + .getOrElse(fa) + case _ => fa + } +} diff --git a/modules/smithy4s/src/main/scala/jsonrpclib/smithy4sinterop/JsonRpcTransformations.scala b/modules/smithy4s/src/main/scala/jsonrpclib/smithy4sinterop/JsonRpcTransformations.scala new file mode 100644 index 0000000..4ff844d --- /dev/null +++ b/modules/smithy4s/src/main/scala/jsonrpclib/smithy4sinterop/JsonRpcTransformations.scala @@ -0,0 +1,33 @@ +package jsonrpclib.smithy4sinterop + +import smithy4s.~> +import smithy4s.schema.ErrorSchema +import smithy4s.schema.OperationSchema +import smithy4s.Endpoint +import smithy4s.Schema +import smithy4s.Service + +private[jsonrpclib] object JsonRpcTransformations { + + def apply[Alg[_[_, _, _, _, _]]]: Service[Alg] => Service[Alg] = + _.toBuilder + .mapEndpointEach( + Endpoint.mapSchema( + OperationSchema + .mapInputK(JsonPayloadTransformation) + .andThen(OperationSchema.mapOutputK(JsonPayloadTransformation)) + .andThen(OperationSchema.mapErrorK(errorTransformation)) + ) + ) + .build + + private val payloadTransformation: Schema ~> Schema = Schema + .transformTransitivelyK(JsonPayloadTransformation) + + private val errorTransformation: ErrorSchema ~> ErrorSchema = + new smithy4s.kinds.PolyFunction[ErrorSchema, ErrorSchema] { + def apply[A](e: ErrorSchema[A]): ErrorSchema[A] = { + payloadTransformation(e.schema).error(e.unliftError)(e.liftError.unlift) + } + } +} diff --git a/modules/smithy4s/src/main/scala/jsonrpclib/smithy4sinterop/ServerEndpoints.scala b/modules/smithy4s/src/main/scala/jsonrpclib/smithy4sinterop/ServerEndpoints.scala index 40e1076..f4e375b 100644 --- a/modules/smithy4s/src/main/scala/jsonrpclib/smithy4sinterop/ServerEndpoints.scala +++ b/modules/smithy4s/src/main/scala/jsonrpclib/smithy4sinterop/ServerEndpoints.scala @@ -32,8 +32,9 @@ object ServerEndpoints { def apply[Alg[_[_, _, _, _, _]], F[_]]( impl: FunctorAlgebra[Alg, F] )(implicit service: Service[Alg], F: Monadic[F]): List[Endpoint[F]] = { - val interpreter: service.FunctorInterpreter[F] = service.toPolyFunction(impl) - service.endpoints.toList.flatMap { smithy4sEndpoint => + val transformedService = JsonRpcTransformations.apply(service) + val interpreter: transformedService.FunctorInterpreter[F] = transformedService.toPolyFunction(impl) + transformedService.endpoints.toList.flatMap { smithy4sEndpoint => EndpointSpec .fromHints(smithy4sEndpoint.hints) .map { endpointSpec => diff --git a/project/plugins.sbt b/project/plugins.sbt index 399d7d0..225c2ae 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -14,6 +14,6 @@ addSbtPlugin("com.eed3si9n" % "sbt-assembly" % "2.3.1") addSbtPlugin("org.scalameta" % "sbt-scalafmt" % "2.5.4") -addSbtPlugin("com.disneystreaming.smithy4s" % "smithy4s-sbt-codegen" % "0.18.35") +addSbtPlugin("com.disneystreaming.smithy4s" % "smithy4s-sbt-codegen" % "0.18.36") addDependencyTreePlugin From e168783d984cf5b594f6658d31982a73d6f66ae8 Mon Sep 17 00:00:00 2001 From: ghostbuster91 Date: Thu, 29 May 2025 22:13:07 +0200 Subject: [PATCH 35/47] Rename traits to include Rpc in their names --- .../smithyShared/src/main/smithy/spec.smithy | 16 +++++----- .../JsonNotificationOutputValidator.java | 4 +-- .../validation/JsonRpcOperationValidator.java | 10 +++---- .../UniqueJsonRpcMethodNamesValidator.java | 12 ++++---- .../META-INF/smithy/jsonrpclib.smithy | 24 +++++++-------- .../src/main/smithy/spec.smithy | 30 +++++++++---------- .../smithy4sinterop/EndpointSpec.scala | 6 ++-- .../JsonPayloadTransformation.scala | 4 +-- 8 files changed, 53 insertions(+), 53 deletions(-) diff --git a/modules/examples/smithyShared/src/main/smithy/spec.smithy b/modules/examples/smithyShared/src/main/smithy/spec.smithy index 518c745..905eb1d 100644 --- a/modules/examples/smithyShared/src/main/smithy/spec.smithy +++ b/modules/examples/smithyShared/src/main/smithy/spec.smithy @@ -2,21 +2,21 @@ $version: "2.0" namespace test -use jsonrpclib#jsonRequest -use jsonrpclib#jsonRPC -use jsonrpclib#jsonNotification +use jsonrpclib#jsonRpcRequest +use jsonrpclib#jsonRpc +use jsonrpclib#jsonRpcNotification -@jsonRPC +@jsonRpc service TestServer { operations: [Greet, Ping] } -@jsonRPC +@jsonRpc service TestClient { operations: [Pong] } -@jsonRequest("greet") +@jsonRpcRequest("greet") operation Greet { input := { @required @@ -28,7 +28,7 @@ operation Greet { } } -@jsonNotification("ping") +@jsonRpcNotification("ping") operation Ping { input := { @required @@ -36,7 +36,7 @@ operation Ping { } } -@jsonNotification("pong") +@jsonRpcNotification("pong") operation Pong { input := { @required diff --git a/modules/smithy/src/main/java/jsonrpclib/validation/JsonNotificationOutputValidator.java b/modules/smithy/src/main/java/jsonrpclib/validation/JsonNotificationOutputValidator.java index 1a47a46..4ece264 100644 --- a/modules/smithy/src/main/java/jsonrpclib/validation/JsonNotificationOutputValidator.java +++ b/modules/smithy/src/main/java/jsonrpclib/validation/JsonNotificationOutputValidator.java @@ -1,6 +1,6 @@ package jsonrpclib.validation; -import jsonrpclib.JsonNotificationTrait; +import jsonrpclib.JsonRpcNotificationTrait; import software.amazon.smithy.model.Model; import software.amazon.smithy.model.shapes.ShapeId; import software.amazon.smithy.model.validation.AbstractValidator; @@ -18,7 +18,7 @@ public class JsonNotificationOutputValidator extends AbstractValidator { @Override public List validate(Model model) { - return model.getShapesWithTrait(JsonNotificationTrait.ID).stream().flatMap(op -> { + return model.getShapesWithTrait(JsonRpcNotificationTrait.ID).stream().flatMap(op -> { ShapeId outputShapeId = op.asOperationShape().orElseThrow().getOutputShape(); var outputShape = model.expectShape(outputShapeId); if (outputShape.asStructureShape().map(s -> !s.members().isEmpty()).orElse(true)) { diff --git a/modules/smithy/src/main/java/jsonrpclib/validation/JsonRpcOperationValidator.java b/modules/smithy/src/main/java/jsonrpclib/validation/JsonRpcOperationValidator.java index 4e3a298..0968e06 100644 --- a/modules/smithy/src/main/java/jsonrpclib/validation/JsonRpcOperationValidator.java +++ b/modules/smithy/src/main/java/jsonrpclib/validation/JsonRpcOperationValidator.java @@ -1,8 +1,8 @@ package jsonrpclib.validation; -import jsonrpclib.JsonNotificationTrait; -import jsonrpclib.JsonRPCTrait; -import jsonrpclib.JsonRequestTrait; +import jsonrpclib.JsonRpcNotificationTrait; +import jsonrpclib.JsonRpcTrait; +import jsonrpclib.JsonRpcRequestTrait; import software.amazon.smithy.model.Model; import software.amazon.smithy.model.shapes.ServiceShape; import software.amazon.smithy.model.shapes.Shape; @@ -18,7 +18,7 @@ public class JsonRpcOperationValidator extends AbstractValidator { @Override public List validate(Model model) { return model.getServiceShapes().stream() - .filter(service -> service.hasTrait(JsonRPCTrait.class)) + .filter(service -> service.hasTrait(JsonRpcTrait.class)) .flatMap(service -> validateService(model, service)) .collect(Collectors.toList()); } @@ -32,7 +32,7 @@ private Stream validateService(Model model, ServiceShape servic } private boolean hasJsonRpcMethod(Shape op) { - return op.hasTrait(JsonRequestTrait.ID) || op.hasTrait(JsonNotificationTrait.ID); + return op.hasTrait(JsonRpcRequestTrait.ID) || op.hasTrait(JsonRpcNotificationTrait.ID); } } diff --git a/modules/smithy/src/main/java/jsonrpclib/validation/UniqueJsonRpcMethodNamesValidator.java b/modules/smithy/src/main/java/jsonrpclib/validation/UniqueJsonRpcMethodNamesValidator.java index 592ab2d..5ad1c6d 100644 --- a/modules/smithy/src/main/java/jsonrpclib/validation/UniqueJsonRpcMethodNamesValidator.java +++ b/modules/smithy/src/main/java/jsonrpclib/validation/UniqueJsonRpcMethodNamesValidator.java @@ -1,8 +1,8 @@ package jsonrpclib.validation; -import jsonrpclib.JsonNotificationTrait; -import jsonrpclib.JsonRPCTrait; -import jsonrpclib.JsonRequestTrait; +import jsonrpclib.JsonRpcNotificationTrait; +import jsonrpclib.JsonRpcTrait; +import jsonrpclib.JsonRpcRequestTrait; import software.amazon.smithy.model.Model; import software.amazon.smithy.model.shapes.OperationShape; import software.amazon.smithy.model.shapes.ServiceShape; @@ -20,7 +20,7 @@ public class UniqueJsonRpcMethodNamesValidator extends AbstractValidator { @Override public List validate(Model model) { - return model.getShapesWithTrait(JsonRPCTrait.class).stream() + return model.getShapesWithTrait(JsonRpcTrait.class).stream() .flatMap(service -> validateService(service.asServiceShape().orElseThrow(), model)) .collect(Collectors.toList()); } @@ -53,9 +53,9 @@ private Stream validateService(ServiceShape service, Model mode } private Optional getJsonRpcMethodName(OperationShape operation) { - return operation.getTrait(JsonRequestTrait.class) + return operation.getTrait(JsonRpcRequestTrait.class) .map(StringTrait::getValue) - .or(() -> operation.getTrait(JsonNotificationTrait.class).map(StringTrait::getValue)); + .or(() -> operation.getTrait(JsonRpcNotificationTrait.class).map(StringTrait::getValue)); } } diff --git a/modules/smithy/src/main/resources/META-INF/smithy/jsonrpclib.smithy b/modules/smithy/src/main/resources/META-INF/smithy/jsonrpclib.smithy index 015d53e..6303ce1 100644 --- a/modules/smithy/src/main/resources/META-INF/smithy/jsonrpclib.smithy +++ b/modules/smithy/src/main/resources/META-INF/smithy/jsonrpclib.smithy @@ -5,9 +5,9 @@ namespace jsonrpclib /// the JSON-RPC protocol, /// see https://www.jsonrpc.org/specification @protocolDefinition(traits: [ - jsonRequest - jsonNotification - jsonPayload + jsonRpcRequest + jsonRpcNotification + jsonRpcPayload smithy.api#jsonName smithy.api#length smithy.api#pattern @@ -20,28 +20,28 @@ namespace jsonrpclib alloy#untagged ]) @trait(selector: "service") -structure jsonRPC { +structure jsonRpc { } /// Identifies an operation that abides by request/response semantics /// https://www.jsonrpc.org/specification#request_object -@trait(selector: "operation", conflicts: [jsonNotification]) -string jsonRequest +@trait(selector: "operation", conflicts: [jsonRpcNotification]) +string jsonRpcRequest /// Identifies an operation that abides by fire-and-forget semantics /// see https://www.jsonrpc.org/specification#notification -@trait(selector: "operation", conflicts: [jsonRequest]) -string jsonNotification +@trait(selector: "operation", conflicts: [jsonRpcRequest]) +string jsonRpcNotification /// Binds a single structure member to the payload of a jsonrpc message. -/// Just like @httpPayload, but for jsonRPC. +/// Just like @httpPayload, but for jsonRpc. @trait(selector: "structure > member", structurallyExclusive: "member") @traitValidators({ - "jsonPayload.OnlyTopLevel": { - message: "jsonPayload can only be used on the top level of an operation input/output/error.", + "jsonRpcPayload.OnlyTopLevel": { + message: "jsonRpcPayload can only be used on the top level of an operation input/output/error.", severity: "ERROR", selector: "$allowedShapes(:root(operation -[input, output, error]-> structure > member)) :not(:in(${allowedShapes}))" } }) -structure jsonPayload {} +structure jsonRpcPayload {} diff --git a/modules/smithy4s-tests/src/main/smithy/spec.smithy b/modules/smithy4s-tests/src/main/smithy/spec.smithy index e88d378..9d55f5b 100644 --- a/modules/smithy4s-tests/src/main/smithy/spec.smithy +++ b/modules/smithy4s-tests/src/main/smithy/spec.smithy @@ -2,22 +2,22 @@ $version: "2.0" namespace test -use jsonrpclib#jsonNotification -use jsonrpclib#jsonRPC -use jsonrpclib#jsonRequest -use jsonrpclib#jsonPayload +use jsonrpclib#jsonRpcNotification +use jsonrpclib#jsonRpc +use jsonrpclib#jsonRpcRequest +use jsonrpclib#jsonRpcPayload -@jsonRPC +@jsonRpc service TestServer { operations: [Greet, Ping] } -@jsonRPC +@jsonRpc service TestClient { operations: [Pong] } -@jsonRequest("greet") +@jsonRpcRequest("greet") operation Greet { input := { @required @@ -31,21 +31,21 @@ operation Greet { } -@jsonRPC +@jsonRpc service TestServerWithPayload { operations: [GreetWithPayload] } -@jsonRequest("greetWithPayload") +@jsonRpcRequest("greetWithPayload") operation GreetWithPayload { input := { @required - @jsonPayload + @jsonRpcPayload payload: GreetInputPayload } output := { @required - @jsonPayload + @jsonRpcPayload payload: GreetOutputPayload } } @@ -66,7 +66,7 @@ structure NotWelcomeError { msg: String } -@jsonNotification("ping") +@jsonRpcNotification("ping") operation Ping { input := { @required @@ -74,7 +74,7 @@ operation Ping { } } -@jsonNotification("pong") +@jsonRpcNotification("pong") operation Pong { input := { @required @@ -82,12 +82,12 @@ operation Pong { } } -@jsonRPC +@jsonRpc service WeatherService { operations: [GetWeather] } -@jsonRequest("getWeather") +@jsonRpcRequest("getWeather") operation GetWeather { input := { @required diff --git a/modules/smithy4s/src/main/scala/jsonrpclib/smithy4sinterop/EndpointSpec.scala b/modules/smithy4s/src/main/scala/jsonrpclib/smithy4sinterop/EndpointSpec.scala index 4dd4386..2c91f14 100644 --- a/modules/smithy4s/src/main/scala/jsonrpclib/smithy4sinterop/EndpointSpec.scala +++ b/modules/smithy4s/src/main/scala/jsonrpclib/smithy4sinterop/EndpointSpec.scala @@ -8,8 +8,8 @@ private[smithy4sinterop] object EndpointSpec { case class Request(methodName: String) extends EndpointSpec def fromHints(hints: Hints): Option[EndpointSpec] = hints match { - case jsonrpclib.JsonRequest.hint(r) => Some(Request(r.value)) - case jsonrpclib.JsonNotification.hint(r) => Some(Notification(r.value)) - case _ => None + case jsonrpclib.JsonRpcRequest.hint(r) => Some(Request(r.value)) + case jsonrpclib.JsonRpcNotification.hint(r) => Some(Notification(r.value)) + case _ => None } } diff --git a/modules/smithy4s/src/main/scala/jsonrpclib/smithy4sinterop/JsonPayloadTransformation.scala b/modules/smithy4s/src/main/scala/jsonrpclib/smithy4sinterop/JsonPayloadTransformation.scala index 0a1a1ca..a37092f 100644 --- a/modules/smithy4s/src/main/scala/jsonrpclib/smithy4sinterop/JsonPayloadTransformation.scala +++ b/modules/smithy4s/src/main/scala/jsonrpclib/smithy4sinterop/JsonPayloadTransformation.scala @@ -1,6 +1,6 @@ package jsonrpclib.smithy4sinterop -import jsonrpclib.JsonPayload +import jsonrpclib.JsonRpcPayload import smithy4s.~> import smithy4s.Schema import smithy4s.Schema.StructSchema @@ -12,7 +12,7 @@ private[jsonrpclib] object JsonPayloadTransformation extends (Schema ~> Schema) case struct: StructSchema[b] => struct.fields .collectFirst { - case field if field.hints.has[JsonPayload] => + case field if field.hints.has[JsonRpcPayload] => field.schema.biject[b]((f: Any) => struct.make(Vector(f)))(field.get) } .getOrElse(fa) From 074fb87d7e144103676d8af95892f2a3f0dbf3f9 Mon Sep 17 00:00:00 2001 From: ghostbuster91 Date: Thu, 29 May 2025 22:20:39 +0200 Subject: [PATCH 36/47] Update readme --- README.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/README.md b/README.md index 64acc58..a12733e 100644 --- a/README.md +++ b/README.md @@ -39,3 +39,27 @@ override def ivyDeps = super.ivyDeps() ++ Agg(ivy"tech.neander::jsonrpclib-fs2:: **/!\ Please be aware that this library is in its early days and offers strictly no guarantee with regards to backward compatibility** See the modules/examples folder. + +## Smithy Integration + +You can now use `jsonrpclib` directly with [Smithy](https://smithy.io/) and [smithy4s](https://disneystreaming.github.io/smithy4s/), enabling type-safe, +schema-first JSON-RPC APIs with minimal boilerplate. + +This integration is supported by the following modules: + +```scala +// Defines the Smithy protocol for JSON-RPC +libraryDependencies += "tech.neander" % "jsonrpclib-smithy" % + +// Provides smithy4s client/server bindings for JSON-RPC +libraryDependencies += "tech.neander" %%% "jsonrpclib-smithy4s" % +``` + +With these modules, you can: + +- Annotate your Smithy operations with `@jsonRpcRequest` or `@jsonRpcNotification` +- Generate client and server interfaces using smithy4s +- Use ClientStub to invoke remote services over JSON-RPC +- Use ServerEndpoints to expose service implementations via FS2Channel + +This allows you to define your API once in Smithy and interact with it as a fully typed JSON-RPC service—without writing manual encoders, decoders, or dispatch logic. From 8eee63e73f7cd9efc29f8d96df3e463a5fca0137 Mon Sep 17 00:00:00 2001 From: ghostbuster91 Date: Thu, 29 May 2025 22:28:13 +0200 Subject: [PATCH 37/47] Fix missing renames --- .../JsonNotificationOutputValidatorSpec.scala | 10 ++--- .../jsonrpclib/JsonPayloadValidatorSpec.scala | 36 +++++++-------- .../JsonRpcOperationValidatorSpec.scala | 28 ++++++------ ...niqueJsonRpcMethodNamesValidatorSpec.scala | 44 +++++++++---------- .../JsonNotificationOutputValidator.java | 2 +- .../validation/JsonRpcOperationValidator.java | 2 +- 6 files changed, 61 insertions(+), 61 deletions(-) diff --git a/modules/smithy-tests/src/test/scala/jsonrpclib/JsonNotificationOutputValidatorSpec.scala b/modules/smithy-tests/src/test/scala/jsonrpclib/JsonNotificationOutputValidatorSpec.scala index e93d5b9..b0c3148 100644 --- a/modules/smithy-tests/src/test/scala/jsonrpclib/JsonNotificationOutputValidatorSpec.scala +++ b/modules/smithy-tests/src/test/scala/jsonrpclib/JsonNotificationOutputValidatorSpec.scala @@ -13,9 +13,9 @@ object JsonNotificationOutputValidatorSpec extends FunSuite { """$version: "2" |namespace test | - |use jsonrpclib#jsonNotification + |use jsonrpclib#jsonRpcNotification | - |@jsonNotification("notify") + |@jsonRpcNotification("notify") |operation NotifySomething { |} |""".stripMargin @@ -28,9 +28,9 @@ object JsonNotificationOutputValidatorSpec extends FunSuite { """$version: "2" |namespace test | - |use jsonrpclib#jsonNotification + |use jsonrpclib#jsonRpcNotification | - |@jsonNotification("notify") + |@jsonRpcNotification("notify") |operation NotifySomething { | output:={ | message: String @@ -47,7 +47,7 @@ object JsonNotificationOutputValidatorSpec extends FunSuite { .shapeId(ShapeId.fromParts("test", "NotifySomething")) .severity(Severity.ERROR) .message( - "Operation marked as @jsonNotification must not return anything, but found `test#NotifySomethingOutput`." + "Operation marked as @jsonRpcNotification must not return anything, but found `test#NotifySomethingOutput`." ) .build() diff --git a/modules/smithy-tests/src/test/scala/jsonrpclib/JsonPayloadValidatorSpec.scala b/modules/smithy-tests/src/test/scala/jsonrpclib/JsonPayloadValidatorSpec.scala index e406833..22b4265 100644 --- a/modules/smithy-tests/src/test/scala/jsonrpclib/JsonPayloadValidatorSpec.scala +++ b/modules/smithy-tests/src/test/scala/jsonrpclib/JsonPayloadValidatorSpec.scala @@ -8,22 +8,22 @@ import software.amazon.smithy.model.validation.ValidationEvent import weaver._ object JsonPayloadValidatorSpec extends FunSuite { - test("no error when jsonPayload is used on the input, output or error structure's member") { + test("no error when jsonRpcPayload is used on the input, output or error structure's member") { assembleModel( """$version: "2" |namespace test | - |use jsonrpclib#jsonRPC - |use jsonrpclib#jsonRequest - |use jsonrpclib#jsonPayload + |use jsonrpclib#jsonRpc + |use jsonrpclib#jsonRpcRequest + |use jsonrpclib#jsonRpcPayload | - |@jsonRPC + |@jsonRpc |service MyService { | operations: [OpA] |} | - |@jsonRequest("foo") + |@jsonRpcRequest("foo") |operation OpA { | input: OpInput | output: OpOutput @@ -31,18 +31,18 @@ object JsonPayloadValidatorSpec extends FunSuite { |} | |structure OpInput { - | @jsonPayload + | @jsonRpcPayload | data: String |} | |structure OpOutput { - | @jsonPayload + | @jsonRpcPayload | data: String |} | |@error("client") |structure OpError { - | @jsonPayload + | @jsonRpcPayload | data: String |} | @@ -51,22 +51,22 @@ object JsonPayloadValidatorSpec extends FunSuite { success } - test("return an error when jsonPayload is used in a nested structure") { + test("return an error when jsonRpcPayload is used in a nested structure") { val events = eventsWithoutLocations( assembleModel( """$version: "2" |namespace test | - |use jsonrpclib#jsonRPC - |use jsonrpclib#jsonRequest - |use jsonrpclib#jsonPayload + |use jsonrpclib#jsonRpc + |use jsonrpclib#jsonRpcRequest + |use jsonrpclib#jsonRpcPayload | - |@jsonRPC + |@jsonRpc |service MyService { | operations: [OpA] |} | - |@jsonRequest("foo") + |@jsonRpcRequest("foo") |operation OpA { | input: OpInput |} @@ -76,7 +76,7 @@ object JsonPayloadValidatorSpec extends FunSuite { |} | |structure NestedStructure { - | @jsonPayload + | @jsonRpcPayload | data: String |} |""".stripMargin @@ -85,11 +85,11 @@ object JsonPayloadValidatorSpec extends FunSuite { val expected = ValidationEvent .builder() - .id("jsonPayload.OnlyTopLevel") + .id("jsonRpcPayload.OnlyTopLevel") .shapeId(ShapeId.fromParts("test", "NestedStructure", "data")) .severity(Severity.ERROR) .message( - "Found an incompatible shape when validating the constraints of the `jsonrpclib#jsonPayload` trait attached to `test#NestedStructure$data`: jsonPayload can only be used on the top level of an operation input/output/error." + "Found an incompatible shape when validating the constraints of the `jsonrpclib#jsonRpcPayload` trait attached to `test#NestedStructure$data`: jsonRpcPayload can only be used on the top level of an operation input/output/error." ) .build() diff --git a/modules/smithy-tests/src/test/scala/jsonrpclib/JsonRpcOperationValidatorSpec.scala b/modules/smithy-tests/src/test/scala/jsonrpclib/JsonRpcOperationValidatorSpec.scala index 99daed7..9a3e213 100644 --- a/modules/smithy-tests/src/test/scala/jsonrpclib/JsonRpcOperationValidatorSpec.scala +++ b/modules/smithy-tests/src/test/scala/jsonrpclib/JsonRpcOperationValidatorSpec.scala @@ -8,24 +8,24 @@ import software.amazon.smithy.model.validation.ValidationEvent import weaver._ object JsonRpcOperationValidatorSpec extends FunSuite { - test("no error when all operations in @jsonRPC service are properly annotated") { + test("no error when all operations in @jsonRpc service are properly annotated") { assembleModel( """$version: "2" |namespace test | - |use jsonrpclib#jsonRPC - |use jsonrpclib#jsonRequest - |use jsonrpclib#jsonNotification + |use jsonrpclib#jsonRpc + |use jsonrpclib#jsonRpcRequest + |use jsonrpclib#jsonRpcNotification | - |@jsonRPC + |@jsonRpc |service MyService { | operations: [OpA, OpB] |} | - |@jsonRequest("methodA") + |@jsonRpcRequest("methodA") |operation OpA {} | - |@jsonNotification("methodB") + |@jsonRpcNotification("methodB") |operation OpB { | output: unit |} @@ -34,24 +34,24 @@ object JsonRpcOperationValidatorSpec extends FunSuite { success } - test("return an error when a @jsonRPC service has an operation without @jsonRequest or @jsonNotification") { + test("return an error when a @jsonRpc service has an operation without @jsonRpcRequest or @jsonRpcNotification") { val events = eventsWithoutLocations( assembleModel( """$version: "2" |namespace test | - |use jsonrpclib#jsonRPC - |use jsonrpclib#jsonRequest + |use jsonrpclib#jsonRpc + |use jsonrpclib#jsonRpcRequest | - |@jsonRPC + |@jsonRpc |service MyService { | operations: [GoodOp, BadOp] |} | - |@jsonRequest("good") + |@jsonRpcRequest("good") |operation GoodOp {} | - |operation BadOp {} // ❌ missing jsonRequest or jsonNotification + |operation BadOp {} // ❌ missing jsonRpcRequest or jsonRpcNotification |""".stripMargin ) ) @@ -63,7 +63,7 @@ object JsonRpcOperationValidatorSpec extends FunSuite { .shapeId(ShapeId.fromParts("test", "BadOp")) .severity(Severity.ERROR) .message( - "Operation is part of service `test#MyService` marked with @jsonRPC but is missing @jsonRequest or @jsonNotification." + "Operation is part of service `test#MyService` marked with @jsonRpc but is missing @jsonRpcRequest or @jsonRpcNotification." ) .build() diff --git a/modules/smithy-tests/src/test/scala/jsonrpclib/UniqueJsonRpcMethodNamesValidatorSpec.scala b/modules/smithy-tests/src/test/scala/jsonrpclib/UniqueJsonRpcMethodNamesValidatorSpec.scala index f39b053..327d811 100644 --- a/modules/smithy-tests/src/test/scala/jsonrpclib/UniqueJsonRpcMethodNamesValidatorSpec.scala +++ b/modules/smithy-tests/src/test/scala/jsonrpclib/UniqueJsonRpcMethodNamesValidatorSpec.scala @@ -14,19 +14,19 @@ object UniqueJsonRpcMethodNamesValidatorSpec extends FunSuite { """$version: "2" |namespace test | - |use jsonrpclib#jsonRPC - |use jsonrpclib#jsonRequest - |use jsonrpclib#jsonNotification + |use jsonrpclib#jsonRpc + |use jsonrpclib#jsonRpcRequest + |use jsonrpclib#jsonRpcNotification | - |@jsonRPC + |@jsonRpc |service MyService { | operations: [OpA, OpB] |} | - |@jsonRequest("foo") + |@jsonRpcRequest("foo") |operation OpA {} | - |@jsonNotification("bar") + |@jsonRpcNotification("bar") |operation OpB {} |""".stripMargin ).unwrap() @@ -39,19 +39,19 @@ object UniqueJsonRpcMethodNamesValidatorSpec extends FunSuite { """$version: "2" |namespace test | - |use jsonrpclib#jsonRPC - |use jsonrpclib#jsonRequest - |use jsonrpclib#jsonNotification + |use jsonrpclib#jsonRpc + |use jsonrpclib#jsonRpcRequest + |use jsonrpclib#jsonRpcNotification | - |@jsonRPC + |@jsonRpc |service MyService { | operations: [OpA, OpB] |} | - |@jsonRequest("foo") + |@jsonRpcRequest("foo") |operation OpA {} | - |@jsonNotification("foo") + |@jsonRpcNotification("foo") |operation OpB {} // duplicate method name "foo" |""".stripMargin ) @@ -75,21 +75,21 @@ object UniqueJsonRpcMethodNamesValidatorSpec extends FunSuite { """$version: "2" |namespace test | - |use jsonrpclib#jsonRPC - |use jsonrpclib#jsonRequest - |use jsonrpclib#jsonNotification + |use jsonrpclib#jsonRpc + |use jsonrpclib#jsonRpcRequest + |use jsonrpclib#jsonRpcNotification | - |@jsonRPC + |@jsonRpc |service MyService { | operations: [OpA] |} | - |@jsonRPC + |@jsonRpc |service MyOtherService { | operations: [OpA] |} | - |@jsonRequest("foo") + |@jsonRpcRequest("foo") |operation OpA {} | |""".stripMargin @@ -102,18 +102,18 @@ object UniqueJsonRpcMethodNamesValidatorSpec extends FunSuite { """$version: "2" |namespace test | - |use jsonrpclib#jsonRequest - |use jsonrpclib#jsonNotification + |use jsonrpclib#jsonRpcRequest + |use jsonrpclib#jsonRpcNotification | | |service NonJsonRpcService { | operations: [OpA] |} | - |@jsonRequest("foo") + |@jsonRpcRequest("foo") |operation OpA {} | - |@jsonNotification("foo") + |@jsonRpcNotification("foo") |operation OpB {} // duplicate method name "foo" |""".stripMargin ).unwrap() diff --git a/modules/smithy/src/main/java/jsonrpclib/validation/JsonNotificationOutputValidator.java b/modules/smithy/src/main/java/jsonrpclib/validation/JsonNotificationOutputValidator.java index 4ece264..6fffed0 100644 --- a/modules/smithy/src/main/java/jsonrpclib/validation/JsonNotificationOutputValidator.java +++ b/modules/smithy/src/main/java/jsonrpclib/validation/JsonNotificationOutputValidator.java @@ -23,7 +23,7 @@ public List validate(Model model) { var outputShape = model.expectShape(outputShapeId); if (outputShape.asStructureShape().map(s -> !s.members().isEmpty()).orElse(true)) { return Stream.of(error(op, String.format( - "Operation marked as @jsonNotification must not return anything, but found `%s`.", outputShapeId))); + "Operation marked as @jsonRpcNotification must not return anything, but found `%s`.", outputShapeId))); } else { return Stream.empty(); } diff --git a/modules/smithy/src/main/java/jsonrpclib/validation/JsonRpcOperationValidator.java b/modules/smithy/src/main/java/jsonrpclib/validation/JsonRpcOperationValidator.java index 0968e06..44dd37a 100644 --- a/modules/smithy/src/main/java/jsonrpclib/validation/JsonRpcOperationValidator.java +++ b/modules/smithy/src/main/java/jsonrpclib/validation/JsonRpcOperationValidator.java @@ -28,7 +28,7 @@ private Stream validateService(Model model, ServiceShape servic .map(model::expectShape) .filter(op -> !hasJsonRpcMethod(op)) .map(op -> error(op, String.format( - "Operation is part of service `%s` marked with @jsonRPC but is missing @jsonRequest or @jsonNotification.", service.getId()))); + "Operation is part of service `%s` marked with @jsonRpc but is missing @jsonRpcRequest or @jsonRpcNotification.", service.getId()))); } private boolean hasJsonRpcMethod(Shape op) { From 381b08339794a45cbe99b4bb87436d895aa242e3 Mon Sep 17 00:00:00 2001 From: Kasper Kondzielski <5662622+ghostbuster91@users.noreply.github.com> Date: Sat, 7 Jun 2025 10:07:25 +0200 Subject: [PATCH 38/47] Update README.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Jakub Kozłowski --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index a12733e..29e23ec 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,6 @@ With these modules, you can: - Annotate your Smithy operations with `@jsonRpcRequest` or `@jsonRpcNotification` - Generate client and server interfaces using smithy4s - Use ClientStub to invoke remote services over JSON-RPC -- Use ServerEndpoints to expose service implementations via FS2Channel +- Use ServerEndpoints to expose service implementations via a Channel This allows you to define your API once in Smithy and interact with it as a fully typed JSON-RPC service—without writing manual encoders, decoders, or dispatch logic. From 55e611efce1278c464a81e6ec9bae0fc9cbda93d Mon Sep 17 00:00:00 2001 From: Kasper Kondzielski <5662622+ghostbuster91@users.noreply.github.com> Date: Sat, 7 Jun 2025 10:08:18 +0200 Subject: [PATCH 39/47] Update project/build.sbt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Jakub Kozłowski --- project/build.sbt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/project/build.sbt b/project/build.sbt index 27775d2..969768d 100644 --- a/project/build.sbt +++ b/project/build.sbt @@ -1,4 +1,4 @@ libraryDependencies ++= Seq( "software.amazon.smithy" % "smithy-trait-codegen", "software.amazon.smithy" % "smithy-model" -).map(_ % "1.56.0") +).map(_ % "1.58.0") From 6f2f70c24a49391c74cb1885a764747ef1b4fe56 Mon Sep 17 00:00:00 2001 From: Kasper Kondzielski <5662622+ghostbuster91@users.noreply.github.com> Date: Sat, 7 Jun 2025 10:08:27 +0200 Subject: [PATCH 40/47] Update project/plugins.sbt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Jakub Kozłowski --- project/plugins.sbt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/project/plugins.sbt b/project/plugins.sbt index 225c2ae..1a67d0e 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -14,6 +14,6 @@ addSbtPlugin("com.eed3si9n" % "sbt-assembly" % "2.3.1") addSbtPlugin("org.scalameta" % "sbt-scalafmt" % "2.5.4") -addSbtPlugin("com.disneystreaming.smithy4s" % "smithy4s-sbt-codegen" % "0.18.36") +addSbtPlugin("com.disneystreaming.smithy4s" % "smithy4s-sbt-codegen" % "0.18.37") addDependencyTreePlugin From 1bc15ae60efa149aa9082a5fb89a1e229fb98460 Mon Sep 17 00:00:00 2001 From: ghostbuster91 Date: Sat, 7 Jun 2025 22:15:55 +0200 Subject: [PATCH 41/47] Apply review comments --- modules/core/src/main/scala/jsonrpclib/Monadic.scala | 6 +++--- .../jsonrpclib/validation/JsonRpcOperationValidator.java | 3 +-- .../validation/UniqueJsonRpcMethodNamesValidator.java | 4 ++-- 3 files changed, 6 insertions(+), 7 deletions(-) diff --git a/modules/core/src/main/scala/jsonrpclib/Monadic.scala b/modules/core/src/main/scala/jsonrpclib/Monadic.scala index dd1e6ee..4bb6dc8 100644 --- a/modules/core/src/main/scala/jsonrpclib/Monadic.scala +++ b/modules/core/src/main/scala/jsonrpclib/Monadic.scala @@ -28,16 +28,16 @@ object Monadic { } object syntax { - implicit class MonadicOps[F[_], A](fa: F[A]) { + implicit class MonadicOps[F[_], A](private val fa: F[A]) extends AnyVal { def flatMap[B](f: A => F[B])(implicit m: Monadic[F]): F[B] = m.doFlatMap(fa)(f) def map[B](f: A => B)(implicit m: Monadic[F]): F[B] = m.doMap(fa)(f) def attempt[B](implicit m: Monadic[F]): F[Either[Throwable, A]] = m.doAttempt(fa) def void(implicit m: Monadic[F]): F[Unit] = m.doVoid(fa) } - implicit class MonadicOpsPure[A](a: A) { + implicit class MonadicOpsPure[A](private val a: A) extends AnyVal { def pure[F[_]](implicit m: Monadic[F]): F[A] = m.doPure(a) } - implicit class MonadicOpsThrowable(t: Throwable) { + implicit class MonadicOpsThrowable(private val t: Throwable) extends AnyVal { def raiseError[F[_], A](implicit m: Monadic[F]): F[A] = m.doRaiseError(t) } } diff --git a/modules/smithy/src/main/java/jsonrpclib/validation/JsonRpcOperationValidator.java b/modules/smithy/src/main/java/jsonrpclib/validation/JsonRpcOperationValidator.java index 44dd37a..d4f594f 100644 --- a/modules/smithy/src/main/java/jsonrpclib/validation/JsonRpcOperationValidator.java +++ b/modules/smithy/src/main/java/jsonrpclib/validation/JsonRpcOperationValidator.java @@ -17,8 +17,7 @@ public class JsonRpcOperationValidator extends AbstractValidator { @Override public List validate(Model model) { - return model.getServiceShapes().stream() - .filter(service -> service.hasTrait(JsonRpcTrait.class)) + return model.getServiceShapesWithTrait(JsonRpcTrait.class).stream() .flatMap(service -> validateService(model, service)) .collect(Collectors.toList()); } diff --git a/modules/smithy/src/main/java/jsonrpclib/validation/UniqueJsonRpcMethodNamesValidator.java b/modules/smithy/src/main/java/jsonrpclib/validation/UniqueJsonRpcMethodNamesValidator.java index 5ad1c6d..64179ed 100644 --- a/modules/smithy/src/main/java/jsonrpclib/validation/UniqueJsonRpcMethodNamesValidator.java +++ b/modules/smithy/src/main/java/jsonrpclib/validation/UniqueJsonRpcMethodNamesValidator.java @@ -20,8 +20,8 @@ public class UniqueJsonRpcMethodNamesValidator extends AbstractValidator { @Override public List validate(Model model) { - return model.getShapesWithTrait(JsonRpcTrait.class).stream() - .flatMap(service -> validateService(service.asServiceShape().orElseThrow(), model)) + return model.getServiceShapesWithTrait(JsonRpcTrait.class).stream() + .flatMap(service -> validateService(service, model)) .collect(Collectors.toList()); } From 9734e1492b9d7129c38759f17e9fbd013e8f86bb Mon Sep 17 00:00:00 2001 From: ghostbuster91 Date: Sun, 8 Jun 2025 00:14:01 +0200 Subject: [PATCH 42/47] Replace target with release --- build.sbt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.sbt b/build.sbt index 2565ea9..ed731eb 100644 --- a/build.sbt +++ b/build.sbt @@ -37,7 +37,7 @@ val commonSettings = Seq( ), scalacOptions ++= { CrossVersion.partialVersion(scalaVersion.value) match { - case Some((2, _)) => Seq(s"-target:jvm-$jdkVersion") + case Some((2, _)) => Seq(s"-release:$jdkVersion") case _ => Seq(s"-java-output-version:$jdkVersion") } }, From 23b3eb7be406173ccae0e7d7ad1fe7d9bf0b6f89 Mon Sep 17 00:00:00 2001 From: Kasper Kondzielski <5662622+ghostbuster91@users.noreply.github.com> Date: Sat, 14 Jun 2025 09:36:40 +0200 Subject: [PATCH 43/47] Create caches for document encoders (#91) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Create caches for document encoders * Remove unused imports * Remove redundant classes * Apply review comments * add client side error handling * Mark impl details package private * minor simplification --------- Co-authored-by: ghostbuster91 Co-authored-by: Jakub Kozłowski --- .../src/main/scala/jsonrpclib/Monadic.scala | 2 +- .../scala/examples/server/ServerMain.scala | 2 - .../smithy4sinterop/TestClientSpec.scala | 44 +++++++++++++ .../smithy4sinterop/CirceDecoderImpl.scala | 47 ++++++++++++++ .../smithy4sinterop/CirceEncoderImpl.scala | 28 +++++++++ .../smithy4sinterop/CirceJsonCodec.scala | 62 ++++++------------- .../smithy4sinterop/ClientStub.scala | 39 ++++++++++-- .../smithy4sinterop/ServerEndpoints.scala | 19 +++--- 8 files changed, 185 insertions(+), 58 deletions(-) create mode 100644 modules/smithy4s/src/main/scala/jsonrpclib/smithy4sinterop/CirceDecoderImpl.scala create mode 100644 modules/smithy4s/src/main/scala/jsonrpclib/smithy4sinterop/CirceEncoderImpl.scala diff --git a/modules/core/src/main/scala/jsonrpclib/Monadic.scala b/modules/core/src/main/scala/jsonrpclib/Monadic.scala index 4bb6dc8..2acf020 100644 --- a/modules/core/src/main/scala/jsonrpclib/Monadic.scala +++ b/modules/core/src/main/scala/jsonrpclib/Monadic.scala @@ -31,7 +31,7 @@ object Monadic { implicit class MonadicOps[F[_], A](private val fa: F[A]) extends AnyVal { def flatMap[B](f: A => F[B])(implicit m: Monadic[F]): F[B] = m.doFlatMap(fa)(f) def map[B](f: A => B)(implicit m: Monadic[F]): F[B] = m.doMap(fa)(f) - def attempt[B](implicit m: Monadic[F]): F[Either[Throwable, A]] = m.doAttempt(fa) + def attempt(implicit m: Monadic[F]): F[Either[Throwable, A]] = m.doAttempt(fa) def void(implicit m: Monadic[F]): F[Unit] = m.doVoid(fa) } implicit class MonadicOpsPure[A](private val a: A) extends AnyVal { diff --git a/modules/examples/server/src/main/scala/examples/server/ServerMain.scala b/modules/examples/server/src/main/scala/examples/server/ServerMain.scala index 786a278..2704b5b 100644 --- a/modules/examples/server/src/main/scala/examples/server/ServerMain.scala +++ b/modules/examples/server/src/main/scala/examples/server/ServerMain.scala @@ -4,8 +4,6 @@ import cats.effect._ import fs2.io._ import io.circe.generic.semiauto._ import io.circe.Codec -import io.circe.Decoder -import io.circe.Encoder import jsonrpclib.fs2._ import jsonrpclib.CallId import jsonrpclib.Endpoint diff --git a/modules/smithy4s-tests/src/test/scala/jsonrpclib/smithy4sinterop/TestClientSpec.scala b/modules/smithy4s-tests/src/test/scala/jsonrpclib/smithy4sinterop/TestClientSpec.scala index 3592771..eddedb0 100644 --- a/modules/smithy4s-tests/src/test/scala/jsonrpclib/smithy4sinterop/TestClientSpec.scala +++ b/modules/smithy4s-tests/src/test/scala/jsonrpclib/smithy4sinterop/TestClientSpec.scala @@ -8,6 +8,7 @@ import io.circe.Encoder import jsonrpclib._ import jsonrpclib.fs2._ import test._ +import test.TestServerOperation.GreetError import weaver._ import scala.concurrent.duration._ @@ -78,4 +79,47 @@ object TestClientSpec extends SimpleIOSuite { expect.same(result.payload.message, "Hello Bob") } } + + testRes("server returns known error") { + implicit val greetInputDecoder: Decoder[GreetInput] = CirceJsonCodec.fromSchema + implicit val greetOutputEncoder: Encoder[GreetOutput] = CirceJsonCodec.fromSchema + implicit val greetErrorEncoder: Encoder[GreetError] = CirceJsonCodec.fromSchema + implicit val errEncoder: ErrorEncoder[GreetError] = + err => ErrorPayload(-1, "error", Some(Payload(greetErrorEncoder(err)))) + + val endpoint: Endpoint[IO] = + Endpoint[IO]("greet").apply[GreetInput, GreetError, GreetOutput](in => + IO.pure(Left(GreetError.notWelcomeError(NotWelcomeError(s"${in.name} is not welcome")))) + ) + + for { + clientSideChannel <- setup(endpoint) + clientStub = ClientStub(TestServer, clientSideChannel) + result <- clientStub.greet("Bob").attempt.toStream + } yield { + matches(result) { case Left(t: NotWelcomeError) => + expect.same(t.msg, s"Bob is not welcome") + } + } + } + + testRes("server returns unknown error") { + implicit val greetInputDecoder: Decoder[GreetInput] = CirceJsonCodec.fromSchema + implicit val greetOutputEncoder: Encoder[GreetOutput] = CirceJsonCodec.fromSchema + + val endpoint: Endpoint[IO] = + Endpoint[IO]("greet").simple[GreetInput, GreetOutput](_ => IO.raiseError(new RuntimeException("boom!"))) + + for { + clientSideChannel <- setup(endpoint) + clientStub = ClientStub(TestServer, clientSideChannel) + result <- clientStub.greet("Bob").attempt.toStream + } yield { + matches(result) { case Left(t: ErrorPayload) => + expect.same(t.code, 0) && + expect.same(t.message, "boom!") && + expect.same(t.data, None) + } + } + } } diff --git a/modules/smithy4s/src/main/scala/jsonrpclib/smithy4sinterop/CirceDecoderImpl.scala b/modules/smithy4s/src/main/scala/jsonrpclib/smithy4sinterop/CirceDecoderImpl.scala new file mode 100644 index 0000000..ad6c55a --- /dev/null +++ b/modules/smithy4s/src/main/scala/jsonrpclib/smithy4sinterop/CirceDecoderImpl.scala @@ -0,0 +1,47 @@ +package jsonrpclib.smithy4sinterop + +import io.circe.{Decoder => CirceDecoder, _} +import smithy4s.codecs.PayloadPath +import smithy4s.schema.CachedSchemaCompiler +import smithy4s.Document +import smithy4s.Document.{Encoder => _, _} +import smithy4s.Schema + +private[smithy4sinterop] class CirceDecoderImpl extends CachedSchemaCompiler[CirceDecoder] { + val decoder: CachedSchemaCompiler.DerivingImpl[Decoder] = Document.Decoder + + type Cache = decoder.Cache + def createCache(): Cache = decoder.createCache() + + def fromSchema[A](schema: Schema[A], cache: Cache): CirceDecoder[A] = + c => { + c.as[Json] + .map(fromJson(_)) + .flatMap { d => + decoder + .fromSchema(schema, cache) + .decode(d) + .left + .map(e => + DecodingFailure(DecodingFailure.Reason.CustomReason(e.getMessage), c.history ++ toCursorOps(e.path)) + ) + } + } + + def fromSchema[A](schema: Schema[A]): CirceDecoder[A] = fromSchema(schema, createCache()) + + private def toCursorOps(path: PayloadPath): List[CursorOp] = + path.segments.map { + case PayloadPath.Segment.Label(name) => CursorOp.DownField(name) + case PayloadPath.Segment.Index(i) => CursorOp.DownN(i) + } + + private def fromJson(json: Json): Document = json.fold( + jsonNull = DNull, + jsonBoolean = DBoolean(_), + jsonNumber = n => DNumber(n.toBigDecimal.get), + jsonString = DString(_), + jsonArray = arr => DArray(arr.map(fromJson)), + jsonObject = obj => DObject(obj.toMap.view.mapValues(fromJson).toMap) + ) +} diff --git a/modules/smithy4s/src/main/scala/jsonrpclib/smithy4sinterop/CirceEncoderImpl.scala b/modules/smithy4s/src/main/scala/jsonrpclib/smithy4sinterop/CirceEncoderImpl.scala new file mode 100644 index 0000000..b03cf64 --- /dev/null +++ b/modules/smithy4s/src/main/scala/jsonrpclib/smithy4sinterop/CirceEncoderImpl.scala @@ -0,0 +1,28 @@ +package jsonrpclib.smithy4sinterop + +import io.circe.{Encoder => CirceEncoder, _} +import smithy4s.schema.CachedSchemaCompiler +import smithy4s.Document +import smithy4s.Document._ +import smithy4s.Schema + +private[smithy4sinterop] class CirceEncoderImpl extends CachedSchemaCompiler[CirceEncoder] { + val encoder: CachedSchemaCompiler.DerivingImpl[Encoder] = Document.Encoder + + type Cache = encoder.Cache + def createCache(): Cache = encoder.createCache() + + def fromSchema[A](schema: Schema[A], cache: Cache): CirceEncoder[A] = + a => documentToJson(encoder.fromSchema(schema, cache).encode(a)) + + def fromSchema[A](schema: Schema[A]): CirceEncoder[A] = fromSchema(schema, createCache()) + + private val documentToJson: Document => Json = { + case DNull => Json.Null + case DString(value) => Json.fromString(value) + case DBoolean(value) => Json.fromBoolean(value) + case DNumber(value) => Json.fromBigDecimal(value) + case DArray(values) => Json.fromValues(values.map(documentToJson)) + case DObject(entries) => Json.fromFields(entries.view.mapValues(documentToJson)) + } +} diff --git a/modules/smithy4s/src/main/scala/jsonrpclib/smithy4sinterop/CirceJsonCodec.scala b/modules/smithy4s/src/main/scala/jsonrpclib/smithy4sinterop/CirceJsonCodec.scala index 681c8e3..8e37725 100644 --- a/modules/smithy4s/src/main/scala/jsonrpclib/smithy4sinterop/CirceJsonCodec.scala +++ b/modules/smithy4s/src/main/scala/jsonrpclib/smithy4sinterop/CirceJsonCodec.scala @@ -1,55 +1,33 @@ package jsonrpclib.smithy4sinterop import io.circe._ -import smithy4s.codecs.PayloadPath -import smithy4s.Document -import smithy4s.Document.{Decoder => _, _} +import smithy4s.schema.CachedSchemaCompiler import smithy4s.Schema object CirceJsonCodec { + object Encoder extends CirceEncoderImpl + object Decoder extends CirceDecoderImpl + + object Codec extends CachedSchemaCompiler[Codec] { + type Cache = (Encoder.Cache, Decoder.Cache) + def createCache(): Cache = (Encoder.createCache(), Decoder.createCache()) + + def fromSchema[A](schema: Schema[A]): Codec[A] = + io.circe.Codec.from(Decoder.fromSchema(schema), Encoder.fromSchema(schema)) + + def fromSchema[A](schema: Schema[A], cache: Cache): Codec[A] = + io.circe.Codec.from( + Decoder.fromSchema(schema, cache._2), + Encoder.fromSchema(schema, cache._1) + ) + } + /** Creates a Circe `Codec[A]` from a Smithy4s `Schema[A]`. * * This enables encoding values of type `A` to JSON and decoding JSON back into `A`, using the structure defined by * the Smithy schema. */ - def fromSchema[A](implicit schema: Schema[A]): Codec[A] = Codec.from( - c => { - c.as[Json] - .map(fromJson) - .flatMap { d => - Document - .decode[A](d) - .left - .map(e => - DecodingFailure(DecodingFailure.Reason.CustomReason(e.getMessage), c.history ++ toCursorOps(e.path)) - ) - } - }, - a => documentToJson(Document.encode(a)) - ) - - private def toCursorOps(path: PayloadPath): List[CursorOp] = - path.segments.map { - case PayloadPath.Segment.Label(name) => CursorOp.DownField(name) - case PayloadPath.Segment.Index(i) => CursorOp.DownN(i) - } - - private val documentToJson: Document => Json = { - case DNull => Json.Null - case DString(value) => Json.fromString(value) - case DBoolean(value) => Json.fromBoolean(value) - case DNumber(value) => Json.fromBigDecimal(value) - case DArray(values) => Json.fromValues(values.map(documentToJson)) - case DObject(entries) => Json.fromFields(entries.view.mapValues(documentToJson)) - } - - private def fromJson(json: Json): Document = json.fold( - jsonNull = DNull, - jsonBoolean = DBoolean(_), - jsonNumber = n => DNumber(n.toBigDecimal.get), - jsonString = DString(_), - jsonArray = arr => DArray(arr.map(fromJson)), - jsonObject = obj => DObject(obj.toMap.view.mapValues(fromJson).toMap) - ) + def fromSchema[A](implicit schema: Schema[A]): Codec[A] = + Codec.fromSchema(schema) } diff --git a/modules/smithy4s/src/main/scala/jsonrpclib/smithy4sinterop/ClientStub.scala b/modules/smithy4s/src/main/scala/jsonrpclib/smithy4sinterop/ClientStub.scala index 4f2de95..dbc8fde 100644 --- a/modules/smithy4s/src/main/scala/jsonrpclib/smithy4sinterop/ClientStub.scala +++ b/modules/smithy4s/src/main/scala/jsonrpclib/smithy4sinterop/ClientStub.scala @@ -1,8 +1,12 @@ package jsonrpclib.smithy4sinterop import io.circe.Codec +import io.circe.HCursor import jsonrpclib.Channel +import jsonrpclib.ErrorPayload import jsonrpclib.Monadic +import jsonrpclib.Monadic.syntax._ +import jsonrpclib.ProtocolError import smithy4s.~> import smithy4s.schema._ import smithy4s.Service @@ -30,12 +34,13 @@ object ClientStub { private class ClientStub[Alg[_[_, _, _, _, _]], F[_]: Monadic](val service: Service[Alg], channel: Channel[F]) { def compile: service.Impl[F] = { + val codecCache = CirceJsonCodec.Codec.createCache() val interpreter = new service.FunctorEndpointCompiler[F] { def apply[I, E, O, SI, SO](e: service.Endpoint[I, E, O, SI, SO]): I => F[O] = { val shapeId = e.id val spec = EndpointSpec.fromHints(e.hints).toRight(NotJsonRPCEndpoint(shapeId)).toTry.get - jsonRPCStub(e, spec) + jsonRPCStub(e, spec, codecCache) } } @@ -44,18 +49,42 @@ private class ClientStub[Alg[_[_, _, _, _, _]], F[_]: Monadic](val service: Serv def jsonRPCStub[I, E, O, SI, SO]( smithy4sEndpoint: service.Endpoint[I, E, O, SI, SO], - endpointSpec: EndpointSpec + endpointSpec: EndpointSpec, + codecCache: CirceJsonCodec.Codec.Cache ): I => F[O] = { - implicit val inputCodec: Codec[I] = CirceJsonCodec.fromSchema(smithy4sEndpoint.input) - implicit val outputCodec: Codec[O] = CirceJsonCodec.fromSchema(smithy4sEndpoint.output) + implicit val inputCodec: Codec[I] = CirceJsonCodec.Codec.fromSchema(smithy4sEndpoint.input, codecCache) + implicit val outputCodec: Codec[O] = CirceJsonCodec.Codec.fromSchema(smithy4sEndpoint.output, codecCache) + + def errorResponse(throwable: Throwable, errorCodec: Codec[E]): F[E] = { + throwable match { + case ErrorPayload(_, _, Some(payload)) => + errorCodec.decodeJson(payload.data) match { + case Left(err) => ProtocolError.ParseError(err.getMessage).raiseError + case Right(error) => error.pure + } + case e: Throwable => e.raiseError + } + } endpointSpec match { case EndpointSpec.Notification(methodName) => val coerce = coerceUnit[O](smithy4sEndpoint.output) channel.notificationStub[I](methodName).andThen(f => Monadic[F].doFlatMap(f)(_ => coerce)) case EndpointSpec.Request(methodName) => - channel.simpleStub[I, O](methodName) + smithy4sEndpoint.error match { + case None => channel.simpleStub[I, O](methodName) + case Some(errorSchema) => + val errorCodec = CirceJsonCodec.Codec.fromSchema(errorSchema.schema, codecCache) + val stub = channel.simpleStub[I, O](methodName) + (in: I) => + stub.apply(in).attempt.flatMap { + case Right(success) => success.pure + case Left(error) => + errorResponse(error, errorCodec) + .flatMap(e => errorSchema.unliftError(e).raiseError) + } + } } } diff --git a/modules/smithy4s/src/main/scala/jsonrpclib/smithy4sinterop/ServerEndpoints.scala b/modules/smithy4s/src/main/scala/jsonrpclib/smithy4sinterop/ServerEndpoints.scala index f4e375b..92843f4 100644 --- a/modules/smithy4s/src/main/scala/jsonrpclib/smithy4sinterop/ServerEndpoints.scala +++ b/modules/smithy4s/src/main/scala/jsonrpclib/smithy4sinterop/ServerEndpoints.scala @@ -34,11 +34,12 @@ object ServerEndpoints { )(implicit service: Service[Alg], F: Monadic[F]): List[Endpoint[F]] = { val transformedService = JsonRpcTransformations.apply(service) val interpreter: transformedService.FunctorInterpreter[F] = transformedService.toPolyFunction(impl) + val codecCache = CirceJsonCodec.Codec.createCache() transformedService.endpoints.toList.flatMap { smithy4sEndpoint => EndpointSpec .fromHints(smithy4sEndpoint.hints) .map { endpointSpec => - jsonRPCEndpoint(smithy4sEndpoint, endpointSpec, interpreter) + jsonRPCEndpoint(smithy4sEndpoint, endpointSpec, interpreter, codecCache) } .toList } @@ -55,17 +56,19 @@ object ServerEndpoints { * JSON-RPC method name and interaction hints * @param impl * Interpreter that executes the Smithy operation in `F` + * @param codecCache + * Coche for the schema to codec compilation results * @return * A JSON-RPC-compatible `Endpoint[F]` */ private def jsonRPCEndpoint[F[_]: Monadic, Op[_, _, _, _, _], I, E, O, SI, SO]( smithy4sEndpoint: Smithy4sEndpoint[Op, I, E, O, SI, SO], endpointSpec: EndpointSpec, - impl: FunctorInterpreter[Op, F] + impl: FunctorInterpreter[Op, F], + codecCache: CirceJsonCodec.Codec.Cache ): Endpoint[F] = { - - implicit val inputCodec: Codec[I] = CirceJsonCodec.fromSchema(smithy4sEndpoint.input) - implicit val outputCodec: Codec[O] = CirceJsonCodec.fromSchema(smithy4sEndpoint.output) + implicit val inputCodec: Codec[I] = CirceJsonCodec.Codec.fromSchema(smithy4sEndpoint.input, codecCache) + implicit val outputCodec: Codec[O] = CirceJsonCodec.Codec.fromSchema(smithy4sEndpoint.output, codecCache) def errorResponse(throwable: Throwable): F[E] = throwable match { case smithy4sEndpoint.Error((_, e)) => e.pure @@ -86,7 +89,7 @@ object ServerEndpoints { impl(op) } case Some(errorSchema) => - implicit val errorCodec: ErrorEncoder[E] = errorCodecFromSchema(errorSchema) + implicit val errorCodec: ErrorEncoder[E] = errorCodecFromSchema(errorSchema, codecCache) Endpoint[F](methodName).apply[I, E, O] { (input: I) => val op = smithy4sEndpoint.wrap(input) impl(op).attempt.flatMap { @@ -98,8 +101,8 @@ object ServerEndpoints { } } - private def errorCodecFromSchema[A](s: ErrorSchema[A]): ErrorEncoder[A] = { - val circeCodec = CirceJsonCodec.fromSchema(s.schema) + private def errorCodecFromSchema[A](s: ErrorSchema[A], cache: CirceJsonCodec.Codec.Cache): ErrorEncoder[A] = { + val circeCodec = CirceJsonCodec.Codec.fromSchema(s.schema, cache) (a: A) => ErrorPayload( 0, From 9cbe713ce344ec72fb361f575be6e4faf61e2af8 Mon Sep 17 00:00:00 2001 From: ghostbuster91 Date: Sun, 15 Jun 2025 11:40:01 +0200 Subject: [PATCH 44/47] Fail when service does support jsonRPC protocol --- .../examples/smithy/client/ClientMain.scala | 5 +- .../examples/smithy/server/ServerMain.scala | 8 +- .../smithy4sinterop/TestClientSpec.scala | 10 +-- .../smithy4sinterop/TestServerSpec.scala | 88 +++++++++++-------- .../smithy4sinterop/ClientStub.scala | 33 +++++-- .../smithy4sinterop/ServerEndpoints.scala | 44 ++++++---- 6 files changed, 121 insertions(+), 67 deletions(-) diff --git a/modules/examples/smithyClient/src/main/scala/examples/smithy/client/ClientMain.scala b/modules/examples/smithyClient/src/main/scala/examples/smithy/client/ClientMain.scala index 06016f4..96532ca 100644 --- a/modules/examples/smithyClient/src/main/scala/examples/smithy/client/ClientMain.scala +++ b/modules/examples/smithyClient/src/main/scala/examples/smithy/client/ClientMain.scala @@ -40,9 +40,10 @@ object SmithyClientMain extends IOApp.Simple { // Creating a channel that will be used to communicate to the server fs2Channel <- FS2Channel.stream[IO](cancelTemplate = cancelEndpoint.some) // Mounting our implementation of the generated interface onto the channel - _ <- fs2Channel.withEndpointsStream(ServerEndpoints(Client)) + se <- Stream.eval(IO.fromEither(ServerEndpoints.apply[TestClientGen, IO](Client))) + _ <- fs2Channel.withEndpointsStream(se) // Creating stubs to talk to the remote server - server: TestServer[IO] = ClientStub(test.TestServer, fs2Channel) + server: TestServer[IO] <- Stream.eval(IO.fromEither(ClientStub(TestServer, fs2Channel))) _ <- Stream(()) .concurrently(fs2Channel.output.through(lsp.encodeMessages).through(rp.stdin)) .concurrently(rp.stdout.through(lsp.decodeMessages).through(fs2Channel.inputOrBounce)) diff --git a/modules/examples/smithyServer/src/main/scala/examples/smithy/server/ServerMain.scala b/modules/examples/smithyServer/src/main/scala/examples/smithy/server/ServerMain.scala index d410ad3..57cdeb5 100644 --- a/modules/examples/smithyServer/src/main/scala/examples/smithy/server/ServerMain.scala +++ b/modules/examples/smithyServer/src/main/scala/examples/smithy/server/ServerMain.scala @@ -2,6 +2,7 @@ package examples.smithy.server import cats.effect._ import fs2.io._ +import fs2.Stream import jsonrpclib.fs2._ import jsonrpclib.smithy4sinterop.ClientStub import jsonrpclib.smithy4sinterop.ServerEndpoints @@ -27,8 +28,11 @@ object ServerMain extends IOApp.Simple { FS2Channel .stream[IO](cancelTemplate = Some(cancelEndpoint)) .flatMap { channel => - val testClient = ClientStub(TestClient, channel) - channel.withEndpointsStream(ServerEndpoints(new ServerImpl(testClient))) + Stream.eval(IO.fromEither(ClientStub(TestClient, channel))).flatMap { testClient => + Stream.eval(IO.fromEither(ServerEndpoints(new ServerImpl(testClient)))).flatMap { se => + channel.withEndpointsStream(se) + } + } } .flatMap { channel => fs2.Stream diff --git a/modules/smithy4s-tests/src/test/scala/jsonrpclib/smithy4sinterop/TestClientSpec.scala b/modules/smithy4s-tests/src/test/scala/jsonrpclib/smithy4sinterop/TestClientSpec.scala index eddedb0..db20935 100644 --- a/modules/smithy4s-tests/src/test/scala/jsonrpclib/smithy4sinterop/TestClientSpec.scala +++ b/modules/smithy4s-tests/src/test/scala/jsonrpclib/smithy4sinterop/TestClientSpec.scala @@ -43,7 +43,7 @@ object TestClientSpec extends SimpleIOSuite { for { clientSideChannel <- setup(endpoint) - clientStub = ClientStub(TestServer, clientSideChannel) + clientStub <- Stream.eval(IO.fromEither(ClientStub(TestServer, clientSideChannel))) result <- clientStub.greet("Bob").toStream } yield { expect.same(result.message, "Hello Bob") @@ -57,7 +57,7 @@ object TestClientSpec extends SimpleIOSuite { ref <- SignallingRef[IO, Option[PingInput]](none).toStream endpoint: Endpoint[IO] = Endpoint[IO]("ping").notification[PingInput](p => ref.set(p.some)) clientSideChannel <- setup(endpoint) - clientStub = ClientStub(TestServer, clientSideChannel) + clientStub <- Stream.eval(IO.fromEither(ClientStub(TestServer, clientSideChannel))) _ <- clientStub.ping("hello").toStream result <- ref.discrete.dropWhile(_.isEmpty).take(1) } yield { @@ -73,7 +73,7 @@ object TestClientSpec extends SimpleIOSuite { for { clientSideChannel <- setup(endpoint) - clientStub = ClientStub(TestServerWithPayload, clientSideChannel) + clientStub <- Stream.eval(IO.fromEither(ClientStub(TestServerWithPayload, clientSideChannel))) result <- clientStub.greetWithPayload(GreetInputPayload("Bob")).toStream } yield { expect.same(result.payload.message, "Hello Bob") @@ -94,7 +94,7 @@ object TestClientSpec extends SimpleIOSuite { for { clientSideChannel <- setup(endpoint) - clientStub = ClientStub(TestServer, clientSideChannel) + clientStub <- Stream.eval(IO.fromEither(ClientStub(TestServer, clientSideChannel))) result <- clientStub.greet("Bob").attempt.toStream } yield { matches(result) { case Left(t: NotWelcomeError) => @@ -112,7 +112,7 @@ object TestClientSpec extends SimpleIOSuite { for { clientSideChannel <- setup(endpoint) - clientStub = ClientStub(TestServer, clientSideChannel) + clientStub <- Stream.eval(IO.fromEither(ClientStub(TestServer, clientSideChannel))) result <- clientStub.greet("Bob").attempt.toStream } yield { matches(result) { case Left(t: ErrorPayload) => diff --git a/modules/smithy4s-tests/src/test/scala/jsonrpclib/smithy4sinterop/TestServerSpec.scala b/modules/smithy4s-tests/src/test/scala/jsonrpclib/smithy4sinterop/TestServerSpec.scala index 88ae345..4bad3ae 100644 --- a/modules/smithy4s-tests/src/test/scala/jsonrpclib/smithy4sinterop/TestServerSpec.scala +++ b/modules/smithy4s-tests/src/test/scala/jsonrpclib/smithy4sinterop/TestServerSpec.scala @@ -53,32 +53,42 @@ object TestServerSpec extends SimpleIOSuite { } } - def setup(mkServer: FS2Channel[IO] => AlgebraWrapper) = - setupAux(None, mkServer.andThen(Seq(_)), _ => Seq.empty) + def setup(mkServer: FS2Channel[IO] => IO[AlgebraWrapper]) = + setupAux(None, mkServer.andThen(_.map(List(_))), _ => IO(List.empty)) - def setup(mkServer: FS2Channel[IO] => AlgebraWrapper, mkClient: FS2Channel[IO] => AlgebraWrapper) = - setupAux(None, mkServer.andThen(Seq(_)), mkClient.andThen(Seq(_))) + def setup(mkServer: FS2Channel[IO] => IO[AlgebraWrapper], mkClient: FS2Channel[IO] => IO[AlgebraWrapper]) = + setupAux(None, mkServer.andThen(_.map(List(_))), mkClient.andThen(_.map(List(_)))) def setup[Alg[_[_, _, _, _, _]]]( cancelTemplate: CancelTemplate, - mkServer: FS2Channel[IO] => Seq[AlgebraWrapper], - mkClient: FS2Channel[IO] => Seq[AlgebraWrapper] + mkServer: FS2Channel[IO] => IO[List[AlgebraWrapper]], + mkClient: FS2Channel[IO] => IO[List[AlgebraWrapper]] ) = setupAux(Some(cancelTemplate), mkServer, mkClient) def setupAux[Alg[_[_, _, _, _, _]]]( cancelTemplate: Option[CancelTemplate], - mkServer: FS2Channel[IO] => Seq[AlgebraWrapper], - mkClient: FS2Channel[IO] => Seq[AlgebraWrapper] + mkServer: FS2Channel[IO] => IO[List[AlgebraWrapper]], + mkClient: FS2Channel[IO] => IO[List[AlgebraWrapper]] ): Stream[IO, ClientSideChannel] = { for { serverSideChannel <- FS2Channel.stream[IO](cancelTemplate = cancelTemplate) clientSideChannel <- FS2Channel.stream[IO](cancelTemplate = cancelTemplate) - serverChannelWithEndpoints <- serverSideChannel.withEndpointsStream(mkServer(serverSideChannel).flatMap { p => - ServerEndpoints(p.algebra)(p.service, Monadic[IO]) - }) - clientChannelWithEndpoints <- clientSideChannel.withEndpointsStream(mkClient(clientSideChannel).flatMap { p => - ServerEndpoints(p.algebra)(p.service, Monadic[IO]) - }) + se <- Stream.eval( + mkServer(serverSideChannel).flatMap( + _.flatTraverse { p => + IO.fromEither(ServerEndpoints(p.algebra)(p.service, Monadic[IO])) + } + ) + ) + serverChannelWithEndpoints <- serverSideChannel.withEndpointsStream(se.toSeq) + ce <- Stream.eval( + mkClient(clientSideChannel).flatMap( + _.flatTraverse { p => + IO.fromEither(ServerEndpoints(p.algebra)(p.service, Monadic[IO])) + } + ) + ) + clientChannelWithEndpoints <- clientSideChannel.withEndpointsStream(ce.toSeq) _ <- Stream(()) .concurrently(clientChannelWithEndpoints.output.through(serverChannelWithEndpoints.input)) .concurrently(serverChannelWithEndpoints.output.through(clientChannelWithEndpoints.input)) @@ -93,8 +103,9 @@ object TestServerSpec extends SimpleIOSuite { for { clientSideChannel <- setup(channel => { - val testClient = ClientStub(TestClient, channel) - AlgebraWrapper(new ServerImpl(testClient)) + IO.fromEither(ClientStub(TestClient, channel)).map { testClient => + AlgebraWrapper(new ServerImpl(testClient)) + } }) remoteFunction = clientSideChannel.simpleStub[GreetInput, GreetOutput]("greet") result <- remoteFunction(GreetInput("Bob")).toStream @@ -110,10 +121,11 @@ object TestServerSpec extends SimpleIOSuite { ref <- SignallingRef[IO, Option[String]](none).toStream clientSideChannel <- setup( channel => { - val testClient = ClientStub(TestClient, channel) - AlgebraWrapper(new ServerImpl(testClient)) + IO.fromEither(ClientStub(TestClient, channel)).map { testClient => + AlgebraWrapper(new ServerImpl(testClient)) + } }, - _ => AlgebraWrapper(new Client(ref)) + _ => IO(AlgebraWrapper(new Client(ref))) ) remoteFunction = clientSideChannel.notificationStub[PingInput]("ping") _ <- remoteFunction(PingInput("hi server")).toStream @@ -130,17 +142,18 @@ object TestServerSpec extends SimpleIOSuite { ref <- SignallingRef[IO, Option[String]](none).toStream clientSideChannel <- setup( channel => { - val testClient = ClientStub(TestClient, channel) - AlgebraWrapper(new TestServer[IO] { - override def greet(name: String): IO[GreetOutput] = ??? - - override def ping(ping: String): IO[Unit] = { - if (ping == "fail") IO.raiseError(new RuntimeException("throwing internal error on demand")) - else testClient.pong("pong") - } - }) + IO.fromEither(ClientStub(TestClient, channel)).map { testClient => + AlgebraWrapper(new TestServer[IO] { + override def greet(name: String): IO[GreetOutput] = ??? + + override def ping(ping: String): IO[Unit] = { + if (ping == "fail") IO.raiseError(new RuntimeException("throwing internal error on demand")) + else testClient.pong("pong") + } + }) + } }, - _ => AlgebraWrapper(new Client(ref)) + _ => IO(AlgebraWrapper(new Client(ref))) ) remoteFunction = clientSideChannel.notificationStub[PingInput]("ping") _ <- remoteFunction(PingInput("fail")).toStream @@ -158,11 +171,11 @@ object TestServerSpec extends SimpleIOSuite { for { clientSideChannel <- setup(_ => { - AlgebraWrapper(new TestServer[IO] { + IO(AlgebraWrapper(new TestServer[IO] { override def greet(name: String): IO[GreetOutput] = IO.raiseError(NotWelcomeError(s"$name is not welcome")) override def ping(ping: String): IO[Unit] = ??? - }) + })) }) remoteFunction = clientSideChannel.simpleStub[GreetInput, GreetOutput]("greet") result <- remoteFunction(GreetInput("Alice")).attempt.toStream @@ -184,11 +197,11 @@ object TestServerSpec extends SimpleIOSuite { for { clientSideChannel <- setup(_ => { - AlgebraWrapper(new TestServer[IO] { + IO(AlgebraWrapper(new TestServer[IO] { override def greet(name: String): IO[GreetOutput] = IO.raiseError(new RuntimeException("some other error")) override def ping(ping: String): IO[Unit] = ??? - }) + })) }) remoteFunction = clientSideChannel.simpleStub[GreetInput, GreetOutput]("greet") result <- remoteFunction(GreetInput("Alice")).attempt.toStream @@ -210,10 +223,11 @@ object TestServerSpec extends SimpleIOSuite { clientSideChannel <- setupAux( None, channel => { - val testClient = ClientStub(TestClient, channel) - Seq(AlgebraWrapper(new ServerImpl(testClient)), AlgebraWrapper(new WeatherServiceImpl())) + IO.fromEither(ClientStub(TestClient, channel)).map { testClient => + List(AlgebraWrapper(new ServerImpl(testClient)), AlgebraWrapper(new WeatherServiceImpl())) + } }, - _ => Seq.empty + _ => IO(List.empty) ) greetResult <- { implicit val inputEncoder: Encoder[GreetInput] = CirceJsonCodec.fromSchema @@ -243,7 +257,7 @@ object TestServerSpec extends SimpleIOSuite { } for { - clientSideChannel <- setup(_ => AlgebraWrapper(ServerImpl)) + clientSideChannel <- setup(_ => IO(AlgebraWrapper(ServerImpl))) remoteFunction = clientSideChannel.simpleStub[GreetInput, GreetOutput]("greetWithPayload") result <- remoteFunction(GreetInput("Bob")).toStream } yield { diff --git a/modules/smithy4s/src/main/scala/jsonrpclib/smithy4sinterop/ClientStub.scala b/modules/smithy4s/src/main/scala/jsonrpclib/smithy4sinterop/ClientStub.scala index dbc8fde..f607b7f 100644 --- a/modules/smithy4s/src/main/scala/jsonrpclib/smithy4sinterop/ClientStub.scala +++ b/modules/smithy4s/src/main/scala/jsonrpclib/smithy4sinterop/ClientStub.scala @@ -1,16 +1,17 @@ package jsonrpclib.smithy4sinterop import io.circe.Codec -import io.circe.HCursor import jsonrpclib.Channel import jsonrpclib.ErrorPayload import jsonrpclib.Monadic import jsonrpclib.Monadic.syntax._ import jsonrpclib.ProtocolError import smithy4s.~> +import smithy4s.checkProtocol import smithy4s.schema._ import smithy4s.Service import smithy4s.ShapeId +import smithy4s.UnsupportedProtocolError object ClientStub { @@ -19,16 +20,36 @@ object ClientStub { * Given a Smithy `Service[Alg]` and a JSON-RPC communication `Channel[F]`, this constructs a fully functional client * that translates method calls into JSON-RPC messages sent over the channel. * + * Before constructing the client, this method checks whether the given Smithy service supports the JSON-RPC + * protocol. If not, it returns a `Left(UnsupportedProtocolError)`. + * + * Supports both standard request-response and fire-and-forget notification endpoints. + * * Usage: * {{{ - * val stub: MyService[IO] = ClientStub(myService, myChannel) - * val response: IO[String] = stub.hello("world") + * val stubOrError: Either[UnsupportedProtocolError, MyService[IO]] = + * ClientStub(myService, myChannel) + * + * val result: IO[Unit] = stubOrError match { + * case Right(stub) => stub.hello("world").void + * case Left(error) => IO.raiseError(new RuntimeException(error.toString)) + * } * }}} * - * Supports both standard request-response and fire-and-forget notification endpoints. + * @param service + * Smithy service definition + * @param channel + * JSON-RPC communication channel + * @return + * Either an error if the protocol is unsupported or a compiled client implementation */ - def apply[Alg[_[_, _, _, _, _]], F[_]: Monadic](service: Service[Alg], channel: Channel[F]): service.Impl[F] = - new ClientStub(JsonRpcTransformations.apply(service), channel).compile + def apply[Alg[_[_, _, _, _, _]], F[_]: Monadic]( + service: Service[Alg], + channel: Channel[F] + ): Either[UnsupportedProtocolError, service.Impl[F]] = + checkProtocol(service, jsonrpclib.JsonRpc).map(_ => + new ClientStub(JsonRpcTransformations.apply(service), channel).compile + ) } private class ClientStub[Alg[_[_, _, _, _, _]], F[_]: Monadic](val service: Service[Alg], channel: Channel[F]) { diff --git a/modules/smithy4s/src/main/scala/jsonrpclib/smithy4sinterop/ServerEndpoints.scala b/modules/smithy4s/src/main/scala/jsonrpclib/smithy4sinterop/ServerEndpoints.scala index 92843f4..dfcff09 100644 --- a/modules/smithy4s/src/main/scala/jsonrpclib/smithy4sinterop/ServerEndpoints.scala +++ b/modules/smithy4s/src/main/scala/jsonrpclib/smithy4sinterop/ServerEndpoints.scala @@ -7,12 +7,13 @@ import jsonrpclib.ErrorPayload import jsonrpclib.Monadic import jsonrpclib.Monadic.syntax._ import jsonrpclib.Payload +import smithy4s.{Endpoint => Smithy4sEndpoint} +import smithy4s.checkProtocol import smithy4s.kinds.FunctorAlgebra import smithy4s.kinds.FunctorInterpreter import smithy4s.schema.ErrorSchema import smithy4s.Service - -import _root_.smithy4s.{Endpoint => Smithy4sEndpoint} +import smithy4s.UnsupportedProtocolError object ServerEndpoints { @@ -21,27 +22,40 @@ object ServerEndpoints { * Given a Smithy `FunctorAlgebra[Alg, F]`, this extracts all operations and compiles them into JSON-RPC * `Endpoint[F]` handlers that can be mounted on a communication channel (e.g. `FS2Channel`). * + * Before generating endpoints, this method checks whether the service is compatible with the JSON-RPC protocol. If + * the protocol is unsupported, it returns a `Left(UnsupportedProtocolError)`. + * * Supports both standard request-response and notification-style endpoints, as well as Smithy-modeled errors. * * Usage: * {{{ - * val endpoints = ServerEndpoints(new ServerImpl) - * channel.withEndpoints(endpoints) + * val endpointsOrError = ServerEndpoints(new ServerImpl) + * endpointsOrError match { + * case Right(endpoints) => channel.withEndpoints(endpoints) + * case Left(error) => sys.error(s"Incompatible protocol: $error") + * } * }}} + * + * @param impl + * Smithy FunctorAlgebra implementation + * @return + * Either an error if the protocol is unsupported or a list of compiled JSON-RPC endpoints */ def apply[Alg[_[_, _, _, _, _]], F[_]]( impl: FunctorAlgebra[Alg, F] - )(implicit service: Service[Alg], F: Monadic[F]): List[Endpoint[F]] = { - val transformedService = JsonRpcTransformations.apply(service) - val interpreter: transformedService.FunctorInterpreter[F] = transformedService.toPolyFunction(impl) - val codecCache = CirceJsonCodec.Codec.createCache() - transformedService.endpoints.toList.flatMap { smithy4sEndpoint => - EndpointSpec - .fromHints(smithy4sEndpoint.hints) - .map { endpointSpec => - jsonRPCEndpoint(smithy4sEndpoint, endpointSpec, interpreter, codecCache) - } - .toList + )(implicit service: Service[Alg], F: Monadic[F]): Either[UnsupportedProtocolError, List[Endpoint[F]]] = { + checkProtocol(service, jsonrpclib.JsonRpc).map { _ => + val transformedService = JsonRpcTransformations.apply(service) + val interpreter: transformedService.FunctorInterpreter[F] = transformedService.toPolyFunction(impl) + val codecCache = CirceJsonCodec.Codec.createCache() + transformedService.endpoints.toList.flatMap { smithy4sEndpoint => + EndpointSpec + .fromHints(smithy4sEndpoint.hints) + .map { endpointSpec => + jsonRPCEndpoint(smithy4sEndpoint, endpointSpec, interpreter, codecCache) + } + .toList + } } } From 07db4d7c01b43e40d172c2fbf69d34afe507e1bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Koz=C5=82owski?= Date: Fri, 20 Jun 2025 00:28:51 +0200 Subject: [PATCH 45/47] Use external trait codegen plugin --- project/PathRef.scala | 32 ------- project/SmithyTraitCodegen.scala | 127 ------------------------- project/SmithyTraitCodegenPlugin.scala | 95 ------------------ project/plugins.sbt | 2 + 4 files changed, 2 insertions(+), 254 deletions(-) delete mode 100644 project/PathRef.scala delete mode 100644 project/SmithyTraitCodegen.scala delete mode 100644 project/SmithyTraitCodegenPlugin.scala diff --git a/project/PathRef.scala b/project/PathRef.scala deleted file mode 100644 index 45539c5..0000000 --- a/project/PathRef.scala +++ /dev/null @@ -1,32 +0,0 @@ -import sbt.io.Hash -import sbt.util.FileInfo -import sbt.util.HashFileInfo -import sjsonnew.* - -import java.io.File - -case class PathRef(path: os.Path) - -object PathRef { - - def apply(f: File): PathRef = PathRef(os.Path(f)) - - implicit val pathFormat: JsonFormat[PathRef] = - BasicJsonProtocol.projectFormat[PathRef, HashFileInfo]( - p => - if (os.isFile(p.path)) FileInfo.hash(p.path.toIO) - else - // If the path is a directory, we get the hashes of all files - // then hash the concatenation of the hash's bytes. - FileInfo.hash( - p.path.toIO, - Hash( - os.walk(p.path) - .map(_.toIO) - .map(Hash(_)) - .foldLeft(Array.emptyByteArray)(_ ++ _) - ) - ), - hash => PathRef(hash.file) - ) -} diff --git a/project/SmithyTraitCodegen.scala b/project/SmithyTraitCodegen.scala deleted file mode 100644 index 551d51d..0000000 --- a/project/SmithyTraitCodegen.scala +++ /dev/null @@ -1,127 +0,0 @@ -import sbt.* -import sbt.io.IO -import software.amazon.smithy.build.FileManifest -import software.amazon.smithy.build.PluginContext -import software.amazon.smithy.model.node.ArrayNode -import software.amazon.smithy.model.node.ObjectNode -import software.amazon.smithy.model.shapes.ShapeId -import software.amazon.smithy.model.Model -import software.amazon.smithy.traitcodegen.TraitCodegenPlugin - -import java.io.File -import java.nio.file.Paths -import java.util.UUID - -object SmithyTraitCodegen { - - import sjsonnew.* - - import BasicJsonProtocol.* - - case class Args( - javaPackage: String, - smithyNamespace: String, - targetDir: os.Path, - smithySourcesDir: PathRef, - dependencies: List[PathRef] - ) - object Args { - - // format: off - private type ArgsDeconstructed = String :*: String :*: os.Path :*: PathRef :*: List[PathRef] :*: LNil - // format: on - - private implicit val pathFormat: JsonFormat[os.Path] = - BasicJsonProtocol.projectFormat[os.Path, File](p => p.toIO, file => os.Path(file)) - - implicit val argsIso = - LList.iso[Args, ArgsDeconstructed]( - { args: Args => - ("javaPackage", args.javaPackage) :*: - ("smithyNamespace", args.smithyNamespace) :*: - ("targetDir", args.targetDir) :*: - ("smithySourcesDir", args.smithySourcesDir) :*: - ("dependencies", args.dependencies) :*: - LNil - }, - { - case (_, javaPackage) :*: - (_, smithyNamespace) :*: - (_, targetDir) :*: - (_, smithySourcesDir) :*: - (_, dependencies) :*: - LNil => - Args( - javaPackage = javaPackage, - smithyNamespace = smithyNamespace, - targetDir = targetDir, - smithySourcesDir = smithySourcesDir, - dependencies = dependencies - ) - } - ) - - } - - case class Output(metaDir: File, javaDir: File) - - object Output { - - // format: off - private type OutputDeconstructed = File :*: File :*: LNil - // format: on - - implicit val outputIso = - LList.iso[Output, OutputDeconstructed]( - { output: Output => - ("metaDir", output.metaDir) :*: - ("javaDir", output.javaDir) :*: - LNil - }, - { - case (_, metaDir) :*: - (_, javaDir) :*: - LNil => - Output( - metaDir = metaDir, - javaDir = javaDir - ) - } - ) - } - - def generate(args: Args): Output = { - val outputDir = args.targetDir / "smithy-trait-generator-output" - val genDir = outputDir / "java" - val metaDir = outputDir / "meta" - os.remove.all(outputDir) - List(outputDir, genDir, metaDir).foreach(os.makeDir.all(_)) - - val manifest = FileManifest.create(genDir.toNIO) - - val model = args.dependencies - .foldLeft(Model.assembler().addImport(args.smithySourcesDir.path.toNIO)) { case (acc, dep) => - acc.addImport(dep.path.toNIO) - } - .assemble() - .unwrap() - val context = PluginContext - .builder() - .model(model) - .fileManifest(manifest) - .settings( - ObjectNode - .builder() - .withMember("package", args.javaPackage) - .withMember("namespace", args.smithyNamespace) - .withMember("header", ArrayNode.builder.build()) - .withMember("excludeTags", ArrayNode.builder.withValue("nocodegen").build()) - .build() - ) - .build() - val plugin = new TraitCodegenPlugin() - plugin.execute(context) - os.move(genDir / "META-INF", metaDir / "META-INF") - Output(metaDir = metaDir.toIO, javaDir = genDir.toIO) - } -} diff --git a/project/SmithyTraitCodegenPlugin.scala b/project/SmithyTraitCodegenPlugin.scala deleted file mode 100644 index 78903f4..0000000 --- a/project/SmithyTraitCodegenPlugin.scala +++ /dev/null @@ -1,95 +0,0 @@ -import sbt.* -import sbt.plugins.JvmPlugin -import software.amazon.smithy.build.FileManifest -import software.amazon.smithy.build.PluginContext -import software.amazon.smithy.model.node.ArrayNode -import software.amazon.smithy.model.node.ObjectNode -import software.amazon.smithy.model.Model -import software.amazon.smithy.traitcodegen.TraitCodegenPlugin - -import Keys.* - -object SmithyTraitCodegenPlugin extends AutoPlugin { - override def trigger: PluginTrigger = noTrigger - override def requires: Plugins = JvmPlugin - - object autoImport { - val smithyTraitCodegenJavaPackage = - settingKey[String]("The java target package where the generated smithy traits will be created") - val smithyTraitCodegenNamespace = settingKey[String]("The smithy namespace where the traits are defined") - val smithyTraitCodegenDependencies = settingKey[List[ModuleID]]("Dependencies to be added into codegen model") - } - import autoImport.* - - override def projectSettings: Seq[Setting[?]] = - Seq( - Keys.generateSmithyTraits := Def.task { - import sbt.util.CacheImplicits.* - val s = (Compile / streams).value - val logger = sLog.value - - val report = update.value - val dependencies = smithyTraitCodegenDependencies.value - val jars = - dependencies.flatMap(m => - report.matching(moduleFilter(organization = m.organization, name = m.name, revision = m.revision)) - ) - require( - jars.size == dependencies.size, - "Not all dependencies required for smithy-trait-codegen have been found" - ) - - val args = SmithyTraitCodegen.Args( - javaPackage = smithyTraitCodegenJavaPackage.value, - smithyNamespace = smithyTraitCodegenNamespace.value, - targetDir = os.Path((Compile / target).value), - smithySourcesDir = PathRef((Compile / resourceDirectory).value / "META-INF" / "smithy"), - dependencies = jars.map(PathRef(_)).toList - ) - val cachedCodegen = - Tracked.inputChanged[SmithyTraitCodegen.Args, SmithyTraitCodegen.Output]( - s.cacheStoreFactory.make("smithy-trait-codegen-args") - ) { - Function.untupled( - Tracked - .lastOutput[(Boolean, SmithyTraitCodegen.Args), SmithyTraitCodegen.Output]( - s.cacheStoreFactory.make("smithy-trait-codegen-output") - ) { case ((inputChanged, codegenArgs), cached) => - cached - .filter(_ => !inputChanged) - .fold { - SmithyTraitCodegen.generate(codegenArgs) - } { last => - logger.info(s"Using cached result of smithy-trait-codegen") - last - } - } - ) - } - cachedCodegen(args) - }.value, - Compile / sourceGenerators += Def.task { - val codegenOutput = (Compile / Keys.generateSmithyTraits).value - cleanCopy(source = codegenOutput.javaDir, target = (Compile / sourceManaged).value / "java") - }, - Compile / resourceGenerators += Def.task { - val codegenOutput = (Compile / Keys.generateSmithyTraits).value - cleanCopy(source = codegenOutput.metaDir, target = (Compile / resourceManaged).value) - }.taskValue, - libraryDependencies ++= smithyTraitCodegenDependencies.value - ) - - private def cleanCopy(source: File, target: File) = { - val sourcePath = os.Path(source) - val targetPath = os.Path(target) - os.remove.all(targetPath) - os.copy(from = sourcePath, to = targetPath, createFolders = true) - os.walk(targetPath).map(_.toIO).filter(_.isFile()) - } - - object Keys { - val generateSmithyTraits = - taskKey[SmithyTraitCodegen.Output]("Run AWS smithy-trait-codegen on the protocol specs") - } - -} diff --git a/project/plugins.sbt b/project/plugins.sbt index 1a67d0e..b79993b 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -16,4 +16,6 @@ addSbtPlugin("org.scalameta" % "sbt-scalafmt" % "2.5.4") addSbtPlugin("com.disneystreaming.smithy4s" % "smithy4s-sbt-codegen" % "0.18.37") +addSbtPlugin("org.polyvariant" % "smithy-trait-codegen-sbt" % "0.1.0") + addDependencyTreePlugin From 10f347115ae21d0313ff7b0df381412777af55f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Koz=C5=82owski?= Date: Fri, 20 Jun 2025 02:24:21 +0200 Subject: [PATCH 46/47] bump to 0.2 --- project/plugins.sbt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/project/plugins.sbt b/project/plugins.sbt index b79993b..9494b9c 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -16,6 +16,6 @@ addSbtPlugin("org.scalameta" % "sbt-scalafmt" % "2.5.4") addSbtPlugin("com.disneystreaming.smithy4s" % "smithy4s-sbt-codegen" % "0.18.37") -addSbtPlugin("org.polyvariant" % "smithy-trait-codegen-sbt" % "0.1.0") +addSbtPlugin("org.polyvariant" % "smithy-trait-codegen-sbt" % "0.2.0") addDependencyTreePlugin From ac273389e36df03b1a046f0fef7b9dfd4806d02c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Koz=C5=82owski?= Date: Fri, 20 Jun 2025 02:24:51 +0200 Subject: [PATCH 47/47] remove deps --- project/build.sbt | 4 ---- 1 file changed, 4 deletions(-) delete mode 100644 project/build.sbt diff --git a/project/build.sbt b/project/build.sbt deleted file mode 100644 index 969768d..0000000 --- a/project/build.sbt +++ /dev/null @@ -1,4 +0,0 @@ -libraryDependencies ++= Seq( - "software.amazon.smithy" % "smithy-trait-codegen", - "software.amazon.smithy" % "smithy-model" -).map(_ % "1.58.0")