Skip to content

Routes and Schemas

wiki edited this page Sep 4, 2026 · 1 revision

Routes and schemas

The Route interfaces

Two live in this module, at different levels of detail.

// rextension.Route — the minimum the framework needs to register something.
type Route interface {
	Method() string
	Path() string
}

// rextension/route.Route — what a real route is.
type Route interface {
	Method() string
	Path() string
	Handler() HandlerFunc
}

rextension/route is the canonical package for handlers and route construction. Extensions should import it rather than github.com/kryovyx/rex/route.

import rxroute "github.com/kryovyx/rextension/route"

rt := rxroute.New("GET", "/healthz", func(ctx rxroute.Context) {
	ctx.JSON(200, map[string]string{"status": "ok"})
})

Path parameters are written {name} and read with ctx.Param("name").

The handler context

type Context interface {
	context.Context

	ResponseWriter() http.ResponseWriter
	Request() *http.Request
	Resolver() di.Resolver // the request scope

	Respond(status int, contentType string, body interface{}) error
	Text(status int, v string) error
	JSON(status int, v interface{}) error
	OpenMetrics(status int, v interface{}) error

	Param(name string) string
	SetValue(key, value interface{})
	GetValue(key interface{}) interface{}
}

Resolver() is the request scope: anything registered Scoped is built at most once per request and closed when the request ends. It is typed as di.Resolver, so a handler needs only this module.

SetValue stores a value on the context without replacing the request's own context — useful for passing data from middleware to handler when you do not want to rebuild the *http.Request.

The matched route, from middleware

rt, ok := rxroute.GetMatchedRoute(req)

The router calls SetMatchedRoute after resolving a request to a concrete route, so middleware running in the same request can reach the route without re-parsing the URL. This is what lets a middleware ask "does this route declare X?" at request time when it could not decide at build time.

Prefer deciding at build time via UsePerRoute where you can. Use the matched route when the decision genuinely depends on the request.

Extending a route with capabilities

A route is an interface, so an extension declares what it needs as a further interface and type-asserts. This is how every cross-cutting concern in the ecosystem attaches to routes without any of them sharing a type.

type MyRoute struct {
	rxroute.Route
}

func (r *MyRoute) RequiredSchemes() []string { return []string{"bearer"} }
Interface Declared in Read by
BodySchemaProvider rextension validation, openapi
BodyLimitedRoute rextension the router
SecuredRouteAccessor rextension security, openapi, swagger
DependencyGatedRoute rextension-health health
RateLimitedRoute rextension-ratelimit ratelimit

A route that does not implement one is simply passed through.

Body schemas

type BodySchema interface {
	Kind() SchemaKind
	Types() []interface{}
}

type BodySchemaProvider interface {
	RequestBody() BodySchema
	Responses() map[int]BodySchema
}

Four constructors build one:

rextension.Scalar(CreateUserRequest{})          // a single type
rextension.OneOf(CardPayment{}, BankPayment{})  // exactly one must match
rextension.AnyOf(A{}, B{})                      // one or more may match
rextension.AllOf(Base{}, Extra{})               // all, merged

Declared on a route:

func (r *CreateUser) RequestBody() rextension.BodySchema {
	return rextension.Scalar(CreateUserRequest{})
}

func (r *CreateUser) Responses() map[int]rextension.BodySchema {
	return map[int]rextension.BodySchema{
		201: rextension.Scalar(UserResponse{}),
		422: rextension.Scalar(rextension.Problem{}),
	}
}

Both the validation middleware and the OpenAPI generator read routes through this one interface. A route that does not implement it is passed through without validation and documented without schemas. Returning nil from Responses skips response validation entirely.

The schema contract lives here rather than in the validation extension so that the OpenAPI generator can read it through a named interface. It previously reached those methods with reflect.MethodByName("RequestBody") and type-asserted the result to []interface{} — unchecked, so an unexpected slice type panicked inside document generation. Sharing a type removes the need for the reflection.

Per-route body limits

type BodyLimitedRoute interface {
	MaxBodyBytes() int64 //  0 → the router's limit; -1 → no limit
}
func (r *UploadRoute) MaxBodyBytes() int64 { return 64 << 20 } // 64 MiB
func (r *IngestRoute) MaxBodyBytes() int64 { return -1 }       // streaming

The value is read once, when the route table is built, not per request. The router's own default is 4 MiB; see Router Configuration.

Clone this wiki locally