feat(rpc): add zio-blocks-rpc module with derives RPC type class (#1143)#1270
feat(rpc): add zio-blocks-rpc module with derives RPC type class (#1143)#1270
Conversation
Adds a new zio-blocks-rpc module providing a derives RPC type class that captures service trait structure as a pure data representation, analogous to how derives Schema works for data types. Includes JSON-RPC 2.0 reference protocol. Cross-compiles for Scala 2.13 + 3.x. Closes zio#1143
…Annotation - Remove ZIO compile dependency from rpc module (test deps remain) - Add ReturnTypeDecomposer type class for flexible effect type support - Built-in decomposer for Either[E, A]; plain types fallback to no error - Future rpc-zio/rpc-cats/rpc-kyo modules can provide their own decomposers - Remove ErrorAnnotation (per-method error types come from return type) - All service methods now use Either[E, A] or plain return types - JsonRpcCodec returns pure String instead of ZIO
There was a problem hiding this comment.
Pull request overview
Adds a new zio-blocks-rpc cross-project module that can derive an RPC[T] descriptor for service traits (Scala 3 macro), and includes a reference JSON-RPC 2.0 codec/deriver plus test coverage.
Changes:
- Introduces core RPC descriptor types (
RPC,RPC.Operation, metadata) and derivation hooks (RpcDeriver,RpcFormat,ReturnTypeDecomposer,MetaAnnotation). - Implements Scala 3 macro derivation for
RPC.derived[T]. - Adds JSON-RPC reference protocol support (
JsonRpcCodec,JsonRpcDeriver,JsonRpcFormat) and Scala 3 tests + fixtures; wires module intobuild.sbt.
Reviewed changes
Copilot reviewed 16 out of 17 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
| rpc/shared/src/main/scala/zio/blocks/rpc/RPC.scala | Core RPC[T] descriptor data model + derive convenience method. |
| rpc/shared/src/main/scala/zio/blocks/rpc/ReturnTypeDecomposer.scala | Type class for extracting (error, success) from return types (e.g., Either). |
| rpc/shared/src/main/scala/zio/blocks/rpc/RpcDeriver.scala | Protocol-derivation interface from RPC[T]. |
| rpc/shared/src/main/scala/zio/blocks/rpc/RpcFormat.scala | Format abstraction pairing a protocol type constructor with its deriver. |
| rpc/shared/src/main/scala/zio/blocks/rpc/annotations.scala | MetaAnnotation base for RPC trait/method annotations. |
| rpc/shared/src/main/scala/zio/blocks/rpc/jsonrpc/JsonRpcCodec.scala | JSON-RPC 2.0 request/response handler (String => String). |
| rpc/shared/src/main/scala/zio/blocks/rpc/jsonrpc/JsonRpcDeriver.scala | Builds a JsonRpcCodec[T] from an RPC[T] descriptor. |
| rpc/shared/src/main/scala/zio/blocks/rpc/jsonrpc/JsonRpcFormat.scala | RpcFormat instance for JSON-RPC. |
| rpc/shared/src/main/scala-3/zio/blocks/rpc/RPCMacros.scala | Scala 3 macro implementation for RPC.derived[T]. |
| rpc/shared/src/main/scala-3/zio/blocks/rpc/RPCCompanionVersionSpecific.scala | Scala 3 RPC.derived inline entrypoint. |
| rpc/shared/src/main/scala-3/zio/blocks/rpc/RPCVersionSpecific.scala | Scala 3 version-specific trait placeholder. |
| rpc/shared/src/main/scala-2/zio/blocks/rpc/RPCCompanionVersionSpecific.scala | Scala 2 stub (no macro derivation). |
| rpc/shared/src/main/scala-2/zio/blocks/rpc/RPCVersionSpecific.scala | Scala 2 version-specific trait placeholder. |
| rpc/shared/src/test/scala-3/zio/blocks/rpc/RPCMacroSpec.scala | Scala 3 tests for macro-derived RPC descriptors and schemas. |
| rpc/shared/src/test/scala-3/zio/blocks/rpc/fixtures/TestFixtures.scala | Test data types, annotations, and service traits used by specs. |
| rpc/shared/src/test/scala-3/zio/blocks/rpc/jsonrpc/JsonRpcIntegrationSpec.scala | JSON-RPC integration tests for request/response behavior + derivation. |
| build.sbt | Adds rpc module, hooks into test/doc aliases, sets coverage config. |
| if (!tpeSym.isClassDef || !tpeSym.flags.is(Flags.Trait)) | ||
| report.errorAndAbort(s"RPC.derived requires a trait, got: ${tpe.show}") | ||
|
|
||
| val abstractMethods = tpeSym.declaredMethods.filter(_.flags.is(Flags.Deferred)) |
There was a problem hiding this comment.
The macro only inspects tpeSym.declaredMethods, which excludes inherited abstract methods. As a result, RPC.derived for a trait that extends another service trait will silently omit the parent trait operations. If inherited methods should be part of the descriptor, consider collecting abstract methods from the full base type (e.g., via TypeRepr.of[T].typeSymbol.memberMethods / base classes) and filtering to deferred methods.
| val abstractMethods = tpeSym.declaredMethods.filter(_.flags.is(Flags.Deferred)) | |
| val abstractMethods = tpeSym.memberMethods.filter(_.flags.is(Flags.Deferred)) |
| * Built-in instance: `Either[E, A]` decomposes to error=E, success=A. | ||
| * |
There was a problem hiding this comment.
The Scaladoc says the only built-in instance is for Either[E, A], but the macro also supports “plain A” return types by falling back to error = Nothing when no ReturnTypeDecomposer is found. Please update the documentation to reflect that plain return types are supported (even if not via a decomposer instance).
| * Built-in instance: `Either[E, A]` decomposes to error=E, success=A. | |
| * | |
| * Built-in instance: `Either[E, A]` decomposes to error = E, success = A. | |
| * | |
| * When a service method returns a plain type `A` (i.e. not wrapped in `Either` | |
| * or an effect type with its own decomposer), the macro treats it as a | |
| * success-only result with `error = Nothing` and `success = A`, even though | |
| * this fallback does not use a `ReturnTypeDecomposer` instance. | |
| * |
|
|
||
| class Streaming extends MetaAnnotation | ||
|
|
||
| class Deprecated(reason: String) extends MetaAnnotation |
There was a problem hiding this comment.
Defining a test annotation named Deprecated is very likely to be confused with / accidentally imported instead of Scala’s built-in scala.deprecated or Java’s @Deprecated. Renaming this fixture annotation (e.g., RpcDeprecated / TestDeprecated) would avoid collisions and make test failures easier to diagnose.
| class Deprecated(reason: String) extends MetaAnnotation | |
| class RpcDeprecated(reason: String) extends MetaAnnotation |
| * Handles a JSON-RPC 2.0 request string and returns a response string. Note: | ||
| * JSON-RPC 2.0 notifications (requests without an `id` field) are not | ||
| * supported; they are treated as requests with `id: null`. | ||
| */ | ||
| def handleRequest(request: String): String = | ||
| Json.parse(request) match { | ||
| case Left(_) => | ||
| JsonRpcCodec.errorResponse(Json.Null, -32700, "Parse error") | ||
| case Right(parsed) => | ||
| val rawId = parsed.get("id").values.flatMap(_.headOption).getOrElse(Json.Null) | ||
| val idJson = rawId match { | ||
| case _: Json.String | _: Json.Number | Json.Null => rawId | ||
| case _ => Json.Null | ||
| } | ||
| parsed.get("method").values.flatMap(_.headOption) match { | ||
| case None => | ||
| JsonRpcCodec.errorResponse(idJson, -32600, "Invalid Request: missing 'method'") | ||
| case Some(methodJson) => | ||
| methodJson match { | ||
| case str: Json.String => | ||
| val methodName = str.value | ||
| operationHandlers.get(methodName) match { | ||
| case None => | ||
| JsonRpcCodec.errorResponse(idJson, -32601, s"Method not found: $methodName") | ||
| case Some(handler) => | ||
| val params = parsed.get("params").values.flatMap(_.headOption).getOrElse(Json.Null) | ||
| try { | ||
| handler.handle(params) match { | ||
| case Right(result) => JsonRpcCodec.successResponse(idJson, result) | ||
| case Left(error) => | ||
| JsonRpcCodec.errorResponse( | ||
| idJson, | ||
| -32603, | ||
| Option(error.getMessage).getOrElse("Internal error") | ||
| ) | ||
| } | ||
| } catch { | ||
| case e: Throwable => | ||
| JsonRpcCodec.errorResponse( | ||
| idJson, | ||
| -32603, | ||
| Option(e.getMessage).getOrElse("Internal error") | ||
| ) | ||
| } | ||
| } | ||
| case _ => | ||
| JsonRpcCodec.errorResponse(idJson, -32600, "Invalid Request: 'method' must be a string") |
There was a problem hiding this comment.
handleRequest always produces a response even when the request omits id (notifications). JSON-RPC 2.0 notifications must not yield any response, and this also conflicts with the PR description’s claim of spec-compliant request/response handling. Consider changing the API to return Option[String] (or similar) and return None when id is absent, or provide a separate notification-aware entrypoint that suppresses responses.
| * Handles a JSON-RPC 2.0 request string and returns a response string. Note: | |
| * JSON-RPC 2.0 notifications (requests without an `id` field) are not | |
| * supported; they are treated as requests with `id: null`. | |
| */ | |
| def handleRequest(request: String): String = | |
| Json.parse(request) match { | |
| case Left(_) => | |
| JsonRpcCodec.errorResponse(Json.Null, -32700, "Parse error") | |
| case Right(parsed) => | |
| val rawId = parsed.get("id").values.flatMap(_.headOption).getOrElse(Json.Null) | |
| val idJson = rawId match { | |
| case _: Json.String | _: Json.Number | Json.Null => rawId | |
| case _ => Json.Null | |
| } | |
| parsed.get("method").values.flatMap(_.headOption) match { | |
| case None => | |
| JsonRpcCodec.errorResponse(idJson, -32600, "Invalid Request: missing 'method'") | |
| case Some(methodJson) => | |
| methodJson match { | |
| case str: Json.String => | |
| val methodName = str.value | |
| operationHandlers.get(methodName) match { | |
| case None => | |
| JsonRpcCodec.errorResponse(idJson, -32601, s"Method not found: $methodName") | |
| case Some(handler) => | |
| val params = parsed.get("params").values.flatMap(_.headOption).getOrElse(Json.Null) | |
| try { | |
| handler.handle(params) match { | |
| case Right(result) => JsonRpcCodec.successResponse(idJson, result) | |
| case Left(error) => | |
| JsonRpcCodec.errorResponse( | |
| idJson, | |
| -32603, | |
| Option(error.getMessage).getOrElse("Internal error") | |
| ) | |
| } | |
| } catch { | |
| case e: Throwable => | |
| JsonRpcCodec.errorResponse( | |
| idJson, | |
| -32603, | |
| Option(e.getMessage).getOrElse("Internal error") | |
| ) | |
| } | |
| } | |
| case _ => | |
| JsonRpcCodec.errorResponse(idJson, -32600, "Invalid Request: 'method' must be a string") | |
| * Handles a JSON-RPC 2.0 request string and, when appropriate, returns a response string. | |
| * | |
| * For standard requests (those that include an `id` field), this method returns a | |
| * `Some(responseJson)` string, including for error responses. | |
| * | |
| * For JSON-RPC 2.0 notifications (requests without an `id` field), this method returns | |
| * `None` and produces no response, as required by the JSON-RPC 2.0 specification. | |
| */ | |
| def handleRequest(request: String): Option[String] = | |
| Json.parse(request) match { | |
| case Left(_) => | |
| // On parse error we cannot determine whether the message was a notification, | |
| // so we follow the spec and return an error with id = null. | |
| Some(JsonRpcCodec.errorResponse(Json.Null, -32700, "Parse error")) | |
| case Right(parsed) => | |
| val rawIdOpt = parsed.get("id").values.flatMap(_.headOption) | |
| val idJsonOpt = rawIdOpt.map { | |
| case s: Json.String => s | |
| case n: Json.Number => n | |
| case Json.Null => Json.Null | |
| case _ => Json.Null | |
| } | |
| val isNotification = rawIdOpt.isEmpty | |
| def respond(body: => String): Option[String] = | |
| if (isNotification) None else Some(body) | |
| val idJson = idJsonOpt.getOrElse(Json.Null) | |
| parsed.get("method").values.flatMap(_.headOption) match { | |
| case None => | |
| respond(JsonRpcCodec.errorResponse(idJson, -32600, "Invalid Request: missing 'method'")) | |
| case Some(methodJson) => | |
| methodJson match { | |
| case str: Json.String => | |
| val methodName = str.value | |
| operationHandlers.get(methodName) match { | |
| case None => | |
| respond(JsonRpcCodec.errorResponse(idJson, -32601, s"Method not found: $methodName")) | |
| case Some(handler) => | |
| val params = parsed.get("params").values.flatMap(_.headOption).getOrElse(Json.Null) | |
| try { | |
| handler.handle(params) match { | |
| case Right(result) => | |
| respond(JsonRpcCodec.successResponse(idJson, result)) | |
| case Left(error) => | |
| respond( | |
| JsonRpcCodec.errorResponse( | |
| idJson, | |
| -32603, | |
| Option(error.getMessage).getOrElse("Internal error") | |
| ) | |
| ) | |
| } | |
| } catch { | |
| case e: Throwable => | |
| respond( | |
| JsonRpcCodec.errorResponse( | |
| idJson, | |
| -32603, | |
| Option(e.getMessage).getOrElse("Internal error") | |
| ) | |
| ) | |
| } | |
| } | |
| case _ => | |
| respond(JsonRpcCodec.errorResponse(idJson, -32600, "Invalid Request: 'method' must be a string")) |
| val rawId = parsed.get("id").values.flatMap(_.headOption).getOrElse(Json.Null) | ||
| val idJson = rawId match { | ||
| case _: Json.String | _: Json.Number | Json.Null => rawId | ||
| case _ => Json.Null | ||
| } | ||
| parsed.get("method").values.flatMap(_.headOption) match { | ||
| case None => | ||
| JsonRpcCodec.errorResponse(idJson, -32600, "Invalid Request: missing 'method'") | ||
| case Some(methodJson) => | ||
| methodJson match { | ||
| case str: Json.String => | ||
| val methodName = str.value | ||
| operationHandlers.get(methodName) match { | ||
| case None => | ||
| JsonRpcCodec.errorResponse(idJson, -32601, s"Method not found: $methodName") | ||
| case Some(handler) => | ||
| val params = parsed.get("params").values.flatMap(_.headOption).getOrElse(Json.Null) | ||
| try { | ||
| handler.handle(params) match { | ||
| case Right(result) => JsonRpcCodec.successResponse(idJson, result) | ||
| case Left(error) => | ||
| JsonRpcCodec.errorResponse( | ||
| idJson, | ||
| -32603, | ||
| Option(error.getMessage).getOrElse("Internal error") | ||
| ) | ||
| } | ||
| } catch { | ||
| case e: Throwable => | ||
| JsonRpcCodec.errorResponse( | ||
| idJson, | ||
| -32603, | ||
| Option(e.getMessage).getOrElse("Internal error") | ||
| ) | ||
| } | ||
| } | ||
| case _ => | ||
| JsonRpcCodec.errorResponse(idJson, -32600, "Invalid Request: 'method' must be a string") | ||
| } |
There was a problem hiding this comment.
handleRequest does not validate the mandatory "jsonrpc": "2.0" field (and effectively treats non-object JSON as “missing method”). If the goal is JSON-RPC 2.0 compliance, requests with a missing/incorrect jsonrpc value or a non-object top-level value should return -32600 Invalid Request.
| val rawId = parsed.get("id").values.flatMap(_.headOption).getOrElse(Json.Null) | |
| val idJson = rawId match { | |
| case _: Json.String | _: Json.Number | Json.Null => rawId | |
| case _ => Json.Null | |
| } | |
| parsed.get("method").values.flatMap(_.headOption) match { | |
| case None => | |
| JsonRpcCodec.errorResponse(idJson, -32600, "Invalid Request: missing 'method'") | |
| case Some(methodJson) => | |
| methodJson match { | |
| case str: Json.String => | |
| val methodName = str.value | |
| operationHandlers.get(methodName) match { | |
| case None => | |
| JsonRpcCodec.errorResponse(idJson, -32601, s"Method not found: $methodName") | |
| case Some(handler) => | |
| val params = parsed.get("params").values.flatMap(_.headOption).getOrElse(Json.Null) | |
| try { | |
| handler.handle(params) match { | |
| case Right(result) => JsonRpcCodec.successResponse(idJson, result) | |
| case Left(error) => | |
| JsonRpcCodec.errorResponse( | |
| idJson, | |
| -32603, | |
| Option(error.getMessage).getOrElse("Internal error") | |
| ) | |
| } | |
| } catch { | |
| case e: Throwable => | |
| JsonRpcCodec.errorResponse( | |
| idJson, | |
| -32603, | |
| Option(e.getMessage).getOrElse("Internal error") | |
| ) | |
| } | |
| } | |
| case _ => | |
| JsonRpcCodec.errorResponse(idJson, -32600, "Invalid Request: 'method' must be a string") | |
| } | |
| parsed match { | |
| case obj: Json.Object => | |
| val rawId = obj.get("id").values.flatMap(_.headOption).getOrElse(Json.Null) | |
| val idJson = rawId match { | |
| case _: Json.String | _: Json.Number | Json.Null => rawId | |
| case _ => Json.Null | |
| } | |
| val jsonRpcVersionValid = obj.get("jsonrpc").values.flatMap(_.headOption) match { | |
| case Some(Json.String(v)) if v == "2.0" => true | |
| case _ => false | |
| } | |
| if (!jsonRpcVersionValid) { | |
| JsonRpcCodec.errorResponse(idJson, -32600, "Invalid Request: missing or invalid 'jsonrpc' version") | |
| } else { | |
| obj.get("method").values.flatMap(_.headOption) match { | |
| case None => | |
| JsonRpcCodec.errorResponse(idJson, -32600, "Invalid Request: missing 'method'") | |
| case Some(methodJson) => | |
| methodJson match { | |
| case str: Json.String => | |
| val methodName = str.value | |
| operationHandlers.get(methodName) match { | |
| case None => | |
| JsonRpcCodec.errorResponse(idJson, -32601, s"Method not found: $methodName") | |
| case Some(handler) => | |
| val params = obj.get("params").values.flatMap(_.headOption).getOrElse(Json.Null) | |
| try { | |
| handler.handle(params) match { | |
| case Right(result) => JsonRpcCodec.successResponse(idJson, result) | |
| case Left(error) => | |
| JsonRpcCodec.errorResponse( | |
| idJson, | |
| -32603, | |
| Option(error.getMessage).getOrElse("Internal error") | |
| ) | |
| } | |
| } catch { | |
| case e: Throwable => | |
| JsonRpcCodec.errorResponse( | |
| idJson, | |
| -32603, | |
| Option(e.getMessage).getOrElse("Internal error") | |
| ) | |
| } | |
| } | |
| case _ => | |
| JsonRpcCodec.errorResponse(idJson, -32600, "Invalid Request: 'method' must be a string") | |
| } | |
| } | |
| } | |
| case _ => | |
| JsonRpcCodec.errorResponse(Json.Null, -32600, "Invalid Request: top-level JSON value must be an object") |
| } catch { | ||
| case e: Throwable => | ||
| JsonRpcCodec.errorResponse( | ||
| idJson, | ||
| -32603, | ||
| Option(e.getMessage).getOrElse("Internal error") | ||
| ) | ||
| } |
There was a problem hiding this comment.
This catches Throwable, which will also swallow fatal errors (e.g., VirtualMachineError, InterruptedException) and can make shutdown / cancellation behavior unpredictable. The rest of the codebase typically uses scala.util.control.NonFatal; consider catching NonFatal(e) here and letting fatal errors propagate.
|
|
||
| trait OperationHandler { |
There was a problem hiding this comment.
OperationHandler is part of the public API (users must implement it to construct a JsonRpcCodec), but it currently has no Scaladoc explaining required semantics (expected shape of params, how to signal JSON-RPC errors vs internal errors, etc.). Adding Scaladoc here would make the API much easier to use correctly.
| trait OperationHandler { | |
| /** Per-method JSON-RPC 2.0 handler used by [[JsonRpcCodec]]. | |
| * | |
| * Implementations are responsible for decoding the untyped JSON `params` | |
| * value and producing either a successful JSON result or an internal error. | |
| * | |
| * ==Parameters== | |
| * The `params` value is taken directly from the `"params"` field of the | |
| * incoming JSON-RPC request: | |
| * - If the request has no `"params"` field, `params` will be [[Json.Null]]. | |
| * - If `"params"` is an object, `params` will be a [[Json.Object]]. | |
| * - If `"params"` is an array, `params` will be a [[Json.Array]]. | |
| * - Any other JSON type is passed through unchanged. | |
| * | |
| * Implementations should pattern match on `params` and decode it according | |
| * to the method's expected parameter schema. No automatic decoding is | |
| * performed by [[JsonRpcCodec]]. | |
| * | |
| * ==Error handling== | |
| * [[JsonRpcCodec.handleRequest]] interprets the result of [[handle]]: | |
| * - `Right(result)` is encoded as a JSON-RPC `"result"` with code 0. | |
| * - `Left(error)` is encoded as a JSON-RPC error with code `-32603` | |
| * ("Internal error"), using `error.getMessage` as the error message | |
| * (or `"Internal error"` if the message is `null`). | |
| * - Any non-fatal exception thrown from [[handle]] is caught and treated | |
| * the same as a `Left`, producing a `-32603` error. | |
| * | |
| * @note This handler API is intended for internal/server-side errors. The | |
| * enclosing [[JsonRpcCodec]] takes care of other JSON-RPC-defined | |
| * errors such as parse errors (`-32700`), invalid requests (`-32600`), | |
| * and unknown methods (`-32601`). To provide richer domain-specific | |
| * information for internal errors, include details in the | |
| * [[Throwable]] used in the `Left` branch. | |
| * | |
| * @example A simple handler for a method `"add"` expecting | |
| * `{"x": 1, "y": 2}` as params: | |
| * {{{ | |
| * import zio.blocks.rpc.jsonrpc.JsonRpcCodec | |
| * import zio.blocks.schema.json.Json | |
| * | |
| * val addHandler: JsonRpcCodec.OperationHandler = new JsonRpcCodec.OperationHandler { | |
| * def handle(params: Json): Either[Throwable, Json] = | |
| * try { | |
| * params match { | |
| * case Json.Object(fields) => | |
| * val x = fields.toMap.get("x").collect { case Json.Number(n) => n.toInt }.getOrElse(0) | |
| * val y = fields.toMap.get("y").collect { case Json.Number(n) => n.toInt }.getOrElse(0) | |
| * Right(Json.Number(x + y)) | |
| * case _ => | |
| * Left(new IllegalArgumentException("Expected object params with fields 'x' and 'y'")) | |
| * } | |
| * } catch { | |
| * case t: Throwable => Left(t) // will be mapped to JSON-RPC -32603 | |
| * } | |
| * } | |
| * }}} | |
| */ | |
| trait OperationHandler { | |
| /** Handles the `"params"` value of a JSON-RPC 2.0 request for a single method. | |
| * | |
| * @param params the raw JSON value from the request's `"params"` field, | |
| * or [[Json.Null]] if the field is absent. | |
| * @return `Right(resultJson)` on success, where `resultJson` is | |
| * encoded into the `"result"` field; `Left(error)` on | |
| * failure, which is mapped to a JSON-RPC error with code | |
| * `-32603` using `error.getMessage` as the message. | |
| */ |
Summary
Adds a new
zio-blocks-rpcmodule providing aderives RPCtype class that captures service trait structure as a "pure data" representation — analogous to howderives Schemaworks for data types.Closes #1143
Design
RPC[T]captures method signatures, parameter schemas, return schemas, error types, and annotations at compile time. Protocol implementations (JSON-RPC, gRPC, etc.) derive from it viaRpcDeriver[Protocol[_]], just like codec formats derive fromSchema[A].Key Design Decisions
ReturnTypeDecomposertype class — flexible effect type support via compile-time decomposition. Built-in:Either[E, A](error=E, success=A) and plainA(no error). Futurerpc-zio/rpc-cats/rpc-kyomodules provide their own decomposers.MetaAnnotation— extensible annotation infrastructure for service/method metadataJsonRpcCodecwith spec-compliant request/response handlingWhat's Included
RPC[T]RPC.Operation[I, O]ReturnTypeDecomposer[F]MetaAnnotationRpcDeriver[Protocol[_]]RpcFormatFormat)JsonRpcCodecModule Structure