-
Notifications
You must be signed in to change notification settings - Fork 0
Writing an Extension
An extension is a Go module that implements five hooks and
imports only rextension. This page is the shape every extension in the
ecosystem follows; deviating from it is fine, but the parts marked as rules are
rules.
myext/
go.mod // requires github.com/kryovyx/rextension — and nothing else from this ecosystem
config.go // Config, NewDefaultConfig, ConfigOption, NewConfig
extension.go // Extension, NewMyExtension, WithMyExt, the five hooks
middleware.go // the actual behaviour
route.go // route capability interfaces, if any
module github.com/you/rextension-myext
go 1.27
require github.com/kryovyx/rextension v0.3.0Do not require rex, and do not require dix — the DI contract is
re-exported here.
Every extension exposes both: one that returns the extension, and one that
returns an Option, so an application can write either form.
// NewMyExtension constructs the extension. A nil cfg takes the defaults.
func NewMyExtension(cfg *Config) rx.Extension {
c := NewDefaultConfig()
if cfg != nil {
c = cfg
}
return &Extension{cfg: *c}
}
// WithMyExt is the ergonomic form.
func WithMyExt(cfg *Config) rx.Option {
return rx.WithExtension(NewMyExtension(cfg))
}app := rex.New(myext.WithMyExt(nil))A Config struct, a NewDefaultConfig, and functional options:
type Config struct {
Enabled bool
Budget time.Duration
}
func NewDefaultConfig() *Config {
return &Config{Budget: 2 * time.Second}
}
type ConfigOption func(*Config)
func WithBudget(d time.Duration) ConfigOption {
return func(c *Config) { c.Budget = d }
}
func NewConfig(opts ...ConfigOption) *Config {
c := NewDefaultConfig()
for _, opt := range opts {
if opt != nil { // tolerate a nil in a variadic list
opt(c)
}
}
return c
}Default to the safe thing, and say so when the safe thing does nothing. The CORS extension's default policy allows no origin, because an extension that permitted any origin out of the box would turn adding it into a policy decision its author did not make — and it logs a warning when it starts with an empty allowlist, so "CORS is enabled but everything is refused" is discovered from the log rather than from a browser console.
Configuration that cannot work should stop the deployment, not produce mysterious responses:
func (e *Extension) OnInitialize(_ context.Context, r rx.Rex) error {
e.logger = r.Logger().WithField("extension", "myext")
if err := e.cfg.Policy.Valid(); err != nil {
return fmt.Errorf("myext: %w", err)
}
if e.cfg.Budget <= 0 {
return fmt.Errorf("myext: budget must be positive, got %s", e.cfg.Budget)
}
// … declare …
return nil
}For checks that depend on the whole route table — "this route requires a
scheme nobody registered" — use RouteValidator instead.
OnInitialize is too early: the routes do not all exist yet.
Reach for the most specific registration that fits:
// Applies to every route: cheap, unconditional.
r.Use(mw)
// Applies to some routes, or is configured per route. ← usually this one
r.UsePerRoute(func(info rx.RouteInfo) rx.Middleware {
cfg, ok := info.Route.(MyConfiguredRoute)
if !ok {
return nil // not applicable — nothing attached, no cost
}
budget := cfg.Budget() // read ONCE, at build time
return func(next http.Handler) http.Handler { /* closes over budget */ }
}, rx.PriorityDefault)
// Configuration depends on which router it runs on.
r.UsePerRouter(func(routerName string) rx.Middleware {
return Middleware(&e.cfg, routerName)
}, rx.PriorityCORS)Two rules:
- Pick the priority that describes what the middleware is, from the fixed scale. Not "it needs to run before auth" — if it does, it is rate limiting or CORS, and there is a constant for that.
- Capture configuration in the factory; never look it up per request. The closure is built once per route.
Do not require applications to embed your type. Declare what you need and type-assert:
// In your extension's package.
type BudgetedRoute interface {
Budget() time.Duration
}An application opts in by implementing it on its own route type. A route that does not is passed through untouched. This is how every cross-cutting concern in this ecosystem attaches to routes without any of them sharing a type — see Routes and schemas.
Health and metrics endpoints belong on a separate port from application traffic, so they can be reached when the public listener is saturated and firewalled off from the internet:
err := r.CreateRouter("myext", rx.RouterConfig{Addr: ":9095", ReadTimeout: 5 * time.Second})
if err != nil && !errors.Is(err, rx.ErrRouterExists) {
return err
}
if err := r.RegisterRouteToRouter(rt, "myext"); err != nil {
return err
}Always tolerate ErrRouterExists — another extension may have created the
same router first, and reusing it is correct.
rx.WriteProblem(w, req, http.StatusServiceUnavailable,
rx.ProblemDependencyUnavailable, "a required dependency is unavailable")Never put err.Error() in the detail. See Problem Details.
func (e *Extension) OnInitialize(ctx context.Context, r rx.Rex) error {
return r.Container().Singleton(func() *Registry { return e.registry })
}And consume optionally — an extension that is not installed should degrade, not crash:
var reg rx.SchemeRegistry
if err := r.Container().Resolve(®); err != nil {
e.logger.Debug("no scheme registry; documenting no security")
return nil
}Never use a package-level variable for this. A global registry means two Rex
instances in one process clobber each other, and state leaks between tests in
the same binary — which is exactly why RegisterSecuritySchemes was replaced by
a container-held SchemeRegistry.
-
Import
rextensiononly. Notrex, notdix. - Implement all five hooks, even as no-ops.
-
Declare in
OnInitialize/OnStart. Never register fromOnReady— it is refused withErrRouterFrozen. -
Tolerate
ErrRouterExistsfromCreateRouter. -
Read route configuration once, at build time, inside a
PerRouteMiddlewarefactory. - No package-level mutable state. Hold it on the extension, or in the container.
- Answer with problem documents, safe text only.
- Use the priority constants.
- Nothing exact on top of events — they are lossy by design.
-
go.modrequiresrextensionand nothing else from this ecosystem -
NewMyExtension(cfg *Config)andWithMyExt(cfg *Config) rx.Option -
nilconfig takes safe defaults, and a no-op configuration warns - configuration validated in
OnInitialize, route table inValidateRoutes - middleware registered at a documented priority
- errors answered as RFC 9457 problems
-
OnStop/OnShutdownrelease everything the extension started - tests construct a fresh extension per test — no shared globals
rextension — the Rex extension contract · MIT · © 2026 Kryovyx · pre-1.0, interfaces may change
Building an extension
Contracts
Reference
Ecosystem