Documentation
¶
Overview ¶
Package vov is a declarative, batteries-included Go backend framework.
The endpoint declaration is the single source of truth. A Route groups every method of one URL, and each Endpoint carries its handler alongside the facts that govern it — which middleware stack wraps it, whether it needs an authenticated user, which roles or permissions or paid tier that user must hold — all as plain data. Everything else is a consumer of that declaration rather than a parallel structure that can drift from it: routing today, and Manifest, generated tests, and an OpenAPI or MCP surface after.
The point of keeping those facts as data is that data can be diffed. A policy that is quietly loosened still passes every test, because the tests assert against the declaration that changed; a checked-in Manifest turns it into a modified line in a pull request, which is the only place a person sees it.
An App assembles those declarations onto a standard http.ServeMux, wraps that mux in AppConfig.ServerWrappers, and serves the result — see App.Handler for what is served and App.Mux for the escape hatch that lets routes vov does not model coexist with the ones it does. It owns the server lifecycle too: binding, SIGINT and SIGTERM, draining, and cleanup hooks.
Two things sit deliberately outside the App, so that neither depends on it: LoadEnv, which binds the process environment onto a config struct before anything is built, and Global, a typed write-once holder for the objects an app builds at boot and reaches from many handlers.
Requests pass through two distinct layers, and the difference is not cosmetic:
ServerWrappers every request the server receives, including the 404s and
405s http.ServeMux answers itself — so CORS preflight,
panic recovery, and access logging belong here
MiddlewareStack only requests that reach an endpoint, split by the auth
seam into Pre (outside the guard) and Post (inside it,
where the user is known)
Every piece is meant to be droppable. Handlers keep the standard net/http signature, middleware keeps the standard wrapper shape, and the mux underneath is a plain *http.ServeMux — so a codebase can adopt vov one route at a time and stop wherever it likes.
Index ¶
- Constants
- func ContextWithUser(ctx context.Context, u User) context.Context
- func LoadEnv(dst any) error
- func Manifest(routes []Route) string
- func Ptr[T any](v T) *T
- type App
- func (a *App) Handler() http.Handler
- func (a *App) Invoke(ctx context.Context, req InvokeRequest) (InvokeResult, error)
- func (a *App) MCP() *MCPConfig
- func (a *App) Manifest() string
- func (a *App) Mux() *http.ServeMux
- func (a *App) OnShutdown(fn func(context.Context) error)
- func (a *App) Routes() []Route
- func (a *App) Run() error
- type AppConfig
- type AuthMode
- type AuthResponse
- type Authenticator
- type Endpoint
- type Endpoints
- type EnvError
- type Field
- type Global
- type InvokeRequest
- type InvokeResult
- type Kind
- type MCPConfig
- type MCPTool
- type Middleware
- type MiddlewareStack
- type Readiness
- type RequestMode
- type Route
- type Schema
- type Server
- type User
Constants ¶
const DefaultAddress = ":8080"
DefaultAddress is the listen address used when AppConfig.Address is empty.
const DefaultShutdownTimeout = 15 * time.Second
DefaultShutdownTimeout bounds how long App.Run waits for in-flight requests and cleanup hooks to finish before giving up.
const DefaultStackName = "default"
DefaultStackName is the key in AppConfig.MiddlewareStacks whose stack applies to every endpoint that does not name one.
Variables ¶
This section is empty.
Functions ¶
func ContextWithUser ¶ added in v0.1.0
ContextWithUser returns a copy of ctx carrying u. vov calls this for every endpoint that requires auth; apps rarely need it outside tests.
func LoadEnv ¶ added in v0.1.0
LoadEnv fills dst from the process environment, where dst is a pointer to a struct whose fields carry `env` tags:
type Config struct {
DatabaseURL string `env:"DATABASE_URL,required"`
Port int `env:"PORT" envDefault:"8080"`
Timeout time.Duration `env:"TIMEOUT" envDefault:"30s"`
Debug bool `env:"DEBUG"`
}
The struct is both the declaration and the access surface, so what a service requires and how it reads it cannot drift apart. Call it before NewApp: the dependencies that need configuration are built before the endpoints that use them.
A field with no `env` tag is left alone, so a config struct may hold values that come from somewhere else. Nested structs are walked. A missing optional variable leaves the field at its zero value; declare the field as a pointer to tell "unset" from "set to the zero value", as Ptr does elsewhere in vov.
An unset variable and one set to the empty string are treated the same: the default applies, and a required variable is reported missing.
Every problem is collected and returned together as an EnvError — one boot failure listing all of them beats fixing one variable per restart.
func Manifest ¶ added in v0.1.0
Manifest renders routes as the text to check in beside the code.
It exists because of a gap no test tier can cover. Generated tests assert that the code does what the declaration says, so an endpoint whose policy is quietly loosened stays green — the tests move with it. A checked-in manifest turns that same change into a modified line in a pull request, which is the only place a human sees it.
The output is deterministic: routes are sorted by path, and each route's methods appear in a fixed order, so the file changes only when a declaration changes and not when the Routes slice is reordered.
Every line carries its own method and path rather than being grouped under a URL heading, because a diff shows a handful of lines without their surroundings — a changed line has to say for itself which endpoint it is.
The manifest covers declared routes only. Anything registered straight on App.Mux is outside the framework and cannot appear here.
Types ¶
type App ¶ added in v0.1.0
type App struct {
// contains filtered or unexported fields
}
App assembles a set of Endpoint declarations onto a standard http.ServeMux, holds the http.Server it will serve with, and owns the server lifecycle. Construct it with NewApp.
func NewApp ¶ added in v0.1.0
NewApp validates the configuration, builds a mux, registers every handler on it, and resolves the http.Server to serve with. It fails closed: an endpoint with an empty path, a nil handler, or a route that duplicates an earlier one is a construction error, not a boot-time surprise. (Genuinely conflicting — but non-identical — mux patterns are still reported by the underlying http.ServeMux.)
func (*App) Handler ¶ added in v0.2.0
Handler returns what the app serves: the mux wrapped in AppConfig.ServerWrappers. This is the handler to hand to httptest, or to mount inside a larger server when App.Run is not being used.
func (*App) Invoke ¶ added in v0.2.1
func (a *App) Invoke(ctx context.Context, req InvokeRequest) (InvokeResult, error)
Invoke dispatches a request to a declared endpoint in process and returns the response, without a network round trip.
It exists because two things need the same primitive. A tool call — MCP or otherwise — that maps onto an existing endpoint should reach that endpoint rather than a second implementation of it, or the two drift. And a test runner that calls every declared endpoint as several principals, or creates a record as one user and reaches for it as another, is doing exactly this and nothing more.
It runs the endpoint's full middleware stack, not just its handler. That is not an optimization to skip: an application whose stack republishes the resolved user, opens a transaction, or scopes a tenant would otherwise hand the handler a request missing all of it — and the failure is silent, because the handler still runs.
It does not run AppConfig.ServerWrappers. Those exist for requests arriving over the network — CORS on an in-process call is meaningless, and a tool invocation is already inside whatever recovery and logging wrapped the request that triggered it. Dispatch goes through App.Mux, which is the same distinction App.Handler draws.
The dispatched request carries RequestModeInvoke, so middleware can meter and audit the two channels apart. Handlers should read that, not branch on it — see RequestMode.
The returned error reports a request that could not be built — a malformed path, an invalid method. Anything the router or the endpoint decided, 404 and 405 included, comes back as an InvokeResult with that status, because those are answers rather than failures.
func (*App) MCP ¶ added in v0.2.1
MCP returns the app's Model Context Protocol declaration, or nil when it has none. It is how the vov/mcp module reads what to serve; applications rarely need it.
func (*App) Mux ¶ added in v0.1.0
Mux returns the underlying *http.ServeMux, for registering routes vov does not model. Handlers registered on it bypass the framework's endpoint management and do not appear in Manifest.
It is the registration surface, not the serving one: it does not include AppConfig.ServerWrappers, which wrap it. To exercise the app as it is actually served — in a test, or behind another server — use App.Handler.
func (*App) OnShutdown ¶ added in v0.1.0
OnShutdown registers a cleanup function to run during graceful shutdown, after the HTTP server has stopped accepting connections and in-flight requests have drained. Hooks run in registration order, sharing the shutdown deadline. Safe to call concurrently.
func (*App) Routes ¶ added in v0.1.0
Routes returns the route declarations the app was built from, in the order they were given. The slice is a copy, so a caller cannot reshape the app's routing by editing it.
func (*App) Run ¶ added in v0.1.0
Run starts the HTTP server and blocks until an interrupt (SIGINT) or termination (SIGTERM) signal arrives, or the server stops on its own. On signal it stops accepting connections, waits for in-flight requests to drain, and runs the registered shutdown hooks, all bounded by ShutdownTimeout.
type AppConfig ¶ added in v0.1.0
type AppConfig struct {
// Routes are the URLs to serve, in order. Each groups every method of one
// URL — see [Route]. A URL may be declared only once: two Routes sharing a
// Path is a construction error, since it would split one URL's definition
// across two places.
Routes []Route
// RequireGlobals are the [Global] holders that must be populated before this
// app may be built. Listing one turns "forgot to call Set" from a panic on
// the first request that happens to touch it into an error at construction,
// before the server listens.
//
// vov never reads what a holder contains; it only asks whether it is ready.
RequireGlobals []Readiness
// Stacks are the named middleware combinations endpoints can be wrapped in,
// each split into a Pre and Post half by the auth seam — see
// [MiddlewareStack]. The stack under [DefaultStackName] applies to every
// endpoint that does not name another; an endpoint naming one that was never
// declared is a construction error.
//
// Stacks apply to endpoints only. Routes registered straight on [App.Mux]
// are outside the framework and get no middleware from it.
MiddlewareStacks map[string]MiddlewareStack
// ServerWrappers wrap the whole assembled handler, outermost first, and
// therefore see every request the server receives — not only the ones that
// reach an endpoint. They wrap in both directions: a wrapper observes the
// response on the way out as well as the request on the way in, which is
// what makes access logging and panic recovery possible here.
//
// That is the difference from MiddlewareStacks, and it is not a shade of
// meaning. http.ServeMux answers two kinds of request itself, before
// dispatching to anything registered: a path no route declares (404), and a
// method the matched path does not declare (405). No endpoint middleware
// runs for either. A CORS preflight is exactly the second kind — OPTIONS
// against a path registered for GET and PATCH — so a cross-origin browser
// app cannot work unless something outside the mux answers it.
//
// The same is true of the things an operator most wants to be unconditional:
// panic recovery, request-id stamping, access logging. An unmatched path
// that panics or goes unlogged is precisely the request worth a record.
//
// Because it wraps the mux, it also covers routes registered straight on
// [App.Mux]. That is unavoidable rather than chosen: the mux decides its own
// 404 internally, so anything that can see that decision is necessarily
// outside everything the mux serves.
//
// A wrapper runs before routing, so it cannot know which endpoint matched:
// no path parameters, no AuthMode, no declared roles. It is also invisible to
// [Manifest], which documents endpoint policy. Keep authorization out of it —
// a rule declared here cannot be reviewed there.
ServerWrappers []Middleware
// Authenticator resolves the user a request acts as. Endpoints require an
// authenticated user unless they declare [AuthModeNone], so this is required
// unless every endpoint opts out — [NewApp] rejects a configuration that
// needs an authenticator and has none, rather than starting a server whose
// every protected route answers 401.
Authenticator Authenticator
// Address is the TCP listen address, e.g. ":8080". Defaults to
// [DefaultAddress] when empty.
Address string
// ShutdownTimeout bounds graceful shutdown. Defaults to
// [DefaultShutdownTimeout] when zero.
ShutdownTimeout time.Duration
// MCP, when set, describes the Model Context Protocol server this app
// exposes — see [MCPConfig]. The endpoints it exposes are the ones declaring
// an [MCPTool]; nothing is restated here.
//
// Setting it does not serve anything by itself. The vov/mcp module builds a
// handler from this and the declarations, which the app mounts where it
// likes.
MCP *MCPConfig
// Server optionally tunes the http.Server vov serves with — timeouts, TLS,
// connection hooks, byte limits, and so on. It mirrors http.Server but drops
// Addr and Handler, which vov owns (one place for each, no conflicting
// values). Its defaulted timeout fields are pointers: a nil field takes vov's
// default, an explicit value — including 0, meaning "no timeout" — is honored.
// Nil means all defaults. See [Server].
Server *Server
}
AppConfig is the declarative input to NewApp. Apart from Handlers, its fields are the app-wide operational preferences; more are added here as they are needed rather than imagined up front.
type AuthMode ¶ added in v0.1.0
type AuthMode string
AuthMode says whether an Endpoint needs an authenticated user.
The zero value — an endpoint that says nothing — means AuthModeRequired. An endpoint is opened only by naming AuthModeNone, so forgetting to declare auth protects a route rather than exposing it. Everything here is arranged around that: see [AuthMode.required].
const ( // AuthModeRequired demands an authenticated user. It is also what the zero // value means, so it rarely needs writing. AuthModeRequired AuthMode = "required" // AuthModeNone opts out: no user is resolved and none is required. Reach for // it deliberately — it is the visible, greppable token that says a route is // open. AuthModeNone AuthMode = "none" )
type AuthResponse ¶ added in v0.2.0
type AuthResponse struct {
// contains filtered or unexported fields
}
AuthResponse is the part of the response an Authenticator may touch: its headers, and nothing else. vov owns the status and the body, because vov is what decides whether the outcome is 401, 403, 402, or the handler's own response — so an authenticator can add a header without any risk of writing a response that collides with the one about to be sent.
The motivating case is a credential that must be revoked as it is rejected. A long-lived signed cookie belonging to an account that no longer exists should be cleared while it is refused; otherwise the browser re-sends the dead credential on every request until it expires on its own, which for a 30-day session means 30 days.
Rotating a session on successful authentication — sliding expiry — is the same mechanism, on the other branch.
func NewAuthResponse ¶ added in v0.2.1
func NewAuthResponse(h http.Header) AuthResponse
NewAuthResponse wraps h so that an Authenticator can be driven from outside this package — by an in-process caller such as an MCP tool server, or a test.
Pass the header of the response the call will produce. Passing a throwaway header is valid and means any header the authenticator sets is discarded, which is the honest thing to do when there is no response to put it on.
func (AuthResponse) Header ¶ added in v0.2.0
func (a AuthResponse) Header() http.Header
Header returns the response headers, for anything AuthResponse.SetCookie does not cover — a WWW-Authenticate challenge, for instance.
func (AuthResponse) SetCookie ¶ added in v0.2.0
func (a AuthResponse) SetCookie(c *http.Cookie)
SetCookie adds a Set-Cookie header, like http.SetCookie does for a ResponseWriter. Clearing a cookie is the same call with MaxAge set to -1 and the Name and Path that were used to write it.
type Authenticator ¶ added in v0.1.0
type Authenticator func(resp AuthResponse, r *http.Request) (User, error)
Authenticator resolves the User a request acts as. The app supplies it, because only the app knows where credentials live — a session cookie, a bearer token, a signed header — and how to look them up.
It receives an AuthResponse so that it can set headers on the way past — clearing a revoked cookie, rotating a live one. Headers set there survive whatever vov writes next, including a refusal.
Return values are distinguished on purpose:
(user, nil) → the request is authenticated as user
(nil, nil) → no credentials were presented; vov answers 401
(nil, err) → the lookup itself failed (database down, and so on); vov
answers 500, because a broken dependency is not a bad password
Return an untyped nil User for "no user". A typed nil pointer is a non-nil interface, and vov would call IsAuthenticated on it.
type Endpoint ¶ added in v0.1.0
type Endpoint struct {
// Handler serves the request. It is the standard net/http signature; the
// objects an app builds at boot are reached through a [Global] holder rather
// than being threaded through here.
Handler http.HandlerFunc
// MiddlewareStack names the [MiddlewareStack] from
// [AppConfig.MiddlewareStacks] that wraps this endpoint. Empty selects
// [DefaultStackName], so the common case says nothing. Naming a stack
// replaces the default outright rather than adding to it; a name that was
// never declared is a construction error.
MiddlewareStack string
// AuthMode declares this endpoint's authentication requirement. The zero
// value requires an authenticated user, so a method that says nothing is
// protected; [AuthModeNone] opts out. It is per-method on purpose: a URL may
// be readable by anyone and writable only by an authenticated user.
AuthMode AuthMode
// RolesAnyOf restricts the endpoint to users holding at least one of them —
// any-of, because a role is an identity and any of the listed identities
// will do: "an admin or an owner may do this". Empty means no restriction.
RolesAnyOf []string
// PermissionsAllOf restricts the endpoint to users holding every one of
// them — all-of, because a permission is a capability and each listed one is
// needed. Every entry is checked. Empty means no restriction.
//
// The two may be declared together; a user must then satisfy both. Neither
// is checked on an endpoint declaring [AuthModeNone], which resolves no
// user, so that combination is a construction error rather than a
// requirement that silently does nothing.
PermissionsAllOf []string
// Body describes the shape of the request body, built with [BodyOf] from the
// type the handler decodes into. Query does the same for the query string,
// with [QueryOf].
//
// They describe; vov does not decode or enforce them. What they are for is
// the consumers that cannot work without a machine-readable contract: an
// OpenAPI document, an MCP tool's input schema, and a test runner that has to
// build a valid body to attempt a request it should not be allowed to make.
// Declaring the shape once, on the endpoint, is what keeps those three from
// becoming three copies that drift.
//
// A type vov cannot describe is a construction error, so a declaration never
// silently renders as something it is not.
Body *Schema
Query *Schema
// MCPTool, when set, declares this endpoint callable by an AI assistant over
// the Model Context Protocol, under the given name — see [MCPTool]. It adds
// only that name and the prose a model reads; the method, path, arguments
// and policy are the ones already declared here, so a tool is never a second
// description of the endpoint.
MCPTool *MCPTool
// MinTier restricts the endpoint to users whose [User.Tier] is at least this
// high. Zero — the default — places no restriction, so only endpoints behind
// a paywall mention it.
//
// A user who is refused here gets **402 Payment Required**, not 403, because
// the two say different things to a client: 403 is a denial, 402 is a price.
// A frontend can act on that — show the upgrade panel rather than a generic
// error — which is why tier is a declaration of its own rather than a
// permission.
//
// Reach for it only for access that money actually unlocks. Ordinal gating
// that payment cannot resolve belongs in [Endpoint.PermissionsAllOf], whose
// refusal is honest about being final.
MinTier int
}
Endpoint is one method of one URL: the handler, plus the facts that describe how it is served — all as plain data. It lives inside an Endpoints group, which supplies the path and the method, so an Endpoint says only what is specific to that method.
It is deliberately a value type with no exported methods and no hidden state, so it can be read, listed, and (later) checked by tools without constructing an App. Its zero value declares nothing: a method field left empty on a group is a method that URL does not answer.
type Endpoints ¶ added in v0.1.0
type Endpoints struct {
// Any answers every method that no specific field below claims. It is for
// catch-alls — a static file tree, a single-page-app fallback — not for
// ordinary endpoints, which should name their method so the declaration
// says what the URL supports.
Any Endpoint
GET Endpoint
// HEAD rarely needs declaring: net/http already answers HEAD with the GET
// endpoint, and reports it in Allow. Declare it only to handle HEAD
// differently from GET.
HEAD Endpoint
POST Endpoint
PUT Endpoint
PATCH Endpoint
DELETE Endpoint
OPTIONS Endpoint
}
Endpoints is every method of one URL, declared together.
It is separate from Route so that it can live beside the handlers that implement it — in the same file, next to the code it dispatches to — while the AppConfig.Routes slice stays a thin mapping from URL to endpoint group. The grouping is then visible in both places: the route list says which URLs exist, and the handler file says what one URL answers.
Methods are fields rather than map keys, so they cannot be misspelled, an editor completes them, and a single go-to-definition shows a URL's whole surface. Each carries its own Endpoint, so — unlike a Django class view — a URL's methods need not share configuration: one may take a different middleware stack or opt out of auth without affecting its siblings.
A method left empty is simply not registered, and requests using it get the 405 and Allow header that net/http derives from the methods that are.
type EnvError ¶ added in v0.1.0
type EnvError struct {
Problems []string
}
EnvError reports everything wrong with the environment at once. Its Problems name variables and say what was expected; they never contain a variable's value, because the environment is where credentials live and an error string ends up in logs.
type Field ¶ added in v0.2.1
type Field struct {
// Name is the JSON name, from the json tag when there is one.
Name string
// Schema describes the field's value.
Schema *Schema
// Required reports whether the field must be present. It comes from a
// `vov:"required"` tag and never from inference: `omitempty` is about how a
// value is written, not whether a caller must send it, and reading a
// contract off an encoding hint would make the declaration mean something
// its author did not write.
Required bool
}
Field is one member of an object Schema.
type Global ¶ added in v0.1.0
type Global[T any] struct { // contains filtered or unexported fields }
Global is a typed, write-once holder for an object an application builds at boot and needs from many handlers: a struct of a database handle, a logger, service clients.
The name is literal. This is process-wide state, with the coupling and the test-substitution hazards that implies, and it says so rather than dressing itself up as dependency injection — vov injects nothing, constructs nothing, and never looks inside what it holds.
It exists so that handlers can reach those objects while keeping the standard net/http signature. The alternatives both cost more than they give: threading a dependency type through the framework makes every declaration mention it and forces one shared type on every handler, and stashing values in the request context trades a compile error for a runtime assertion.
Declare one holder per application, next to the type it holds:
type Deps struct{ DB *sql.DB; Log *slog.Logger }
var deps = vov.NewGlobal[*Deps]()
func main() {
deps.Set(&Deps{DB: db, Log: log})
app, err := vov.NewApp(vov.AppConfig{RequireGlobals: []vov.Readiness{deps}, ...})
}
func listTasks(w http.ResponseWriter, r *http.Request) {
d := deps.Get()
d.Log.Debug("listing")
}
Get is typed, so nothing is asserted at the call site and a wrong field is a compile error. What it does not do — and cannot — is put a handler's dependencies in its signature: reaching for a holder is invisible to a reader of the declaration. That is the price of the standard handler shape.
A Global is safe for concurrent use. Handlers may call Get on every request; it takes a read lock and returns the stored value.
func NewGlobal ¶ added in v0.1.0
NewGlobal returns an empty holder. Populate it with Global.Set once the value exists — typically after configuration has been loaded and before NewApp is called.
func (*Global[T]) Describe ¶ added in v0.1.0
Describe names the held type, for error messages. It satisfies Readiness.
func (*Global[T]) Get ¶ added in v0.1.0
func (g *Global[T]) Get() T
Get returns the stored value. It panics if Set has not been called, rather than handing back a zero value that would fail later as a nil dereference somewhere less obvious.
Pass the holder to AppConfig.RequireGlobals to turn that panic into a construction error instead, caught before the server ever listens.
type InvokeRequest ¶ added in v0.2.1
type InvokeRequest struct {
// Method is the HTTP method to dispatch. Empty means GET.
Method string
// Path is the URL path, e.g. "/projects/42". It must begin with "/". Path
// parameters are matched against the declared routes exactly as they are for
// a network request, so r.PathValue works in the handler.
Path string
// Query is the URL query, if any.
Query url.Values
// Body is the request body. When it is non-empty and Header sets no
// Content-Type, application/json is assumed — both known callers send JSON.
Body []byte
// Header carries any additional request headers.
Header http.Header
// User is the principal the call acts as, already resolved.
//
// Invoke does not authenticate: it takes this user's identity on trust,
// because the caller is in-process and has established identity by whatever
// means it owns — an OAuth access token for a tool call, a fixture for a
// test. Supplying the wrong user here is an authorization bypass, so the
// caller must be the thing that verified it.
//
// What Invoke does still enforce is everything the endpoint declares about
// this user: RolesAnyOf, PermissionsAllOf and MinTier are checked by the same
// guard a network request meets, with the same 401, 403 and 402 answers. A
// nil User is anonymous and is refused by any endpoint requiring auth.
User User
}
InvokeRequest describes an in-process call to a declared endpoint.
It is a request in every sense that matters to the endpoint — path parameters resolve, middleware runs, the auth guard enforces the declaration — except that nothing is serialized and no socket is involved.
type InvokeResult ¶ added in v0.2.1
InvokeResult is what the endpoint answered.
type Kind ¶ added in v0.2.1
type Kind string
Kind is the JSON type of a declared value.
const ( KindString Kind = "string" KindNumber Kind = "number" KindInteger Kind = "integer" KindBoolean Kind = "boolean" KindArray Kind = "array" KindObject Kind = "object" // KindAny is a value whose shape is deliberately not described — a // json.RawMessage or an any field. It says "something goes here", which is // honest, rather than pretending to a structure that was never declared. KindAny Kind = "" )
type MCPConfig ¶ added in v0.2.1
type MCPConfig struct {
// Name and Version identify this server to clients. Both are required when
// MCPConfig is set.
Name string
Version string
// Title is an optional human-readable name.
Title string
// Instructions tell an assistant how to navigate this particular product —
// which tool to establish context with before the others make sense, for
// instance. They reach the model once, at connection, and change how well it
// uses everything else.
Instructions string
// Authenticate resolves the user a tool call acts as. It is required, and it
// is the seam vov cannot cross: only the application knows which credentials
// its tool endpoint honours — an OAuth access token bound to an audience is
// a different thing from the session cookie a browser sends.
//
// An app whose tool endpoint honours its ordinary credentials passes the
// same function it gave [AppConfig.Authenticator].
Authenticate Authenticator
// Path is the URL the tool server is served at, e.g. "/mcp". Setting it,
// together with BuildHandler, is all it takes: [NewApp] builds the handler
// and mounts it, and there is nothing to do afterwards.
//
// Leave it empty to mount the handler yourself — build one with the vov/mcp
// module and register it wherever you like, which is what an app that wraps
// the tool endpoint in its own OAuth gate will want.
Path string
// BuildHandler builds the handler served at Path. Pass the vov/mcp module's
// constructor:
//
// Path: "/mcp", BuildHandler: mcp.NewHandler
//
// It is a field rather than something vov calls directly because the protocol
// implementation lives in its own module: root vov has no dependencies, and
// naming the builder here is what keeps it that way while still leaving the
// whole declaration in one place. Required when Path is set, ignored
// otherwise.
BuildHandler func(*App) (http.Handler, error)
// Logger, if set, receives protocol-level logging from the tool server.
Logger *slog.Logger
}
MCPConfig describes the Model Context Protocol server an app exposes, when it exposes one. It is the app-level half of the declaration; the per-endpoint half is MCPTool.
Setting it does not start anything. It records what the server is called and how a caller is identified, so that the vov/mcp module can build a handler from declarations alone rather than being handed a second copy of them.
type MCPTool ¶ added in v0.2.1
type MCPTool struct {
// Name is what an assistant calls, e.g. "list_tasks". It must be unique
// across the app.
Name string
// Description tells the assistant what the tool is for and when to use it.
// It is the whole of what a model knows when choosing between tools, so it
// is worth more care than its length suggests.
Description string
// ReadOnly marks the tool as making no modifications, which lets a client
// treat it as safe to call freely. Nil derives it from the endpoint's
// method: GET and HEAD are read-only, everything else is not.
ReadOnly *bool
}
MCPTool declares that an endpoint is callable by an AI assistant over the Model Context Protocol.
It carries only what cannot be derived from the endpoint it sits on. The method, the path, the arguments, who may call it and what happens when they may not are all already declared — a tool adds a name and the prose that tells a model when to reach for it, and nothing else. Restating the route here would be the same duplication Route exists to remove.
Descriptions are usually long, and long string literals read badly inside a route table. Keep them in a const beside the handler and reference it:
const listTasksDoc = "Every task on the list, with its id, title and owner. " +
"Start here: the ids returned are what get_task takes."
GET: vov.Endpoint{
Handler: listTasks,
Query: vov.QueryOf[listTasksQuery](),
MCPTool: &vov.MCPTool{Name: "list_tasks", Description: listTasksDoc},
}
Declaring an MCPTool does not by itself serve anything. A protocol package — see the vov/mcp module — reads these declarations and exposes them; vov itself only records that the endpoint is meant to be reachable that way, which is a fact worth having in the manifest whether or not a server is mounted.
type Middleware ¶ added in v0.1.0
Middleware wraps an http.Handler, following the standard net/http idiom. It is declared as data — inside a MiddlewareStack — rather than pre-applied to the handler, so that what wraps a route stays readable off the declaration.
type MiddlewareStack ¶ added in v0.1.0
type MiddlewareStack struct {
// Pre runs outside the auth guard, outermost first, for every request that
// reaches this endpoint — including the ones the guard rejects, which is what
// keeps a 401 logged and stamped.
//
// "Reaches this endpoint" is the limit, and it matters. Anything that must
// see every request the server receives — CORS, panic recovery, access
// logging, request ids on unrouted paths — belongs in
// [AppConfig.ServerWrappers] instead. http.ServeMux answers a request for an
// undeclared path (404) or an undeclared method (405) itself, before any Pre
// middleware exists to run, and a CORS preflight is exactly such a request:
// put CORS here and a browser client silently breaks.
//
// What belongs here is endpoint-scoped and must precede authentication: a
// per-route rate limit, a body-size cap, a webhook signature check. So does
// anything an endpoint needs while declaring [AuthModeNone], since Post does
// not run there.
Pre []Middleware
// Post runs inside the auth guard, outermost first, so it can read the user
// with [UserFrom]: audit logging, tenant scoping, per-user rate limits.
//
// It runs only on endpoints that require auth. An endpoint declaring
// [AuthModeNone] has no user, so Post is skipped for it — a stack shared between
// protected and open endpoints keeps working, but middleware an open
// endpoint depends on must live in Pre.
Post []Middleware
}
MiddlewareStack is one named combination of middleware, split by the seam where the request's user becomes known.
Stacks are declared once in AppConfig.MiddlewareStacks and selected by name from an endpoint. Real services converge on a handful of combinations — public, authenticated, admin, webhook — so naming them puts each combination in one place and turns "what wraps this route?" into a word a reviewer can read off the declaration.
type Readiness ¶ added in v0.1.0
Readiness is anything that can report whether it has been populated. A *Global satisfies it, which is what lets AppConfig.RequireGlobals check holders without knowing the type inside them — the reason none of vov's other types need a type parameter.
type RequestMode ¶ added in v0.2.1
type RequestMode string
RequestMode says how a request reached an endpoint: over the network, or in process through App.Invoke.
It exists so that an application can meter and audit the two channels separately — a tool call spending a different quota than a browser call, an audit row recording which one wrote a record. It does not exist so that handlers can behave differently.
Read it, do not branch on it ¶
The reason App.Invoke dispatches to the declared endpoint at all is that a tool and an API call should be the same code path. A handler that changes what it *does* based on the mode puts two implementations behind one URL, which is the exact thing the in-process dispatch exists to prevent — and the second implementation is the one no test, no manifest, and no reviewer is looking at.
The line that holds in practice: recording the mode is fine, deciding with it is not. Metering, audit trails, and log fields are recording. An `if mode == …` around business logic is deciding.
const ( // RequestModeNetwork is a request that arrived over HTTP. It is what // [ModeFrom] reports for any request not tagged otherwise, which is the safe // default: an untagged request really is a network request, and defaulting // the other way would bill browser traffic to a tool's allowance. RequestModeNetwork RequestMode = "network" // RequestModeInvoke is a request dispatched in process by [App.Invoke] — // a tool call, or a test runner exercising an endpoint. RequestModeInvoke RequestMode = "invoke" )
func ModeFrom ¶ added in v0.2.1
func ModeFrom(ctx context.Context) RequestMode
ModeFrom reports how the request carrying ctx arrived. It answers RequestModeNetwork for anything vov did not dispatch itself.
vov distinguishes exactly two channels, because those are the two it can tell apart. An application that needs a finer label — which *kind* of in-process caller, say an MCP tool as against a test runner — can put its own value on the context it passes to App.Invoke, where it will reach the handler alongside this one.
type Route ¶ added in v0.1.0
type Route struct {
// Path is the URL path pattern, using net/http ServeMux syntax, e.g.
// "/projects/{projectId}/rounds". It must be non-empty and begin with "/".
Path string
// Endpoints are the methods this URL answers. Declaring none is a
// construction error: a Route that serves nothing is a mistake, not a
// placeholder.
Endpoints Endpoints
}
Route binds one URL to the Endpoints that serve it.
type Schema ¶ added in v0.2.1
type Schema struct {
Kind Kind
// Fields are the members of an object, in declaration order. Empty on an
// object means free-form — a map, whose keys are not known ahead of time.
Fields []Field
// Elem is the element schema of an array.
Elem *Schema
// Nullable reports whether null is an accepted value. A Go pointer is both
// nullable and, absent a required tag, optional.
Nullable bool
// Format carries a JSON Schema format hint, e.g. "date-time".
Format string
// TypeName is the Go type the schema was derived from, for the manifest.
TypeName string
// contains filtered or unexported fields
}
Schema describes the shape of a request body or query string.
It is derived from a Go type — the same type the handler decodes into — so a declaration cannot drift from the code that reads it. What it is *for* is being consumed: an OpenAPI document, an MCP tool's input schema, and a test runner that has to construct a valid-but-foreign body to attempt a cross-tenant write all need this same description, and deriving it once is why it lives on the endpoint.
It describes; it does not enforce. vov does not decode the body, because decoding is where a PATCH decides whether an absent field and an explicit null mean the same thing — a distinction Go's own decoder collapses for a plain pointer, and one only the application's types can resolve. Handlers decode as they always did; semantic rules stay with them.
func BodyOf ¶ added in v0.2.1
BodyOf describes T as an endpoint's request body.
Pass the type the handler decodes into:
type createProject struct {
Name string `json:"name" vov:"required"`
Country *string `json:"hqCountry"` // optional, and may be null
}
POST: vov.Endpoint{Handler: create, Body: vov.BodyOf[createProject]()}
func QueryOf ¶ added in v0.2.1
QueryOf describes T as an endpoint's query string. T should be a flat struct: a query is a list of names and values, so a nested object has no natural encoding and is rejected.
func (*Schema) Err ¶ added in v0.2.1
Err reports why a declared type could not be described, if it could not be.
func (*Schema) JSONSchema ¶ added in v0.2.1
JSONSchema renders the schema as a JSON Schema document, which is the shape an MCP tool's inputSchema and an OpenAPI requestBody both want.
type Server ¶ added in v0.1.0
type Server struct {
// DisableGeneralOptionsHandler, if true, passes "OPTIONS *" requests to the Handler,
// otherwise responds with 200 OK and Content-Length: 0.
DisableGeneralOptionsHandler bool
// TLSConfig optionally provides a TLS configuration for use
// by ServeTLS and ListenAndServeTLS. Note that this value is
// cloned by ServeTLS and ListenAndServeTLS, so it's not
// possible to modify the configuration with methods like
// tls.Config.SetSessionTicketKeys. To use
// SetSessionTicketKeys, use Server.Serve with a TLS Listener
// instead.
TLSConfig *tls.Config
// default: 15 * time.Second
// ReadTimeout is the maximum duration for reading the entire
// request, including the body. A zero or negative value means
// there will be no timeout.
//
// Because ReadTimeout does not let Handlers make per-request
// decisions on each request body's acceptable deadline or
// upload rate, most users will prefer to use
// ReadHeaderTimeout. It is valid to use them both.
ReadTimeout *time.Duration
// default: 10 * time.Second
// ReadHeaderTimeout is the amount of time allowed to read
// request headers. The connection's read deadline is reset
// after reading the headers and the Handler can decide what
// is considered too slow for the body. If zero, the value of
// ReadTimeout is used. If negative, or if zero and ReadTimeout
// is zero or negative, there is no timeout.
ReadHeaderTimeout *time.Duration
// default: 15 * time.Second
// WriteTimeout is the maximum duration before timing out
// writes of the response. It is reset whenever a new
// request's header is read. Like ReadTimeout, it does not
// let Handlers make decisions on a per-request basis.
// A zero or negative value means there will be no timeout.
WriteTimeout *time.Duration
// default: 60 * time.Second
// IdleTimeout is the maximum amount of time to wait for the
// next request when keep-alives are enabled. If zero, the value
// of ReadTimeout is used. If negative, or if zero and ReadTimeout
// is zero or negative, there is no timeout.
IdleTimeout *time.Duration
// MaxHeaderBytes controls the maximum number of bytes the
// server will read parsing the request header's keys and
// values, including the request line. It does not limit the
// size of the request body.
// If zero, DefaultMaxHeaderBytes is used.
MaxHeaderBytes int
// MaxHeaderValueCount controls the maximum number of header
// values that the server is willing to parse from a request.
// If zero, DefaultMaxHeaderValueCount is used.
// Note that comma-separated values in a single header line are
// counted once, while values sent as multiple header lines are
// counted multiple times.
MaxHeaderValueCount int
// TLSNextProto optionally specifies a function to take over
// ownership of the provided TLS connection when an ALPN
// protocol upgrade has occurred. The map key is the protocol
// name negotiated. The Handler argument should be used to
// handle HTTP requests and will initialize the Request's TLS
// and RemoteAddr if not already set. The connection is
// automatically closed when the function returns.
// If TLSNextProto is not nil, HTTP/2 support is not enabled
// automatically.
//
// Historically, TLSNextProto was used to disable HTTP/2 support.
// The Server.Protocols field now provides a simpler way to do this.
TLSNextProto map[string]func(*http.Server, *tls.Conn, http.Handler)
// ConnState specifies an optional callback function that is
// called when a client connection changes state. See the
// ConnState type and associated constants for details.
ConnState func(net.Conn, http.ConnState)
// ErrorLog specifies an optional logger for errors accepting
// connections, unexpected behavior from handlers, and
// underlying FileSystem errors.
// If nil, logging is done via the log package's standard logger.
ErrorLog *log.Logger
// BaseContext optionally specifies a function that returns
// the base context for incoming requests on this server.
// The provided Listener is the specific Listener that's
// about to start accepting requests.
// If BaseContext is nil, the default is context.Background().
// If non-nil, it must return a non-nil context.
BaseContext func(net.Listener) context.Context
// ConnContext optionally specifies a function that modifies
// the context used for a new connection c. The provided ctx
// is derived from the base context and has a ServerContextKey
// value.
ConnContext func(ctx context.Context, c net.Conn) context.Context
// HTTP2 configures HTTP/2 connections.
HTTP2 *http.HTTP2Config
// Protocols is the set of protocols accepted by the server.
//
// If Protocols includes UnencryptedHTTP2, the server will accept
// unencrypted HTTP/2 connections. The server can serve both
// HTTP/1 and unencrypted HTTP/2 on the same address and port.
//
// If Protocols is nil, the default is usually HTTP/1 and HTTP/2.
// If TLSNextProto is non-nil and does not contain an "h2" entry,
// the default is HTTP/1 only.
Protocols *http.Protocols
// DisableClientPriority specifies whether client-specified priority, as
// specified in RFC 9218, should be respected or not.
//
// This field only takes effect if using HTTP/2, and if no custom write
// scheduler is defined for the HTTP/2 server. Otherwise, this field is a
// no-op.
//
// If set to true, requests will be served in a round-robin manner, without
// prioritization.
DisableClientPriority bool
}
A Server defines parameters for running an HTTP server. The zero value for Server is a valid configuration.
type User ¶ added in v0.1.0
type User interface {
// IsAuthenticated reports whether the request carries a valid principal.
IsAuthenticated() bool
// HasRole reports whether the user holds the named role.
HasRole(role string) bool
// HasPermission reports whether the user holds the named permission.
HasPermission(permission string) bool
// Tier reports the user's paid tier: 0 for free, and higher numbers for
// higher subscriptions. It is ordinal, so an endpoint declaring
// [Endpoint.MinTier] admits every tier at or above it and a subscription
// upgrade never removes access.
//
// A model with nothing to sell returns 0, which is honest and fail-closed:
// an endpoint that later gates on tier refuses rather than opening.
Tier() int
}
User is the principal a request acts as. It is behavioral and deliberately tiny: vov asks it yes-or-no questions and never reads its storage. Capability lives on the model — the app decides what a role or a permission means — while policy lives on the endpoint declaration, where it can be read and reviewed.
A model that does not use roles or permissions answers false, which is both honest and fail-closed: an endpoint that later requires one is refused rather than accidentally opened.
vov asks only what the matched endpoint declares: HasRole only when the endpoint lists roles, HasPermission once per listed permission, Tier only when it sets MinTier. An endpoint that declares none of them costs none of them.
So an answer that needs I/O — a paid-until lookup, a permission table — can be resolved lazily on first call and cached for the rest of the request. Prefer that to resolving everything in the Authenticator: the Authenticator is not told which endpoint matched, so work done there is paid on every authenticated request whether the endpoint consults it or not. Do cache, though — a route listing two permissions asks twice.
func UserFrom ¶ added in v0.1.0
UserFrom returns the User resolved for the request, and whether there was one. Handlers on endpoints that require auth can rely on ok being true.
vov intentionally returns the interface rather than a generic concrete type: the guard only ever asks yes/no questions, so only the interface belongs here. Apps that want their own type back wrap this in a one-line typed accessor.