Routing
Venator’s routing system is fairly simple on the surface, but has a lot of complex internals designed to make the developer UX easier. Most aspiring contributors won’t need to worry about the specifics of the router, but details will be listed here anyway.
Relevant GoDocs:
If you are looking for something specific, it is recommended you use the TOC to the left of this book.
Routes
Routes are defined using global variables that construct an interfaces.Route. This Route type is essential to the
entire routing system - it tells the router what the path(s) are, what the supported methods are, any middlewares that
need to be run before invoking the handler (and in what order), etc. The router aggregates these routes, and stores them
for later processing.
Anatomy of a Route
In Venator, a route is a Route instance, with an appropriate Handler.
A route handler is a function which takes one argument: the incoming request, and returns an optional error.
This is a route and a route handler:
var RouteMyAPI = interfaces.Route{
Methods: []string{http.MethodGet}, // This route supports the GET method
Path: mautrix.ClientURLPath{
"unstable",
"page.codeberg.timedout",
"venator",
"route",
// This route is reachable at /_matrix/client/unstable/page.codeberg.timedout/venator/route
},
Middlewares: []interfaces.RouteHandler{
middlewares.CORSMiddleware,
middlewares.AccountAuth(true, false, false),
}, // A list of middlewares to run through, in order
Handler: routeMyAPI, // The actual callback
}
func routeMyAPI(req *interfaces.Request) error {
return req.Ok(nil) // returns 200 OK with a body of `{}`
}
Placement
Venator tries to keep a sensible project layout when it comes to finding routes. All routes go in the api package
(under internal/venatord), and then are sorted into a subcategory. For example, client houses all client-to-server
routes, federation contains server-to-server routes, so on. The venator subcategory is special - this is the
“site-local API”, which is all routes under /_venator, including the admin API.
Looking inside the client subcategory, we have some directories, and a couple files. Looking inside account,
you’ll see several Go source files. Inside these files are, typically, at least three important components:
- An
init()function - A global variable, typically called something like
var RouteFooBar, with a definedinterfaces.Routeinstance - A function, typically called something like
getFooBar/postFooBar, or justrouteFooBar.
More on this later (goto).
Registration
You’ll notice in api/client, there’s a file called init.go. This is the “init file”, which Venator uses to
“initialise” the package. Since the CLI tool simply imports the api package, the api package has to self-initialise
in a way that allows all defined routes to be discovered by the router.
Considering venatorctl has the line:
import (
_ "codeberg.org/matrix-venator/venator/internal/venatord/api"
)
this imports all the code in the api package. This includes the init.go file present in api’s root, which has the
following effect:
api’sinit.gois imported.init.goinapithen importsapi/client, which importsclient’sinit.go.client’sinit.gothen imports all the subpackages it contains, which all have route definitions.- When a file with routes is imported, its
init()function is called. This allows it to register the route for the router to discover later.
This “waterfall” import system allows Venator to avoid the anti-pattern previously present where each route had to be manually appended to a function that looked like:
RegisterRoutes(route1, route2, route3, route4) // ...
Source structure
As mentioned earlier, “route files” typically contain a simple structure that makes it easy to understand what is in a
file at a glance. Let’s use the GET /_matrix/client/v3/account/whoami route file as an example, due to its simple
nature:
// Copyright (C) 2026 The Venator Contributors
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this file,
// You can obtain one at https://mozilla.org/MPL/2.0/.
package account
import (
"net/http"
"codeberg.org/matrix-venator/venator/internal/venatord/interfaces"
"codeberg.org/matrix-venator/venator/internal/venatord/router"
"codeberg.org/matrix-venator/venator/internal/venatord/router/middlewares"
"maunium.net/go/mautrix"
)
func init() {
router.Routes.Add(&RouteWhoAmi)
}
var RouteWhoAmi = interfaces.Route{
Methods: []string{http.MethodGet},
Path: mautrix.ClientURLPath{"v3", "account", "whoami"},
Middlewares: []interfaces.RouteHandler{
middlewares.CORSMiddleware,
middlewares.AccountAuth(true, true, true),
},
Handler: routeWhoAmI,
}
func routeWhoAmI(req *interfaces.Request) error {
account := req.MustAccount()
device := req.Device()
resp := &mautrix.RespWhoami{
UserID: account.ID(req.ServerName()),
}
if device != nil {
resp.DeviceID = device.ID
}
return req.Ok(resp)
}
Here you can see the skeleton for a route file:
- The license header
- The
packagedefinition - Any imports
- The
initfunction (see step 4 of registration), which registers the route - The actual
Routedefinition (route metadata) - The route handler definition (the actual callback)
Route handler
The route handler is the callback that is executed when an incoming request is received, validated, and has had all registered middlewares executed. This is where the fun begins!
Utilities
There’s a few utilities and shortcuts on the interfaces.Request variable passed to the route handler:
Request.Context()returns the currentcontext.Contextassociated with this specific request.Request.Account()returns the currentdb.Account, if one is authenticated.- If authentication is not optional on the route,
Request.MustAccount()always returns a non-nildb.Accountpointer. If there is no authenticated account, it will panic.
- If authentication is not optional on the route,
Request.Sender()returns the full user ID of the authenticated user, or an empty string if there isn’t one.Request.Logger()returns azerolog.Loggerinstance, pre-populated with some fields that identify the incoming request. This is preferred overzerolog.Ctx(ctx)in routes.Request.ServerName()returns the current server’s server name.
Tip
The context returned by
Request.Context()is scoped to the current request - this means it will be cancelled when the request finishes or is aborted early. This means it is not suitable for long-running operations, operations that are not allowed to fail, or background tasks. In those cases, inherit a context from the server’s lifetime. TODO: document server component
Propagating errors
One of the more helpful features available via the router is the ability to just return errors directly in the route handler.
When a route handler returns, the router checks if the error value is non-nil. If it is, it first tries to convert it
to a standard Mautrix response error (like mautrix.MForbidden, mautrix.MNotFound, etc). If the type cast succeeds,
the mautrix error is directly serialised to the caller, and no error is logged to the standard logging streams. Also,
mautrix.RespError is detected at any level of wrapping, which means it’s safe to wrap the returned error with
something like fmt.Errorf (provided the %w template is used). It will be picked up during type casting, which
recursively unwraps the error.
However, if the error is non-nil and is not a mautrix response error, it is instead treated as an internal server
error. This means you can return standard errors, like those returned by the std io package, and the router will
gracefully handle them. It will return a HTTP 500 (Internal Server Error) to the caller, with the standard Matrix error
M_UNKNOWN. If debug is true in the config, some additional information regarding the error is returned. Internal
server errors include a request ID in their error message, which allows you to track down the request and associated
logs. An error level log is also always dispatched in this case.
Important
If any error is returned in a route handler, and the route is marked as transactional, the entire transaction will be aborted and rolled back. This means you cannot safely return something like
mautrix.MNotFoundafter writing data, as that data will then be rolled back, and never committed.Some routes can actually take advantage of this behaviour by propagating a user-interactive authentication challenge (as an error), which in turn rolls back any potentially unauthorised changes. Don’t dance with the devil though.
Writing errors directly
Sometimes, you may wish to write an error directly to the response, but not have it go through the post-execution
processing outlined above. To do so, you may use Request.Error. It will write the error you give it directly to
the response, but will always return nil, which allows you to do something like:
func myRoute(req *interfaces.Request) error {
// logic here ... ...
return req.Error(mautrix.MUnknown.WithMessage("i forgor"))
}
In the context of the previous warning, this will also ensure any relevant route-scoped transaction is commited, since
the router will see nil for the error.
Returning responses
Unlike error propagation, you can’t just return a response you want written. Doing so would require a major refactor
and just isn’t planned at this time. Instead, you can use Request.Ok.
Similar to Request.Error, Request.Ok always returns nil. This means it can be used as a return function:
func myRoute(req *interfaces.Request) error {
// logic here ... ...
return req.Ok(struct{}{})
}
But unlike Error, Ok can accept a nil value too, which is then transformed into the empty body {}. This is to
avoid littering the codebase with empty anonymous structs solely intended to return {}.
Since the argument to Ok is any, any struct that supports being marshalled into JSON can be passed in. This means
you can return nearly all mautrix structs, and even some database structs (if they have json tags).
Since the type is any, Go’s type system isn’t expressive enough to require that the value passed in is a pointer.
While passing in a value will still work, it introduces unnecessary copying - always pass in a pointer to a variable
where possible.
All routes MUST return a response in some capacity. If you do not return an error, you must write some sort of
response, usually via Request.Ok. If you do not, the router will become frightened and will report to the response
that the request actually failed. It also logs a very noisy ERROR log that clearly states that this is a bug.
Reading the request body
Depending on how you want to read the body, there’s a couple ways to do it.
To unmarshal a JSON request body into a struct (the most common usage), you can
use Request.Body. This function consumes the request body, unmarshals it into the target struct,
and returns an error appropriate for direct propagation should there be one (like M_NOT_JSON). You would use it like
so:
var body MyStruct // e.g. `var body mautrix.ReqFooBar`
if err := req.Body(&body); err != nil {
return err // propagate any error directly, no need to wrap it.
}
However, if you want to handle binary data, you can just read from Request.RawBody instead!
RawBody returns an io.Reader that is already pre-wrapped with limiters, so you don’t need to worry about
accidentally buffering an infinite stream of data into memory.
Caution
Never use
Request.Request.Body(the standardhttp.Request.Body) directly. Doing so is unsafe, as the body may have already been consumed elsewhere. Likewise, consuming the body when it is later expected to be consumed again will make any attempt at buffering it impossible, and request bodies are a stream, meaning they are immediately consumed unless manually buffered.
Buffering
If you need to read a request body multiple times, you will need to call Request.Buffer() before any further read
operations. This will immediately read the entire request body (up to max_request_bytes) into an in-memory buffer
that is read-only, but also seekable. This means the buffer is immutable, but can be read as many times as your heart
desires (provided you seek back to the start each time, which Request.RawBody() does).
Multiple Request.Buffer() calls also have no effect, meaning it is safe to call multiple times.
If Buffer() fails to read the request body (for example, it is too large, or there’s an unhandled net IO error),
it will itself return that error. Like with Request.Body(), you should propagate that error directly too.
Think twice before buffering. Buffers typically are large enough to always be placed on the heap, which means their
memory allocation is not actually freed and released to the operating system until Go’s garbage collector runs again.
It also means there’s more work for the garbage collector to do, which slows down the whole program. Furthermore, an
adversary could attempt to exhaust the resources of a Venator deployment by repeatedly sending garbage request bodies
that are the same size as max_request_bytes (which is often set rather high).
Buffer only when you have no other way.
Known limitations
While Venator’s router is more advanced than the standard http.ServeMux, it still has some limitations that will bite.
No route inheritance
Routes do not inherit anything - not paths, not middlewares, not rate-limiting. Each route is individually defined
and does not yet support any sort of waterfall dependency system. This means you need to specify full paths in each
Route, and list each relevant middleware (including CORS for client-to-server APIs), etc.
No method multiplexing
Since routes register almost directly with an internal http.ServeMux instance, registering two routes with the same
path but different methods will error. This means you cannot have two Routes, where one is GET /mything, and the
other is POST /mything, etc.
To work around this limitation, you can instead use interfaces.NewSplitHandler as the
Handler for a route. This will transform into a HTTP handler that handles all HTTP methods and routes to the correct
subroutine based on the incoming method, or returns HTTP 405 (method not allowed) if the method is not supported by
the route. For example:
package example
import (
"io"
"net/http"
"codeberg.org/timedout/venator/internal/venatord/interfaces"
"maunium.net/go/mautrix"
)
var MyMultiMethodRoute = interfaces.Route{
Methods: []string{http.MethodGet, http.MethodPost},
Path: mautrix.BaseURLPath{"my", "path", "segments"},
Handler: interfaces.NewSplitHandler(map[string]interfaces.RouteHandler{
http.MethodGet: getMyRoute,
http.MethodPost: postMyRoute,
}),
// etc
}
func getMyRoute(req *interfaces.Request) error {
return req.Ok(nil)
}
func postMyRoute(req *interfaces.Request) error {
_, _ = io.ReadAll(req.Request.Body) // don't do this though
return req.Ok(nil)
}
Then, when this route is registered, if GET /my/path/segments is received, getMyRoute will handle it. If
POST /my/path/segments is received, postMyRoute will handle it. Otherwise, HTTP 405 will be returned.
Warning
Ensure that every method listed in
Methodsis present in the map passed toNewSplitHandler, and vice versa.
NewSplitHandlerhas no knowledge of the route it is handling, so it may unexpectedly reject seemingly valid requests with405 Method Not Allowedif they are listed inMethodsbut not thesubroutesmap passed to the function.Likewise, passing methods to
NewSplitHandlerthat aren’t also inMethodswill result in them being unreachable, and being dropped by the internal mux.
Old patterns
nil-checking Account
Some old route handlers may have code that manually checks if Request.Account() is nil, returning an error if so:
func routeMyEndpoint(req *interfaces.Request) error {
account := req.Account()
if account == nil {
return errs.ErrContextMissing
}
// ...
}
This pattern has been replaced with Request.MustAccount, which ensures that the account is not nil:
func routeMyEndpoint(req *interfaces.Request) error {
account := req.MustAccount()
// ...
}
Fetching the account using AccountFromCtx
This is the same situation as nil-checking Account, but where interfaces.AccountFromCtx is
being used instead of Request.Account. If auth is optional, Request.Account() should be used, otherwise
Request.MustAccount().
Writing JSON directly to the response
Some very old route handlers may have code that manually writes a JSON response to the response via the WriteJSON
method of Router, like so:
func routeMyEndpoint(req *interfaces.Request) error {
// ...
req.Router.WriteJSON(req, http.StatusOK, MyStruct{Foo: "bar"})
return nil
}
This approach has almost entirely been replaced by Request.Ok:
func routeMyEndpoint(req *interfaces.Request) error {
// ...
return req.Ok(&MyStruct{Foo: "bar"})
}
The primary exception to this rule is when you need to write a non-200 successful response, for example, a response
with http 201 Created.
Parsing the request body with ReadAndUnmarshal
Some older route handlers may still make use of interfaces.ReadAndUnmarshal, which previously allowed for both
creating and unmarshalling into a new struct of a specified type. The code may look like this:
func routeMyEndpoint(req *interfaces.Request) error {
// ...
body := interfaces.ReadAndUnmarshal[MyStruct](req)
if body == nil {
return mautrix.MBadJSON // Some places also inconsistently use M_NOT_JSON
}
// ...
}
However, unlike Request.Body, it cannot return an error. This means that any error during unmarshalling would
cause the resulting struct to be nil, and the code had to assume it was a bad input. In reality, there are several
non-input related reasons reading and unmarshalling could fail, and the inability to make this distinction is the reason
this function has been abandoned. By the time this shortfall had been realised, it was already too late to refactor the
function’s signature, and the Request.Body method was implemented to replace it.
The downside of Request.Body over interfaces.ReadAndUnmarshal is that Go does not support generic methods, meaning
the caller has to create the struct before giving it to the method to unmarshal into. This may not be the case in a
future Golang version.
The above pattern has generally been replaced with:
func routeMyEndpoint(req *interfaces.Request) error {
// ...
var body MyStruct // create a zero'd struct, not a nil pointer
if err := req.Body(&body); err != nil {
return err
}
// ...
}
Using Account.ID instead of Request.Sender
Before the Request.Sender method was introduced, route handlers would get the user ID of the authorised user with a
pattern generally like:
func routeMyEndpoint(req *interfaces.Request) error {
account, ok := middlewares.AccountFromCtx(req.Context())
if !ok || account == nil {
return errs.ErrContextMissing
}
sender := account.ID(req.Server.Config().ServerName)
// Some slightly less old handlers may also have used:
// sender := account.Id(req.ServerName())
// ...
}
This was just plain old ugly and was replaced by the much nicer, and nil-safe Request.Sender:
func routeMyEndpoint(req *interfaces.Request) error {
sender := req.Sender()
// ...
}