Forge oRPC is the JSON-RPC 2.0 extension for Forge, the Go backend framework. You add it to an app and every HTTP route you've registered becomes a JSON-RPC method on one endpoint, with batch requests, a method list and a generated OpenRPC schema. You keep writing ordinary Forge handlers. Clients that prefer RPC call the same code through POST /rpc.
Documentation · Quick start · Features · Method names · Calling methods · OpenRPC schema · Configuration · FAQ
You need Go 1.26 or later. Add Forge and the extension to your module:
go get github.com/xraph/forge@latest
go get github.com/xraph/forge/extensions/orpc@latestSave this as main.go:
package main
import (
"log"
"net/http"
"github.com/xraph/forge"
"github.com/xraph/forge/extensions/orpc"
)
func main() {
app := forge.NewApp(forge.AppConfig{
Name: "users",
HTTPAddress: ":8080",
Extensions: []forge.Extension{
orpc.NewExtension(orpc.WithServerInfo("users", "1.0.0")),
},
})
err := app.Router().GET("/users/:id", func(ctx forge.Context) error {
return ctx.JSON(http.StatusOK, map[string]string{
"id": ctx.Param("id"),
"name": "Ada",
})
},
forge.WithSummary("Get a user by ID"),
forge.WithORPCMethod("users.get"),
)
if err != nil {
log.Fatal(err)
}
if err := app.Run(); err != nil {
log.Fatal(err)
}
}Run it with go mod tidy && go run ., then call the route as a JSON-RPC method:
curl -X POST http://localhost:8080/rpc \
-H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","method":"users.get","params":{"id":"42"},"id":1}'
# {"jsonrpc":"2.0","result":{"id":"42","name":"Ada"},"id":1}
curl http://localhost:8080/rpc/methods
curl http://localhost:8080/rpc/schemaThe extension reads the router's routes when it starts, so register your routes before app.Run() (or app.Start). A route you add after that won't show up as a method.
| You need | Forge oRPC gives you |
|---|---|
| An RPC interface without a second set of handlers | Every Forge route, exposed as a JSON-RPC 2.0 method that runs the real handler |
| Control over what gets exposed | Include and exclude path patterns, per-route opt-out, and a method name prefix |
| Predictable method names | get.users.id style names from the path, the route name, or one you set with forge.WithORPCMethod |
| Fewer round trips | Batch requests, capped by batch_limit |
| A machine-readable contract | An OpenRPC 1.3.2 document built from your routes and their OpenAPI request and response schemas |
| Visibility | A method list endpoint, request counters and a method gauge in Forge metrics |
With the default config the extension adds three routes to your app:
| Route | What it does |
|---|---|
POST /rpc |
Runs a single JSON-RPC request or a batch (a JSON array) |
GET /rpc/methods |
Lists method names, descriptions, tags and deprecation flags |
GET /rpc/schema |
Returns the OpenRPC document |
Change the paths with WithEndpoint and WithOpenRPCEndpoint. The methods route always sits at <endpoint>/methods, and WithDiscovery(false) or WithOpenRPC(false) removes the last two.
The default naming strategy, path, builds a name from the HTTP verb and the path. POST becomes create, GET becomes get, PUT becomes update, PATCH stays patch and DELETE stays delete. Slashes turn into dots, path parameter markers are dropped and hyphens become underscores:
| Route | Method name |
|---|---|
GET /users/:id |
get.users.id |
POST /api/v1/posts |
create.api.v1.posts |
DELETE /order-items/{id} |
delete.order_items.id |
Set naming_strategy: method to use the route's name (from forge.WithName) where it has one, falling back to the path. WithMethodPrefix("billing.") puts a prefix on every generated name. A name set with forge.WithORPCMethod always wins over the strategy, though the prefix still applies.
These route options shape what the extension does with a route:
| Route option | Effect |
|---|---|
forge.WithORPCMethod("users.get") |
Sets the method name |
forge.WithORPCExclude() |
Leaves the route out |
forge.WithORPCParams(&orpc.ParamsSchema{...}) |
Replaces the generated params schema |
forge.WithORPCResult(&orpc.ResultSchema{...}) |
Replaces the generated result schema |
forge.WithORPCPrimaryResponse(201) |
Picks which OpenAPI response schema becomes the method's result |
forge.WithSummary, forge.WithTags |
Become the method's description and tags |
Path patterns come first. exclude_patterns defaults to ["/_/*"], which keeps Forge's health, info and metrics routes out. If you set include_patterns, a route has to match one of them to be exposed. Patterns use filepath.Match, and a trailing /* matches any path under the prefix. The extension's own routes are always skipped.
params is a JSON object. Path parameters go in as top-level keys, the request body goes under body, and query string values go under query:
{
"jsonrpc": "2.0",
"method": "update.users.id",
"params": {
"id": "42",
"body": {"name": "Ada Lovelace"},
"query": {"notify": true}
},
"id": 7
}The extension turns that into PUT /users/42?notify=true with a JSON body, sends it through your router and puts the decoded JSON response in result. A handler that answers with a status of 400 or more comes back as a JSON-RPC error with code -32603 and the message route returned status <code>. A non-JSON response comes back as {"status": ..., "body": ...}.
Watch for one thing. The router sees a brand new request, and none of the headers on your original POST /rpc call (an Authorization header, a session cookie, a tracing header) are copied onto it, so if your route reads them to decide who the caller is, it gets nothing back when you call it over JSON-RPC.
A batch is an array of requests, answered with an array of responses in the same order:
curl -X POST http://localhost:8080/rpc -d '[
{"jsonrpc":"2.0","method":"users.get","params":{"id":"1"},"id":1},
{"jsonrpc":"2.0","method":"nope","id":2}
]'
# [{"jsonrpc":"2.0","result":{"id":"1","name":"Ada"},"id":1},
# {"jsonrpc":"2.0","error":{"code":-32601,"message":"Method 'nope' not found"},"id":2}]JSON-RPC errors are always sent with HTTP 200. The codes are the standard ones, exported as constants:
| Constant | Code | When |
|---|---|---|
orpc.ErrParseError |
-32700 | The body isn't valid JSON |
orpc.ErrInvalidRequest |
-32600 | Wrong jsonrpc version, missing method, or a malformed batch |
orpc.ErrMethodNotFound |
-32601 | No method by that name |
orpc.ErrInvalidParams |
-32602 | params can't be decoded |
orpc.ErrInternalError |
-32603 | The route or an interceptor returned an error |
orpc.ErrServerError |
-32000 | Request too large, batches disabled, or batch over the limit |
The service is in the container as *orpc.ORPCService. You can add a method that isn't backed by a route, or wrap every call in an interceptor. A forge.PhaseAfterStart hook is a good place for it, since the extension has started by then:
err := app.RegisterHookFn(forge.PhaseAfterStart, "rpc-extras", func(ctx context.Context, a forge.App) error {
svc, err := forge.Inject[*orpc.ORPCService](a.Container())
if err != nil {
return err
}
svc.Use(func(ctx context.Context, req *orpc.Request, next orpc.MethodHandler) (any, error) {
log.Printf("rpc %s", req.Method)
return next(ctx, req.Params)
})
return svc.RegisterMethod(&orpc.Method{
Name: "system.ping",
Description: "Liveness check",
Handler: func(ctx any, params any) (any, error) {
return map[string]string{"pong": "ok"}, nil
},
})
})An interceptor gets the raw request. Pass req.Params (or your own value) to next, which hands it to the method's handler.
GET /rpc/schema returns an OpenRPC 1.3.2 document. You set info.title and info.version with WithServerInfo, and servers points at your endpoint. For a method's params, the extension uses the first of these you've provided: forge.WithORPCParams, the route's OpenAPI request schema, the request type registered for OpenAPI, and finally a basic schema with the path parameters, query and (for POST, PUT and PATCH) body. Results work the same way, from forge.WithORPCResult or the route's OpenAPI response schemas, so if you already describe your responses for OpenAPI you don't have to describe them twice. Methods are sorted by name. You can diff the output between builds.
The extension reads extensions.orpc from your config, falling back to orpc. Options you pass in code override the file.
extensions:
orpc:
enabled: true
endpoint: /rpc
openrpc_endpoint: /rpc/schema
server_name: users
server_version: 1.0.0
auto_expose_routes: true
method_prefix: ""
exclude_patterns: ["/_/*"]
include_patterns: []
naming_strategy: path # path, method or custom
enable_openrpc: true
enable_discovery: true
enable_batch: true
batch_limit: 10
max_request_size: 1048576 # bytes, at least 1024
enable_metrics: true| Key | Option | Default | What it does |
|---|---|---|---|
enabled |
WithEnabled |
true |
Turns the extension off without removing it |
endpoint |
WithEndpoint |
/rpc |
Path of the JSON-RPC endpoint |
openrpc_endpoint |
WithOpenRPCEndpoint |
/rpc/schema |
Path of the OpenRPC document |
server_name, server_version |
WithServerInfo |
forge-app, 1.0.0 |
info block in the OpenRPC document |
auto_expose_routes |
WithAutoExposeRoutes |
true |
Turns routes into methods at start |
method_prefix |
WithMethodPrefix |
"" |
Prefix for every generated method name |
exclude_patterns |
WithExcludePatterns |
["/_/*"] |
Paths to leave out |
include_patterns |
WithIncludePatterns |
[] |
If set, only matching paths are exposed |
naming_strategy |
WithNamingStrategy |
path |
path, method or custom (which behaves like path today) |
enable_openrpc |
WithOpenRPC |
true |
Serves the OpenRPC document |
enable_discovery |
WithDiscovery |
true |
Serves <endpoint>/methods |
enable_batch |
WithBatch |
true |
Accepts batch requests |
batch_limit |
WithBatchLimit |
10 |
Largest batch accepted |
max_request_size |
WithMaxRequestSize |
1048576 |
Largest request body, in bytes |
enable_metrics |
WithMetrics |
true |
Records orpc_requests_total and orpc_methods_total |
WithConfig replaces the whole config and WithRequireConfig(true) makes a missing config section an error at startup.
Six more settings exist in Config and pass validation, but nothing reads them yet: require_auth, auth_header and auth_tokens (set by WithAuth), rate_limit_per_minute (WithRateLimit), request_timeout (WithRequestTimeout) and schema_cache (WithSchemaCache). Don't rely on WithAuth to protect the endpoint. Put Forge middleware or your auth extension in front of /rpc instead.
| Topic | Where |
|---|---|
| What the extension does | Features |
| Every config key | Configuration |
| Exported functions and types | Functions, pkg.go.dev |
| How the extension follows the spec | SPEC_COMPLIANCE.md |
| Extensions in general | Forge extension system |
Add orpc.NewExtension() to your Forge app and register your routes before the app starts. Each route becomes a method you can call at POST /rpc, and GET /rpc/methods shows you the names it picked.
Yes. GET /rpc/schema serves an OpenRPC 1.3.2 document. It's built from the same route metadata Forge uses for OpenAPI, so typed request and response schemas carry over.
No. This extension speaks JSON-RPC 2.0 and describes itself with OpenRPC. It doesn't implement the wire protocol of the TypeScript orpc project.
Add forge.WithORPCExclude() to the route, or list its path in exclude_patterns. Internal routes under /_/ are excluded by default.
The extension is its own Go module inside the Forge repository. Test it from its directory:
cd extensions/orpc
GOWORK=off go test -race ./...
GOWORK=off golangci-lint run ./...AGENTS.md has the full build, lint and release rules for the repository, and CONTRIBUTING.md has the contribution guidelines.
Forge oRPC is part of Forge and is available under the Apache License 2.0.