Skip to content

feat(rpc): add zio-blocks-rpc module with derives RPC type class (#1143)#1270

Open
987Nabil wants to merge 2 commits intozio:mainfrom
987Nabil:feat/rpc
Open

feat(rpc): add zio-blocks-rpc module with derives RPC type class (#1143)#1270
987Nabil wants to merge 2 commits intozio:mainfrom
987Nabil:feat/rpc

Conversation

@987Nabil
Copy link
Copy Markdown
Contributor

Summary

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.

Closes #1143

Design

trait UserService derives RPC:
  def getUser(id: Long): Either[UserError, User]

  @Idempotent
  def createUser(name: String, email: String): Either[UserError, User]

  def listUsers(): Either[UserError, List[User]]

  def health(): Status  // plain return, no error

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 via RpcDeriver[Protocol[_]], just like codec formats derive from Schema[A].

Key Design Decisions

  • No ZIO dependency — core rpc module depends only on schema
  • ReturnTypeDecomposer type class — flexible effect type support via compile-time decomposition. Built-in: Either[E, A] (error=E, success=A) and plain A (no error). Future rpc-zio/rpc-cats/rpc-kyo modules provide their own decomposers.
  • Cross-version — Scala 2.13 + 3.x (macro derivation Scala 3 only, shared types available in 2.13)
  • MetaAnnotation — extensible annotation infrastructure for service/method metadata
  • JSON-RPC 2.0 reference protocol — proof-of-concept JsonRpcCodec with spec-compliant request/response handling

What's Included

Component Description
RPC[T] Type class capturing service operations, schemas, annotations
RPC.Operation[I, O] Per-operation metadata with Schema-backed I/O types
ReturnTypeDecomposer[F] Compile-time type class for effect type decomposition
MetaAnnotation Base class for service/method annotations
RpcDeriver[Protocol[_]] Trait for protocol-specific derivation
RpcFormat Associates protocol with deriver (like Schema's Format)
JsonRpcCodec JSON-RPC 2.0 handler (pure functions, no ZIO)
33 tests Macro derivation (22) + JSON-RPC integration (11)

Module Structure

rpc/shared/src/main/scala/        # Cross-version shared types
rpc/shared/src/main/scala-2/      # Scala 2 stubs (empty traits)
rpc/shared/src/main/scala-3/      # Macro derivation + version-specific
rpc/shared/src/test/scala-3/      # Tests (Scala 3 only)

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
Copilot AI review requested due to automatic review settings March 25, 2026 11:09
…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
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

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 into build.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))
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

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

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.

Suggested change
val abstractMethods = tpeSym.declaredMethods.filter(_.flags.is(Flags.Deferred))
val abstractMethods = tpeSym.memberMethods.filter(_.flags.is(Flags.Deferred))

Copilot uses AI. Check for mistakes.
Comment on lines +26 to +27
* Built-in instance: `Either[E, A]` decomposes to error=E, success=A.
*
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

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

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).

Suggested change
* 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.
*

Copilot uses AI. Check for mistakes.

class Streaming extends MetaAnnotation

class Deprecated(reason: String) extends MetaAnnotation
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

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

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.

Suggested change
class Deprecated(reason: String) extends MetaAnnotation
class RpcDeprecated(reason: String) extends MetaAnnotation

Copilot uses AI. Check for mistakes.
Comment on lines +31 to +77
* 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")
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

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

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.

Suggested change
* 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"))

Copilot uses AI. Check for mistakes.
Comment on lines +40 to +78
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")
}
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

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

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.

Suggested change
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")

Copilot uses AI. Check for mistakes.
Comment on lines +67 to +74
} catch {
case e: Throwable =>
JsonRpcCodec.errorResponse(
idJson,
-32603,
Option(e.getMessage).getOrElse("Internal error")
)
}
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

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

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.

Copilot uses AI. Check for mistakes.
Comment on lines +84 to +85

trait OperationHandler {
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

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

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.

Suggested change
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.
*/

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

zio-blocks-rpc: RPC type class for service trait introspection (derives RPC)

2 participants