# Zrpc
A modern RPC framework for Elixir with a clean DSL, middleware system, and hierarchical routing. Define your API once, generate TypeScript clients and OpenAPI specs automatically.
Zrpc provides a type-safe, transport-agnostic way to define and execute remote procedure calls. It's inspired by tRPC and designed to work seamlessly with Phoenix, Plug, or any Elixir application. Your procedure definitions serve as the **single source of truth** for validation, documentation, and client generation.
## Features
- **Single Source of Truth** - Generate TypeScript clients and OpenAPI specs from your procedure definitions
- **Clean DSL** for defining queries, mutations, and subscriptions
- **Schema Validation** with [Zoi](https://github.com/wavezync/zoi) for input/output validation
- **Middleware System** with compile-time optimization
- **Hierarchical Router** with namespacing, scopes, and aliases
- **Transport Agnostic** - works with HTTP, WebSocket, or custom transports
- **Telemetry Integration** for observability
- **Batch Execution** with configurable concurrency
## Installation
Add `zrpc` to your list of dependencies in `mix.exs`:
```elixir
def deps do
[
{:zrpc, "~> 0.0.0-alpha"}
]
end
```
## Quick Start
### 1. Define Procedures
```elixir
defmodule MyApp.Procedures.Users do
use Zrpc.Procedure
query :get do
input Zoi.object(%{
id: Zoi.string() |> Zoi.uuid()
})
output Zoi.object(%{
id: Zoi.string(),
name: Zoi.string(),
email: Zoi.string()
})
handler fn %{id: id}, _ctx ->
case MyApp.Users.get(id) do
nil -> {:error, :not_found}
user -> {:ok, user}
end
end
end
mutation :create do
input Zoi.object(%{
name: Zoi.string() |> Zoi.min(1),
email: Zoi.string() |> Zoi.email()
})
handler fn input, _ctx ->
MyApp.Users.create(input)
end
end
end
```
### 2. Create a Router
```elixir
defmodule MyApp.Router do
use Zrpc.Router
# Global middleware
middleware MyApp.Middleware.Logger
middleware MyApp.Middleware.Auth
# Register procedures at namespaces
procedures MyApp.Procedures.Users, at: "users"
procedures MyApp.Procedures.Posts, at: "posts"
# Scoped routes with additional middleware
scope "admin" do
middleware MyApp.Middleware.RequireAdmin
procedures MyApp.Procedures.Admin, at: "actions"
end
end
```
This creates paths like:
- `users.get`, `users.create`
- `posts.list`, `posts.get`
- `admin.actions.delete_user`
### 3. Execute Procedures
```elixir
# Create a context
ctx = Zrpc.Context.new()
# Single call
{:ok, user} = Zrpc.Router.call(MyApp.Router, "users.get", %{id: "123"}, ctx)
# Batch call
results = Zrpc.Router.batch(MyApp.Router, [
{"users.get", %{id: "123"}},
{"posts.list", %{user_id: "123"}}
], ctx)
```
## Core Concepts
### Procedures
Procedures are the building blocks of your API. They come in three types:
- **query** - Read operations (idempotent)
- **mutation** - Write operations
- **subscription** - Real-time updates
```elixir
defmodule MyApp.Procedures.Example do
use Zrpc.Procedure
query :fetch_data do
input Zoi.object(%{id: Zoi.string()})
handler fn %{id: id}, ctx -> {:ok, %{id: id}} end
end
mutation :update_data do
input Zoi.object(%{id: Zoi.string(), data: Zoi.any()})
handler fn input, ctx -> {:ok, input} end
end
subscription :watch_data do
input Zoi.object(%{id: Zoi.string()})
handler fn %{id: id}, ctx ->
# Return a stream or subscription
end
end
end
```
### Context
The context carries request information through the middleware chain and into handlers:
```elixir
# Create from Plug.Conn
ctx = Zrpc.Context.from_conn(conn)
# Create from Phoenix.Socket
ctx = Zrpc.Context.from_socket(socket)
# Add custom assigns
ctx = Zrpc.Context.assign(ctx, :current_user, user)
# Access in handlers
handler fn input, ctx ->
user = ctx.assigns[:current_user]
# ...
end
```
### Middleware
Middleware intercepts procedure calls for cross-cutting concerns:
```elixir
defmodule MyApp.Middleware.Auth do
use Zrpc.Middleware
@impl true
def call(ctx, _opts, next) do
case get_current_user(ctx) do
{:ok, user} ->
ctx = Zrpc.Context.assign(ctx, :current_user, user)
next.(ctx)
{:error, _} ->
{:error, :unauthorized}
end
end
end
```
### Router
The router organizes procedures into a hierarchical namespace:
```elixir
defmodule MyApp.Router do
use Zrpc.Router
# Global middleware
middleware MyApp.Middleware.RequestId
# Simple registration
procedures MyApp.Procedures.Public, at: "public"
# Nested scopes
scope "api" do
scope "v1" do
procedures MyApp.Procedures.V1.Users, at: "users"
end
end
# Path aliases for backwards compatibility
path_alias "getUser", to: "api.v1.users.get", deprecated: true
end
```
## Error Handling
Handlers can return errors in multiple formats:
```elixir
# Simple atom code
{:error, :not_found}
# Code with message
{:error, :validation_failed, "Email is invalid"}
# Structured error
{:error, %{code: :custom_error, message: "Details", extra: "data"}}
```
Validation errors are automatically formatted:
```elixir
{:error, %{
code: :validation_error,
message: "Validation failed",
details: %{
"email" => ["must be a valid email"]
}
}}
```
## Telemetry Events
Zrpc emits telemetry events for observability:
```elixir
# Procedure events
[:zrpc, :procedure, :start]
[:zrpc, :procedure, :stop]
[:zrpc, :procedure, :exception]
# Router events
[:zrpc, :router, :lookup, :start]
[:zrpc, :router, :lookup, :stop]
[:zrpc, :router, :batch, :start]
[:zrpc, :router, :batch, :stop]
[:zrpc, :router, :alias, :resolved]
```
## Configuration
```elixir
# config/config.exs
config :zrpc,
# Validate procedure output against schema (default: true)
validate_output: true,
# Include exception details in error responses (default: false)
include_exception_details: false
```
## Documentation
- [Full Guide](guides/guide.md) - Comprehensive usage guide
- [HexDocs](https://hexdocs.pm/zrpc) - API documentation
## License
MIT License - see [LICENSE](LICENSE) for details.
å