Go implementation of Arri RPC. It uses the net/http
package from the standard library, and can be used alongside other http libraries that make use of the standard net/http
library.
- Quickstart
- Basic Example
- Manual Setup
- Creating HTTP Procedures
- Creating Event Stream Procedures
- Defining Messages
- Helper Types
The Arri CLI comes with an initialization script that will scaffold a basic go server for Arri RPC.
# npm
npx arri init [project-name]
cd [project-name]
npm install
npm run dev
# pnpm
pnpm dlx arri init [project-name]
cd [project-name]
pnpm install
pnpm run dev
Follow the prompts and be sure to select go as your language of choice:
What kind of project do you want to initialize?
-> application
generator plugin
What language do you want to use?
typescript
-> go
package main
import (
"github.com/modiimedia/arri"
)
// this is the data type that will be passed around to every procedure
// it must implement the `arri.Event` interface
// if you don't want to define a custom event you can use `arri.DefaultEvent` and `arri.CreateDefaultEvent()` instead
type MyCustomEvent struct {
r *http.Request
w http.ResponseWriter
}
func (e MyCustomEvent) Request() *http.Request {
return e.r
}
func (e MyCustomEvent) Writer() http.ResponseWriter {
return e.writer
}
func main() {
// creates a CLI app that accepts parameters for outputting an Arri app definition
app := arri.NewApp(
http.DefaultServeMux
arri.AppOptions[arri.DefaultEvent]{},
// function to create your custom Event type using the incoming request
func(w http.ResponseWriter, r *http.Request) (*RpcEvent, arri.RpcError) {
return &MyCustomEvent{
r: r,
w: w,
}
},
)
// register procedures
arri.Rpc(&app, SayHello, arri.RpcOptions{})
arri.Rpc(&app, SayGoodbye, arri.RpcOptions{})
// run the app on port 3000
err := app.Run(arri.RunOptions{Port: 3000})
if err != nil {
log.Fatal(err)
return
}
}
// Procedure inputs and outputs must be structs
type GreetingParams struct { Name string }
type GreetingResponse struct { Message string }
// RPCs take two inputs and have two outputs
//
// Inputs:
// The first input will be registered as the RPC params. This is what clients will send to the server.
// The second input will be whatever type you have defined to be the Event type. In this case it's "MyCustomEvent"
//
// Outputs:
// The first output will be the OK response sent back to the client
// The second output will be the Error response sent back to the client
func SayHello(params GreetingParams, event MyCustomEvent) (GreetingResponse, arri.RpcError) {
return GreetingResponse{ Message: "Hello " + params.name }, nil
}
func SayGoodbye(params GreetingParams, event MyCustomEvent) (GreetingResponse, arri.RpcError) {
return GreetingResponse{ Message: "Goodbye " + params.name }, nil
}
First create your RPC function
type GreetingParams struct {
Name string
}
type GreetingResponse struct {
Message string
}
func SayHello(
params GreetingParams,
event arri.DefaultEvent,
) (GreetingResponse, arri.RpcError) {
return GreetingResponse{Message: fmt.SprintF("Hello %s", params.Name)}, nil
}
Then register it on the app instance
// will create the following endpoint:
// POST "/say-hello"
arri.Rpc(&app, SayHello, arri.RpcOptions{})
// will create the following endpoint:
// POST "/greeter/say-hello"
// client generators will group this rpc under the "greeter" service
arri.ScopedRpc(&app, "greeter", SayHello, arri.RpcOptions{})
RpcOptions
is used to customize some of the procedure behaviors.
arri.Rpc(&app, SayHello, arri.RpcOptions{
// specify the HTTP method (default is POST)
Method: arri.HttpMethodGet,
// manually specify the url path
Path: "/custom/url/path",
// manually specify the function name in the generated client(s)
// (will use a camelCase version of the go function name by default)
Name: "CustomFunctionName"
// function description that will appear in the generated client(s)
Description: "Some description"
// mark procedure as deprecated in generated client(s)
IsDeprecated: true
})
First create your Event Stream RPC function
type GreetingParams struct {
Name string
}
type GreetingResponse struct {
Message string
}
// send an event every second
func StreamGreeting(
params GreetingParams,
controller arri.SseController[GreetingResponse],
context arri.DefaultContext,
) arri.RpcError {
t := time.NewTicker(time.Second)
msgCount = 0
defer t.Stop()
for {
select {
case <-t.C:
msgCount++
controller.Push(GreetingResponse{Message: "Hello " + params.Name + " " + fmt.Sprint(msgCount)})
case <-controller.Done():
// exit when the connection closes
return nil
}
}
}
Then register it on the App instance:
// creates the following endpoint:
// POST /stream-greeting
arri.EventStreamRpc(&app, StreamGreeting, arri.RpcOptions{})
// creates the following endpoint:
// POST /greeter/stream-greeting
// client generators will group this rpc under the "greeter" service
arri.ScopedEventStreamRpc(&app, "greeter", StreamGreeting, arri.RpcOptions{})
type SseController[T any] interface {
// Push a new event to the client
// Will return an RpcError if there was an issue with serializing the response
Push(T) RpcError
// Close the connection
// If notifyClient is set to true then a "done" event will be sent to the client.
// Spec compliant Arri clients will not auto-reconnect after receiving a "done" event
Close(notifyClient bool)
// Will fire when the connection has been closed either by the server or the client
Done() <-chan struct{}
// Change how often a "ping" event is sent to the client. Default is (10 seconds)
SetPingInterval(time.Duration)
}
All parameters and responses are structs. Arri uses the Go reflect library to validate incoming requests based on these structs. It also automatically convert these structs into Arri Type Definitions (ATD) which the client generators can use during client generation. This means you don't need to do any additional work to get type-safe clients.
For example this struct:
type User struct {
Id string
Name string
IsAdmin bool
}
will be converted to this
{
"properties": {
"id": {
"type": "string"
},
"name": {
"type": "string"
},
"isAdmin": {
"type": "boolean"
}
},
"metadata": {
"id": "User"
}
}
which the TS client generator will use to create this: (See here for a complete list of generators)
export interface User {
id: string;
name: string;
isAdmin: boolean;
}
The following primitive types are supported:
Go | Arri Type Definition (ATD) |
---|---|
string | {"type": "string"} |
bool | {"type": "boolean"} |
time.Time | {"type": "timestamp"} |
float32 | {"type": "float32"} |
float64 | {"type": "float64"} |
int8 | {"type": "int8"} |
int16 | {"type": "int16"} |
int32 | {"type": "int32"} |
int64 | {"type": "int64"} |
int | {"type": "int64"} |
uint8 | {"type": "uint8"} |
uint16 | {"type": "uint16"} |
uint32 | {"type": "uint32"} |
uint64 | {"type": "uint64"} |
uint | {"type": "uint64"} |
Use the enum
tag on string
fields to define enums for the generated clients. Enums must be a string
.
Also note that the first defined value will be treated as the default value by generated clients.
type User struct {
Id string
Name string
Role string `enum:"STANDARD,ADMIN"`
}
Outputted ATD:
{
"enum": ["STANDARD", "ADMIN"],
"metadata": {
"id": "UserRole"
}
}
You can also manually defined the name of the enum in generated clients using the enumName
tag
type User struct {
Id string
Name string
Role string `enum:"STANDARD,ADMIN" enumName:"Role"`
}
Outputted JTD:
{
"enum": ["STANDARD", "ADMIN"],
"metadata": {
"id": "Role"
}
}
Both arrays and slices are supported
[]string
Outputted ATD:
{
"elements": {
"type": "string"
}
}
Arri supports structs so long as all the fields are one of arri's supported types
type User struct {
Id string
Name string
}
type Post struct {
Id string
Author User // nested structs are okay too
Content string
}
Inline structs are supported so long as they aren't the root type in an RPC input/response
type PostParams {
PostId string
}
type Post struct {
Id string
// nested inlined struct
Author struct {
Id string
Name string
}
}
func GetPost(params PostParams, c arri.DefaultContext) (Post, arri.RpcError) {
// rpc content
}
func GetPost(
// inlined structs cannot go here
params struct{PostId string},
c arri.DefaultContext,
)
(
// inlined structs cannot go here
struct{
Id string,
Author struct{
Id string,
Name string,
},
Content string,
},
arri.RpcError,
) {
// rpc content
}
Arri supports maps with string keys. Attempting to use non-string keys for RPC inputs/outputs will cause a panic when the server starts.
Map values can be any of the supported go types.
map[string]bool
outputted ATD:
{
"values": {
"type": "boolean"
}
}
Since go doesn't have discriminated unions we have created the following convention for defining such data types.
- A discriminated union must have a root struct type which will act as the "parent" type
- All "subtypes" are fields that contain a pointer to a struct. They can be either named structs or inlined.
- All "subtype" fields must have the
discriminator
tag, which defines the value of the"type"
field during serialization. Clients will use this value to determine which subtype has been sent by the server.
Here we are creating a Shape
parent type with the Rectangle
and Circle
type
type Shape struct {
Rectangle *Rectangle `discriminator:"RECTANGLE"`
Circle *Circle `discriminator:"CIRCLE"`
}
type Rectangle struct {
Width float32
Height float32
}
type Circle struct {
Radius float32
}
// The following are also valid
type Shape struct {
*Rectangle `discriminator:"RECTANGLE"`
*Circle `discriminator:"CIRCLE"`
}
type Shape struct {
Rectangle struct{
Width float32
Height float32
} `discriminator:"RECTANGLE"`
Circle struct{
Radius float32
} `discriminator:"CIRCLE"`
}
Outputted JTD:
{
"discriminator": "type",
"mapping": {
"RECTANGLE": {
"properties": {
"width": {
"type": "float32"
},
"height": {
"type": "float32"
}
},
"metadata": {
"id": "Rectangle"
}
},
"CIRCLE": {
"properties": {
"radius": {
"type": "float32"
}
},
"metadata": {
"id": "Circle"
}
}
},
"metadata": {
"id": "Shape"
}
}
The outputed JSON will look something like this:
// initialize a rectangle shape
myShape := Shape{Rectangle: &Rectangle{Width: 10, Height: 20}}
// serialize to json
result, _ := arri.EncodeJSON(myShape, arri.KeyCasingCamelCase)
// print the result
fmt.Println(string(result))
{
"type": "RECTANGLE",
"width": 10,
"height": 20
}
By default arri will put the discriminator value in the "type" field for clients to determine when subtype has been sent.
{
"type": "RECTANGLE",
"width": 10,
"height": 20
}
{
"type": "CIRCLE",
"radius": 20
}
You can override this by using the discriminatorKey
tag in conjunction with the DiscriminatorKey
helper provided by arri
type Shape struct {
arri.DiscriminatorKey `discriminatorKey:"kind"`
Rectangle *Rectangle `discriminator:"RECTANGLE"`
Circle *Circle `discriminator:"CIRCLE"`
}
Now the outputted JSON will look something like this:
{
"kind": "RECTANGLE",
"width": 10,
"height": 20
}
{
"kind": "CIRCLE",
"radius": 20
}
Recursive types are supported so long as all of the field types are supported by arri
type BinaryTree struct {
Left: *BinaryTree
Right: *BinaryTree
}
Outputted JTD:
{
"properties": {
"left": {
"ref": "BinaryTree",
"nullable": true
},
"right": {
"ref": "BinaryTree",
"nullable": true
}
},
"metadata": {
"id": "BinaryTree"
}
}
By default arri treats all fields as required. You can define optional fields using the arri.Option
type
type User struct {
Id string
Name arri.Option[string]
Email arri.Option[string]
}
Outputted ATD:
{
"properties": {
"id": { "type": "string" }
},
"optionalProperties": {
"name": { "type": "string" },
"email": { "type": "string" }
},
"metadata": {
"id": "User"
}
}
Example outputted JSON:
// with set optional values
{
"id": "1",
"name": "john doe",
"email": "johndoe@gmail.com"
}
// with unset optional values
{
"id": "1",
}
// initializing options
optionalString := arri.Some("hello world") // initialize optional with value
optionalString := arri.None[string]() // initialize optional with no value
// working with options
optionalString.Unwrap() // extract the inner value. panics if there is no value
optionalString.UnwrapOr("some-fallback") // extract the inner value if it exist. otherwise use the fallback
optionalString.IsSome() // returns true if inner value has been set
optionalString.IsNone() // returns true if inner value has not been set
optionalString.Set("hello world again") // update the inner value
optionalString.Unset() // unset the inner value
type Option[T] interface {
Unwrap() T bool
Set(val T)
Unset()
}
All pointers are treated as nullable with the exception of maps and arrays which will be serialized as empty objects and empty arrays respectively.
In cases where you don't want to use pointers you can use the arri.Nullable
type.
type User struct {
Id string
Name *string // this is treated as nullable during encoding/decoding
Email arri.Nullable[string] // this is also treated as nullable during encoding/decoding
}
Outputted ATD:
{
"properties": {
"id": {
"type": "string"
},
"name": {
"type": "string",
"nullable": true
},
"email": {
"type": "string",
"nullable": true
}
},
"metadata": {
"id": "User"
}
}
Example outputted JSON:
// with set nullable values / set pointers
{
"id": "1",
"name": "john doe",
"email": "johndoe@gmail.com"
}
// with unset nullable values / unset pointers
{
"id": "1",
"name": null,
"email": null
}
// initializing nullable types
nullableString := arri.NotNull("hello world") // initialize nullable with value
nullableString := arri.Null[string]() // initialize nullable without value
// working with nullables
nullableString.Unwrap() // extract the inner value. panics if not set
nullableString.UnwrapOr("some-fallback") // extract the inner value if it exists. if it doesn't exists return the fallback
nullableString.IsNull() // returns true if null
nullableString.Set("hello world again") // update the inner value
nullableString.Unset() // unset the inner value
Represents a key-value pair
arri.Pair("foo", "bar")
arri.Pair(0, true)
arri.Pair("baz", []string{})
A replacement for map that preserves the key order. Only string keys are supported. Key order is also preserved during serialization.
m := arri.OrderedMap[bool]{}
m.Add(arri.Pair("Foo", true))
m.Add(arri.Pair("Bar", false))
m.Get("Foo") // returns *true
m.Get("Bar") // returns *false
m.Get("Baz") // returns nil
m.Set("Foo", false)
m.Get("Foo") // returns *false
m.Len() // returns 2
m.Keys() // returns ["Foo", "Bar"]
m.Values() // returns [true, false]
m.Entries() // returns [arri.Pair["Foo":true], arri.Pair["Bar":false]]
You can also initialize an ordered map with data
m := arri.OrderedMapWithData(
arri.Pair("Foo", true),
arri.Pair("Bar", false),
)