vatel

package module
v1.25.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Nov 9, 2026 License: MIT Imports: 30 Imported by: 2

README

vatel

GoDoc

vatel is a REST framework built on fasthttp and fasthttp/router. An endpoint is a method, a path and a controller factory; vatel decodes path parameters, query string and JSON body into the controller, calls it, serializes the result to JSON and handles errors, logging (with masking of sensitive fields), metrics, authentication and authorization.

go get github.com/axkit/vatel@latest

Requires Go 1.26, fasthttp v1.77.0+ and fasthttp/router v1.5.4+ and, for WebSocket, regorov/websocket v0.1.5+ (Go modules raise them in your project automatically). Errors are built with github.com/axkit/errors v1.1.0+. Upgrading from v0.14? See MIGRATION.md.

Contents

Quick start

A complete runnable application is in example/ (ADDR=:18080 go run ./example). It verifies HS256 JWTs signed with the JWT_SECRET environment variable (without it, a public development key is used and a warning is logged). To call the protected endpoints, mint a development token (user 100, every permission, valid 24 hours) with the same JWT_SECRET:

TOKEN=$(go run ./example token)
curl -H "Authorization: Bearer $TOKEN" localhost:18080/orders

go run ./example token 2 mints the token for organization 2 (default 1). The order endpoints serve only the orders of the token's organization: GET /orders lists them, GET, PUT and DELETE of /orders/{id} give 404 for an order of another organization, and POST /orders creates the order in the token's organization. The example also serves WebSocket on /ws: send {"path":"updateAccessToken","data":{"mid":"<uuid>","at":"<token>"}} within 10 seconds, then {"path":"orders.watch","data":{"id":1}} (see Security context and closing connections).

The minimal version:

package main

import (
	"context"
	"os"
	"os/signal"
	"syscall"
	"time"

	"github.com/axkit/errors"
	"github.com/axkit/vatel"
	"github.com/axkit/vatel/auth"
	"github.com/fasthttp/router"
	"github.com/rs/zerolog"
)

type Order struct {
	ID      int    `json:"id"`
	Product string `json:"product"`
}

var errOrderNotFound = errors.Template("order not found").Code("APP-0001").StatusCode(404)

// GET /orders/{id}
type GetOrder struct {
	vatel.PathParams[struct {
		ID int `param:"id"`
	}]
	vatel.Output[Order]
}

func (c *GetOrder) Handle(ctx vatel.Context) error {
	if c.Params.ID != 1 {
		return errOrderNotFound.New().Set("id", c.Params.ID)
	}
	c.Out = Order{ID: 1, Product: "Widget"}
	return nil
}

// POST /orders
type CreateOrderInput struct {
	Product string `json:"product"`
}

// Validate is called by vatel after decoding, before Handle.
func (in *CreateOrderInput) Validate() error {
	if in.Product == "" {
		return vatel.ErrValidationFailed.New().Set("reason", "product is required")
	}
	return nil
}

type CreateOrder struct {
	vatel.Body[CreateOrderInput]
	vatel.Output[Order]
}

func (c *CreateOrder) Handle(ctx vatel.Context) error {
	c.Out = Order{ID: 2, Product: c.In.Product}
	return nil
}

func OrderEndpoints(cfg *auth.Config) vatel.Endpoints {
	read := cfg.RequirePerms(map[string]uint{"orders:read": 0}) // name -> bit position
	write := cfg.RequirePerms(map[string]uint{"orders:write": 1})

	return vatel.Endpoints{
		vatel.GET("/orders/{id}", func() vatel.Handler { return &GetOrder{} }, read),
		vatel.POST("/orders", func() vatel.Handler { return &CreateOrder{} }, write,
			vatel.WithSuccessStatusCode(201)),
		vatel.GET("/healthcheck", vatel.Func(func(ctx vatel.Context) error {
			ctx.SetStatusCode(204)
			return nil
		})),
	}
}

func main() {
	log := zerolog.New(os.Stdout).With().Timestamp().Logger()

	authCfg := &auth.Config{
		Decoder:    &JWTDecoder{Secret: []byte(os.Getenv("JWT_SECRET"))}, // your vatel.TokenDecoder
		Authorizer: &BitsetAuthorizer{},                                  // your auth.Authorizer
	}

	v := vatel.NewVatel(
		vatel.WithDefaultLogOption(vatel.LogFullOnExit),
		vatel.WithRequestID(),
		vatel.WithClientErrorFields("reason"),
	)
	v.AddMiddleware(vatel.OnAuthentication, authCfg.Middleware())
	v.Add(OrderEndpoints(authCfg))

	mux := router.New()
	if err := v.BuildHandlers(mux, &log); err != nil {
		log.Fatal().Err(err).Msg("build handlers")
	}

	go func() {
		// nil after Shutdown; ErrServerClosed if Shutdown ran first.
		if err := v.ListenAndServe(":8080", mux, &log); err != nil && !errors.Is(err, vatel.ErrServerClosed) {
			log.Fatal().Err(err).Msg("server stopped")
		}
	}()

	quit := make(chan os.Signal, 1)
	signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
	<-quit

	ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
	defer cancel()
	if err := v.Shutdown(ctx); err != nil {
		log.Error().Err(err).Msg("shutdown")
	}
}

JWTDecoder and BitsetAuthorizer are application types; see example/claims.go for a sample implementation (HS256 signature and exp check with the standard library; a JWT library such as github.com/golang-jwt/jwt/v5 adds key rotation and other algorithms).

Responses:

GET /orders/1   (token with orders:read)  -> 200 {"id":1,"product":"Widget"}
GET /orders/5   (token with orders:read)  -> 404 {"msg":"order not found","severity":"unknown","code":"APP-0001","statusCode":404}
GET /orders/1   (no token)                -> 401 {"msg":"authentication required","severity":"medium","code":"VTL-0004","statusCode":401}
POST /orders    {"product":""}            -> 400 {"msg":"validation failed","severity":"tiny","code":"VTL-0006","statusCode":400,"reason":"product is required"}

GET / (under WithUrlPrefix, if set) is registered automatically and returns an HTML list of all endpoints, WS endpoints included. It is public and cannot be replaced: your own GET / makes BuildHandlers fail. Keep in mind that it exposes the route list.

Endpoints

An endpoint is created with a builder function: method, path template, controller factory and options.

vatel.GET(path, ctrl, opts...)     // also POST, PUT, PATCH, DELETE, HEAD, OPTIONS
vatel.WS(path, wsCtrl, opts...)    // WebSocket message endpoint, see below

ctrl is a func() vatel.Handler. It is called once per request, so each request gets its own controller value, and once more during BuildHandlers to inspect the controller type. Keep it cheap and free of side effects. vatel.Func(f) turns a plain func(vatel.Context) error into a factory.

Path templates use fasthttp/router syntax: /orders/{id}, /files/{path:*}, /items/{id:[0-9]+}.

Group endpoints with vatel.Endpoints (a []Endpoint that implements Endpointer) or with your own type that has an Endpoints() []vatel.Endpoint method, and register them with v.Add(...).

Endpoint options
Option Effect
vatel.Perms(names...) Appends permission names to EndpointMeta.Perms. On HTTP endpoints this is metadata only; use auth.RequirePerms to enforce permissions. On WS endpoints non-empty Perms require WithWsAuthFunc.
vatel.WithLogOption(lo) Log verbosity of the endpoint (see Logging).
vatel.WithSuccessStatusCode(code) Status of a successful response (default 200). Overrides a status set by the handler.
vatel.WithManualStatusCode() vatel does not set the success status; the handler calls ctx.SetStatusCode.
vatel.WithContentType(ct) Content-Type of the Resulter body (default application/json; charset=utf-8); the body is still JSON-marshalled. A handler writing to ctx.BodyWriter() sets the type itself with ctx.SetContentType.
vatel.WithCompress() Wraps the handler in fasthttp.CompressHandler.
vatel.WithOptionalBody() POST/PUT/PATCH: an empty body is accepted (see below).
vatel.WithTimeout(d) Timeout of the request context (see Request context).
cfg.RequireAuth(), cfg.RequirePerms(...) From vatel/auth.

An EndpointOption is a func(*vatel.Endpoint), so you can write your own; it may add per-endpoint middleware with e.AddMiddleware(pos, f).

Building handlers

v.BuildHandlers(mux, &log) validates every endpoint and registers it with the router. It returns an error for, among others: a path with {params} and a controller that is not a Paramer (or the reverse; a vatel.Func handler may have {params} and reads them with ctx.PathValue), a Param(), QueryInput() or GET/DELETE/HEAD/OPTIONS Input() that does not return a pointer to a struct, a path that does not begin with /, a route the router rejects (a duplicate or conflicting path, your own GET /), WithOptionalBody on GET/DELETE/HEAD/OPTIONS, an unknown method, a WS endpoint without WithWebsocketSupport, a duplicate WS path, WS Perms without WithWsAuthFunc. MustBuildHandlers panics instead.

Add endpoints (v.Add) and middleware (v.AddMiddleware) before BuildHandlers: it copies them, later additions have no effect.

BuildHandlers may be called once. Registration is not transactional: after a failed build the router may hold part of the routes, so the Vatel is unusable; further BuildHandlers/ListenAndServe calls return a "previous build failed" error. Create a new Vatel and router.

v.ListenAndServe(addr, mux, &log) builds the handlers if that has not been done yet and starts a fasthttp.Server with IdleTimeout = vatel.DefaultIdleTimeout (90 s) and CloseOnShutdown. vatel.WithServerConfig(func(*fasthttp.Server)) changes it (e.g. ReadTimeout, MaxRequestBodySize, MaxConnsPerIP, a ConnState hook; do not replace Handler). After Shutdown it returns nil; called after Shutdown, it returns vatel.ErrServerClosed. You can also serve mux.Handler with your own server; then Shutdown only cancels request contexts and closes WebSocket connections (waiting, within its ctx, until they have closed).

Controllers and carriers

A controller implements Handle(vatel.Context) error. Capability interfaces tell vatel what to decode and what to send back:

Interface Method Purpose
Paramer Param() any Pointer to a struct with param:"name" fields filled from path parameters.
Querier QueryInput() any Pointer to a struct filled from the URL query string, for every HTTP method.
Inputer Input() any GET/DELETE/HEAD/OPTIONS: filled from the query string; POST/PUT/PATCH: from the JSON body.
Resulter Result() any Value serialized to the JSON response. nil (or a nil pointer) means no body.
Validator Validate() error Implemented by an input type; called after decoding.

The returned pointers must point into the controller (not to a local variable).

Instead of writing these methods, embed the generic carriers:

Carrier Field Implements
vatel.PathParams[T] Params T Paramer
vatel.QueryParams[T] Query T Querier
vatel.Body[T] In T Inputer
vatel.Output[T] Out T Resulter (always writes a body)
vatel.OptionalOutput[T] Out *T Resulter; leave Out nil for no body (204, 304)
vatel.Upload Key, Header multipart upload helper, call BindFile(ctx) in Handle
// POST /customers/{customerId}/orders/search?limit=50
type SearchFilter struct {
	Limit int `param:"limit"`
}

type SearchOrders struct {
	vatel.PathParams[struct {
		CustomerID int `param:"customerId"`
	}]
	vatel.QueryParams[SearchFilter]
	vatel.Body[struct {
		Products []string `json:"products"`
	}]
	vatel.Output[[]Order]
}

func (c *SearchOrders) Handle(ctx vatel.Context) error {
	// c.Params.CustomerID, c.Query.Limit, c.In.Products
	return nil
}

File upload:

type ImportController struct {
	vatel.Upload
	vatel.Output[ImportResult]
}

func (c *ImportController) Handle(ctx vatel.Context) error {
	if err := c.BindFile(ctx); err != nil {
		return err
	}
	f, err := c.Open() // or c.SaveTo(ctx, path), c.StreamTo(w)
	if err != nil {
		return err
	}
	defer f.Close()
	// ...
	return nil
}

vatel.POST("/import", func() vatel.Handler {
	return &ImportController{Upload: vatel.Upload{Key: "file"}}
})

BindFile returns 400 VTL-0007 (ErrInvalidRequestBody) with field field (the Key) when the request is not a multipart form or has no such file field.

Context

vatel.Context gives the handler access to the request and the response without exposing fasthttp: Header, RequestBody, PathValue, QueryValue, FormFile, FormValue, RemoteAddr (real IP), RequestTime, SetStatusCode, SetHeader, SetContentType, BodyWriter, TokenPayload, Context (Go context), and a per-request key-value store Set/Get. Values stored with Set are written to the request log.

SetValue(key, val any) stores a value under any comparable key, typically an unexported typed key, for code that only gets the Go context: ctx.Context().Value(key) returns it. A string key is the same as Set; a value under a non-string key is not returned by Get and is not written to the log.

type patKey struct{}

// middleware (OnAuthorization, OnHandle, ...)
ctx.SetValue(patKey{}, patID)

// business code that receives ctx.Context()
func viaPAT(ctx context.Context) bool { _, ok := ctx.Value(patKey{}).(int64); return ok }

The token payload set by the auth middleware is type-asserted to your claims type:

claims := ctx.TokenPayload().(*Claims)

Input decoding and validation

Order of steps for a request: path parameters (Paramer), query (Querier), query or body (Inputer), then validation: Validate() of the Querier input, then Validate() of the Inputer input. The first error rejects the request with 400.

  • Path parameters and query: int*, uint*, string, bool, float* fields, pointers to them, github.com/axkit/date.Date and *date.Date; fields of nested structs (embedded or untagged) are flattened. A nested struct of a type already being flattened (a cycle such as Parent *Filter) is skipped. A missing query variable leaves the field as is.
  • A value that cannot be parsed gives 400 VTL-0006 (ErrValidationFailed) with field param, and so does an empty date (?from=). So does an integer that does not fit the field (300 for a uint8, 2147483648 for an int32): integers are parsed at the size of the field, never truncated.
  • Param(), QueryInput() and the GET/DELETE/HEAD/OPTIONS Input() must return a non-nil pointer to a struct; anything else, a typed nil included, is a BuildHandlers error.
  • Body: encoding/json into the Inputer value. An empty or whitespace-only body gives 400 VTL-0007 "empty request body, JSON expected"; invalid JSON gives 400 VTL-0007 "request body is not valid JSON". For an input type with mask tags the encoding/json error is not wrapped, because it may quote a value (a number that does not fit the field, a time.Time); the error carries only the fields field, type and offset.
  • A request body on DELETE is not supported (RFC 9110 leaves its semantics undefined); use query parameters (QueryParams).

Declare Validate() on a named type. An anonymous struct (vatel.Body[struct{...}]) cannot have methods, so its Validate() never exists. The method may have a pointer receiver; it may also set default values.

type SearchFilter struct {
	Limit int `param:"limit"`
}

func (f *SearchFilter) Validate() error {
	if f.Limit == 0 {
		f.Limit = 20
	}
	if f.Limit > 100 {
		return vatel.ErrValidationFailed.New().Set("reason", "limit must be <= 100")
	}
	return nil
}

The client sees ErrValidationFailed: the message "validation failed", status 400 and code VTL-0006. Any status of the returned error (409, 422, ...) is replaced by 400; the code of a returned *errors.Error, if it has one, replaces VTL-0006, and its fields are kept. A plain errors.New("...") text from Validate() reaches the client only with verbose errors; to show a reason, set an error field and list it in WithClientErrorFields (see Errors). Validate() is called on Querier and Inputer input only, not on a PathParams struct. On a WebSocket endpoint it is called on the Inputer input after the message data is decoded.

Optional request body

By default POST/PUT/PATCH with an Inputer require a JSON body. With vatel.WithOptionalBody() an empty or whitespace-only body is accepted: decoding is skipped, the input stays zero and Validate() is not called, so a Validate() that fills in defaults does not run. A non-empty body is decoded and validated as usual.

vatel.POST("/orders/{id}/ticket", func() vatel.Handler { return &CreateTicket{} },
	vatel.WithOptionalBody())

Middleware

Middleware has the signature func(vatel.Context, vatel.EndpointMeta) error and runs at a position in the pipeline:

Position When
OnRequest First. Rate limiting by IP, CORS headers.
OnAuthentication Decode the token, set the token payload (auth.Config.Middleware).
OnAuthorization Check access (auth.RequireAuth, auth.RequirePerms). Runs before input decoding.
OnHandle After input decoding and validation, before Handle. Per-user limits, audit.
OnSuccessResponse After a successful response is written. The error is ignored: the response is not changed and later middleware still runs.
OnErrorResponse After an error response is written. The error is ignored.

Global middleware is added with v.AddMiddleware(pos, f...), per-endpoint middleware with e.AddMiddleware(pos, f...) inside an EndpointOption. At each position global middleware runs first, then per-endpoint, in the order added. An error returned at OnRequest..OnHandle stops the request and is written as the error response.

EndpointMeta carries the endpoint's Method, Path (template, with URL prefix), Perms and LogOptions; every position, including OnErrorResponse, receives it.

func audit(ctx vatel.Context, meta vatel.EndpointMeta) error {
	if len(meta.Perms) > 0 {
		ctx.Set("perms", meta.Perms) // written to the request log
	}
	return nil
}

v.AddMiddleware(vatel.OnHandle, audit)

// Per-endpoint middleware as an option.
func RateLimit(perMinute int) vatel.EndpointOption {
	return func(e *vatel.Endpoint) {
		e.AddMiddleware(vatel.OnHandle, func(ctx vatel.Context, meta vatel.EndpointMeta) error {
			// ...
			return nil
		})
	}
}

Authentication and authorization (vatel/auth)

vatel itself does not check tokens. The github.com/axkit/vatel/auth package provides middleware for a JWT-like token with a bitset of permissions.

cfg := &auth.Config{
	Decoder:     myDecoder,     // vatel.TokenDecoder: Decode([]byte) (vatel.Tokener, error)
	Authorizer:  myAuthorizer,  // auth.Authorizer: IsAllowed(requestPerms []byte, bits ...uint) (bool, error)
	RevokeCheck: myRevokeCheck, // optional vatel.RevokeTokenChecker
}

v.AddMiddleware(vatel.OnAuthentication, cfg.Middleware())

v.Add(vatel.Endpoints{
	vatel.GET("/healthcheck", healthCtrl),                                   // public
	vatel.GET("/users/me", meCtrl, cfg.RequireAuth()),                       // any valid token
	vatel.GET("/orders", listCtrl, cfg.RequirePerms(map[string]uint{"orders:read": 0})),
})
  • Middleware() reads the Authorization header, checks revocation, decodes the token and stores Tokener.ApplicationPayload() with ctx.SetTokenPayload. If the payload's Debug() is true, verbose errors are enabled for the request. A missing, invalid, expired or revoked token does not fail the request here: public endpoints serve it as unauthenticated.
  • RequireAuth() rejects a request without a valid token (401; the token error if the token was bad).
  • RequirePerms(map[string]uint) maps permission names to bit positions. It rejects a request without a valid token (401) or without all bits (403 VTL-0003). The names are appended, sorted, to EndpointMeta.Perms. A nil Authorizer gives 500 instead of a panic.
  • Config.Decoder is required with Middleware(). It and RevokeCheck receive the raw Authorization header value; nothing strips a Bearer prefix, so strip it in the decoder. A nil Decoder or a RevokeCheck error fails RequireAuth/RequirePerms endpoints with 500 VTL-0005; public endpoints serve the request as unauthenticated. A Decoder returning neither a token nor an error counts as a bad token (401).
  • Config.Disabled = true turns all checks off; it is read on every request without synchronisation, so set it before the server starts serving.

The payload must implement vatel.AuthPayload (Perms() []byte, Debug() bool; the vatel core does not call them, vatel/auth does); handlers type-assert ctx.TokenPayload() to the concrete type. vatel.Perms(...) alone does not protect an HTTP endpoint.

Errors

Return any error from Handle, Validate or middleware. Errors from github.com/axkit/errors carry an HTTP status, a code and a severity:

var ErrOrderLocked = errors.Template("order is locked").
	Code("APP-0042").StatusCode(409).Severity(errors.Medium)

return ErrOrderLocked.New().Set("orderId", id).Set("reason", "payment in progress")
return ErrOrderLocked.Wrap(err)

The client receives the message, severity, code and status of the first *errors.Error (or *errors.ErrorTemplate) in the error chain; a fmt.Errorf("...: %w", err) wrapper keeps its status and the wrapper text is not sent:

{"msg":"order is locked","severity":"medium","code":"APP-0042","statusCode":409}

Fields (Set), wrapped errors and the stack are written to the server log only. To show selected fields to the client, list them once:

v := vatel.NewVatel(vatel.WithClientErrorFields("reason", "suggestedName"))
// -> {"msg":"order is locked",...,"statusCode":409,"reason":"payment in progress"}

The listed fields are written at the root of the error JSON. Do not list fields that may contain internal data.

Verbose errors (stack, all fields, wrapped errors in the response) are enabled for all requests by vatel.WithVerboseError(true), or per request by a token whose payload Debug() returns true (or by middleware calling ctx.SetVerboseError(true)). Use them in development only.

An error chain without *errors.Error (a driver error, errors.New, fmt.Errorf) may carry internal text, so the client gets a generic error instead: {"msg":"internal error",...,"code":"VTL-0005","statusCode":500}, or "service unavailable" / "gateway timeout" for 503/504. The original error is logged. With verbose errors the client gets the original text.

Status of an error without its own status (a plain error, or an *errors.Error with status 0): 504 if the chain contains context.DeadlineExceeded, 503 for context.Canceled, 500 otherwise. An error with status 429 and a Retry-After field sets the Retry-After header. Errors with status >= 500 are passed to the Alarmer (vatel.WithAlarmer).

Panics

vatel recovers a panic anywhere in the pipeline of an HTTP request (middleware, path/query/body decoding, the handler, response serialization, OnSuccessResponse and OnErrorResponse middleware) and in a WebSocket message handler; the server keeps running. The response written so far (status, headers, body) is dropped and the client gets ErrInternalError: 500, VTL-0005, "internal error" (the panic value only with verbose errors). The panic value is logged under panic and the stack under stack, whatever the log level; the request is reported to the metrics once, the Alarmer is called and OnErrorResponse middleware runs (not when the panic came from a middleware that runs after the response is written). A WebSocket client gets the error frame and its connection keeps serving messages. A panic in the WebSocket OnOpen or OnClose callback is recovered too: it is logged (panic, stack, callback) and the Alarmer is called; after an OnOpen panic the connection is closed with status 1011 (OnClose is still called for it).

Predefined errors:

Error Code Status
ErrAuthorizationHeaderMissed VTL-0001 401 (deprecated: no longer returned, kept for compatibility)
ErrAccessTokenRevoked VTL-0002 401
ErrForbidden VTL-0003 403
ErrUnauthorized VTL-0004 401
ErrInternalError VTL-0005 500
ErrValidationFailed VTL-0006 400
ErrInvalidRequestBody VTL-0007 400
ErrUnknownPath VTL-0008 404 (WebSocket message with an unknown path)
ErrWsContextChanged VTL-0009 409 (WebSocket reply before closing with 4009, see WebSocket)
ErrWsTokenConflict VTL-0010 409 (concurrent UpdateAccessToken for one connection)

Logging

vatel logs with zerolog. Each endpoint logger carries method, path and client (real IP); WithRequestID() adds reqId. path is the path as registered, without the WithUrlPrefix prefix (EndpointMeta.Path and metrics include it). The "new request" and exit lines are logged at debug level, so a logger at info level hides them; errors are logged at error level.

What is logged is a bitmask (LogOption):

Bit Header value Logs
LogEnter enter a "new request" line when the request arrives
LogExit exit a line when the request completes, with dur
LogReqBody req-body request body as requestBody: compacted JSON, or, for an input type with mask tags, the decoded input marshalled and masked
LogReqInput req-input decoded input as reqInput (Inputer) and reqQuery (Querier)
LogRespBody resp-body response body as respBody
LogRespOutput resp-out response as result, only when LogRespBody is not set (the same masked JSON is not logged twice)
LogSilent silent nothing except 5xx errors; overrides all other bits

Presets: LogFull (enter, exit, request body and input, response body), LogFullOnExit (the same without enter), LogConfidential (exit only).

  • The default level is 0: only errors are logged. Set a default with vatel.WithDefaultLogOption(lo) and override it per endpoint with vatel.WithLogOption(lo).
  • Errors are logged at error level with the full error (fields, wrapped errors, stack), the path and query parameters decoded so far and context values. With LogSilent only 5xx errors are logged.
  • Path and query parameters are logged by name; values stored with ctx.Set are logged as well. A value stored with ctx.Set("message", v) replaces the exit line message (a path parameter named message does not).
  • The X-Set-Log-Level request header (X-Set-Log-Level: exit,req-body) replaces the endpoint level for that one request. It cannot switch off LogSilent of an endpoint; silent in the header silences the request. vatel.WithStaticLoggingLevel() ignores the header. Any client can send this header; use WithStaticLoggingLevel() in production unless every secret in request and response types is tagged with mask.

Masking sensitive data

Tag fields with mask and they are masked in the log. The response sent to the client is not changed.

type LoginInput struct {
	Login    string `json:"login"`
	Password string `json:"password" mask:"-"`      // removed from the log
	Card     string `json:"card"     mask:"secret"` // "***"
	Phone    string `json:"phone"    mask:"phone"`  // custom function
}
  • mask:"-" removes the field; any other value names a masking function. A name not registered with AddFunc gives "***" (jsonmask.DefaultMask).

  • Without vatel.WithJsonMasker a default jsonmask.New() is used, so tagged fields are always masked. Register functions with your own masker:

    jm := jsonmask.New()
    jm.AddFunc("phone", func(s string) string {
        if len(s) < 4 {
            return jsonmask.DefaultMask
        }
        return strings.Repeat("*", len(s)-4) + s[len(s)-4:]
    })
    v := vatel.NewVatel(vatel.WithJsonMasker(jm))
    
  • Masked log keys: requestBody, reqInput, reqQuery, respBody, result, including elements of arrays (Body[[]T], Output[[]T]) and OptionalOutput[T]. A result is masked by the rules of the type Result() returns for the request. If masking fails only the error is logged (requestBodyMaskingFailed, reqInputMaskingFailed, respBodyMaskingFailed or resultMaskingFailed), never the unmasked value.

  • The request body of an input type with mask tags is logged as the decoded input, not as the raw body: encoding/json also decodes keys in another letter case ("Password") and the last of duplicate keys, which a path-based masker would miss. A body that cannot be decoded is then not logged (requestBodyLoggingFailed).

  • Tags are honoured in nested structs, *Struct, []Struct, []*Struct and recursive types. A tagged field is a leaf: a tagged struct, pointer or slice (e.g. time.Time) is masked or removed as a whole. A tag on an embedded struct applies to each promoted field without a tag of its own. Maps and any (interface) values are not visited. Fields absent from the JSON (omitempty) are not added.

  • Path and query parameters: mask:"-" omits the parameter from the log, any other non-empty mask logs "***". Masking functions are not applied to parameters. A mask tag on a nested struct of a query input applies to its fields without their own tag. An invalid value of a masked parameter gives 400 without the value in the error (not even in verbose responses). The same holds for a JSON body (HTTP or WS data) of an input type with mask tags that cannot be decoded.

type ResetPassword struct {
	vatel.PathParams[struct {
		Token string `param:"token" mask:"secret"` // logged as "***"
	}]
	vatel.QueryParams[struct {
		Email string `param:"email" mask:"-"` // not logged
	}]
}

Types without mask tags are logged unchanged.

Request context, timeouts and shutdown

ctx.Context() returns a Go context for the request; pass it to database and HTTP calls.

  • It is a child of a base context owned by the Vatel. v.Shutdown(ctx):

    1. starts closing WebSocket connections and stops accepting new connections;
    2. waits until no request is in flight and every WebSocket connection has closed: requests being handled and requests still being received (headers or body) finish normally, and their responses carry Connection: close. Idle keep-alive connections do not hold it up: they are closed. A new connection that has sent nothing yet gets about 5 seconds to start its request (as in net/http), then it is closed too. A WebSocket message being handled is finished;
    3. cancels the base context and returns nil.

    When ctx is done first, the base context is cancelled at once, so handlers still running get context.Canceled (503); Shutdown waits up to one more second for them to finish and returns ctx.Err(); requests still running after that are not interrupted, and their connections are closed when they finish. Requests that start after Shutdown get a cancelled context. Without a server started by ListenAndServe, the base context is cancelled at once and Shutdown waits, within ctx, only for the WebSocket connections. Calling Shutdown again, also concurrently, is safe: each call waits for the server with its own ctx.

  • vatel.WithTimeout(d) (endpoint) and vatel.WithDefaultTimeout(d) (Vatel, for endpoints without their own timeout) add a deadline. Such a context is also cancelled when the request ends.

  • The timeout does not interrupt the handler by itself. Only code that listens to the context stops (database drivers, http.NewRequestWithContext, select on Done()).

  • A handler error whose chain contains context.DeadlineExceeded gives 504, context.Canceled gives 503, unless the error carries its own status.

  • fasthttp does not report a client disconnect, so the context is not cancelled when the client goes away.

  • Value(key) returns the request values first (Set, SetValue, path params, reqId), then the values of the parent context, looked up at call time: a context obtained before a value is stored sees it. It is safe to call from another goroutine. After the request has ended it returns the parent values only (fasthttp reuses the request object for the next request).

  • vatel.NewContext(fctx) (for tests) returns a Context with context.Background().

func (c *Report) Handle(ctx vatel.Context) error {
	rows, err := c.db.QueryContext(ctx.Context(), query)
	if err != nil {
		return err // DeadlineExceeded -> 504
	}
	defer rows.Close()
	// ...
}

vatel.GET("/reports/sales", reportCtrl, vatel.WithTimeout(5*time.Second))

HEAD, OPTIONS and CORS

vatel.HEAD and vatel.OPTIONS work like vatel.GET: an Inputer is filled from the query string, WithOptionalBody is not allowed. On HEAD the fasthttp server drops the response body, so only the status and headers reach the client.

For CORS preflight use the router's GlobalOPTIONS handler. The router calls it only for registered paths (an unknown path gets 404) and sets the Allow header before calling it. An explicit vatel.OPTIONS endpoint on a path replaces GlobalOPTIONS for that path. Do not put RequireAuth/RequirePerms on an OPTIONS endpoint: browsers send the preflight without the Authorization header.

mux := router.New()
mux.GlobalOPTIONS = func(fctx *fasthttp.RequestCtx) {
	h := &fctx.Response.Header
	h.Set("Access-Control-Allow-Origin", allowedOrigin(fctx.Request.Header.Peek("Origin")))
	h.Set("Access-Control-Allow-Methods", string(h.Peek("Allow")))
	h.Set("Access-Control-Allow-Headers", "Authorization, Content-Type")
	h.Set("Access-Control-Max-Age", "600")
	h.Add("Vary", "Origin")
	fctx.SetStatusCode(fasthttp.StatusNoContent)
}

// Allow-Origin on actual responses, including errors.
v.AddMiddleware(vatel.OnRequest, func(ctx vatel.Context, _ vatel.EndpointMeta) error {
	ctx.SetHeader([]byte("Access-Control-Allow-Origin"), []byte(allowedOrigin(ctx.Header("Origin"))))
	ctx.SetHeader([]byte("Vary"), []byte("Origin"))
	return nil
})

See example/cors.go.

WebSocket

WebSocket endpoints share the endpoint model. A client sends JSON messages {"path": "<endpoint path>", "data": {...}}; data is decoded into the handler's Inputer, the Resulter value is sent back as JSON.

type Ping struct {
	in struct {
		Seq int `json:"seq"`
	}
	out struct {
		Seq int `json:"seq"`
	}
}

func (h *Ping) Input() any { return &h.in }
func (h *Ping) Result() any { return &h.out }
func (h *Ping) Handle(ctx vatel.WebsocketContext) error {
	h.out.Seq = h.in.Seq
	return nil
}

wv := vatel.NewWebsocketVatel(
	vatel.WithTokenDecoder(myDecoder),
	vatel.WithWsAuthFunc(func(ctx vatel.WebsocketContext, path string, perms []string) error {
		if len(perms) > 0 && ctx.TokenPayload() == nil {
			return vatel.ErrUnauthorized.New()
		}
		return nil // check perms against ctx.TokenPayload()
	}),
)
wv.OnTokenUpdate(func(cid uint64, prev, next vatel.AuthPayload) error { return nil })

v := vatel.NewVatel(vatel.WithWebsocketSupport(wv))
v.Add(vatel.Endpoints{
	vatel.WS("ping", func() vatel.WebsocketHandler { return &Ping{} }),
	vatel.WS("orders.watch", watchCtrl, vatel.Perms("orders:read")),
})

mux := router.New()
mux.GET("/ws", wv.Upgrade) // the HTTP route that upgrades to WebSocket
  • The built-in endpoint updateAccessToken (data: {"mid": "<uuid>", "at": "<token>"}) decodes the token with WithTokenDecoder and calls the OnTokenUpdate callback with the payload stored so far (prev, nil for the first token) and the new one (next); unless the callback returns an error, it stores next for the connection. An error from the callback rejects the token (the connection keeps its previous payload) and is sent to the client; a token that cannot be decoded, or whose payload has expired (Expirer), gives 401 VTL-0004. Without WithTokenDecoder it replies "Ok" and stores nothing. The stored payload is available as ctx.TokenPayload() in later messages. See Security context and closing connections.
  • WithWsAuthFunc is called for every message with the endpoint's Perms; WS endpoints with Perms require it. HTTP middleware does not run for WS messages.
  • Invalid data gives a 400 VTL-0007 error message. If the Inputer input implements Validator, Validate() is called after decoding, before Handle; an error gives ErrValidationFailed (400, VTL-0006) as in HTTP. Errors are sent, logged and reported to metrics and the Alarmer like HTTP errors.
  • A message that is not valid JSON or has no path gives 400 VTL-0007, an unknown path gives 404 VTL-0008 (ErrUnknownPath). These errors are sent and logged like other errors (on the WithLogger logger, or the BuildHandlers one, with "method":"WS"), but not reported to metrics: no endpoint is known.
  • The metric size of a successful message is the size of the reply.
  • WS handlers have no Go context: WithTimeout, WithDefaultTimeout and the Shutdown cancellation do not apply. LogReqBody/LogReqInput are ignored. WithRequestID logs connectionId instead of reqId.
  • vatel.WithLogger(l) sets the logger of WS endpoints (lines get "layer":"ws"); without it they log to the logger passed to BuildHandlers.
  • OnOpen and OnClose register connection callbacks. v.Shutdown closes all WebSocket connections with a "going away" status first and waits, within its ctx, until they have closed: their further messages are not served, the message being handled is finished, and a client that does not answer the close frame is disconnected after about one second (OnClose gets a nil error). A connection opened after that is closed at once, without the callbacks. wv.Shutdown() alone does not wait.
  • A write to ctx.BodyWriter() after the connection is closed (by Shutdown or the client) returns websocket.ErrConnClosed instead of blocking. A write to a client that does not read blocks once the outbound queue (128 frames) is full; after the write timeout of a frame, vatel.WithWsWriteTimeout(d) (default vatel.DefaultWsWriteTimeout, 10 s; 0 means no limit), the write fails and the connection ends.
  • A message larger than 1 MB (the MaxPayloadSize of github.com/regorov/websocket), also when assembled from fragments, closes the connection with status 1009 (message too big). OnClose gets the client's close status as a websocket.Error when the client closed the connection.
  • A panic in a WS handler is recovered (see Panics): the client gets a 500 VTL-0005 error frame and the connection keeps serving messages. A panic in the OnOpen/OnClose callback is logged and alarmed; after an OnOpen panic the connection is closed with status 1011.
  • UpgradeWithID(ctx, id): the ID must be unique among live connections. A connection with the ID of a live one is closed at once with status 1008 (policy violation), without the callbacks; its messages are not served and it does not share the token payload of the live connection.
Security context and closing connections

vatel does not know what an organization (tenant, workspace, ...) is: it gives the mechanism, the application compares the contexts. The recommended model is one connection = one security context (user + organization). A token of another context is not accepted on a live connection; the client opens a new connection with it.

wv.OnTokenUpdate(func(_ uint64, prev, next vatel.AuthPayload) error {
	if prev == nil {
		return nil // the first token of the connection sets its context
	}
	p, n := prev.(*Claims), next.(*Claims)
	switch {
	case p.UserID != n.UserID:
		return vatel.NewWsCloseError(vatel.WsStatusForbidden, "user changed", nil)
	case p.OrgID != n.OrgID:
		return vatel.NewWsCloseError(vatel.WsStatusContextChanged, "organization changed", nil)
	}
	return nil // a refresh of the same context
})
  • OnTokenUpdate(f func(cid uint64, prev, next AuthPayload) error) runs without a lock. If another payload is stored for the connection while it runs (a concurrent UpdateAccessToken for the same cid), or the connection closes or starts closing, UpdateAccessToken returns ErrWsTokenConflict (409, VTL-0010) and stores nothing; it does not retry, as the callback need not be idempotent. For a connection that is already closing it returns ErrWsTokenConflict without calling the callback.
  • WsCloseError (vatel.NewWsCloseError(code, reason, err), fields Code, Reason, Err). When the handler of any WS message — including updateAccessToken (through OnTokenUpdate) and the WithWsAuthFunc function — returns an error with a *WsCloseError in its chain (errors.As), the client first gets the usual error reply to that message, then the connection is closed with Code and Reason; its further messages are not served. The reply is built from Err when it has an *errors.Error with a status (an *errors.Error joined or wrapped next to the WsCloseError, outside Err, does not count); otherwise from the code: 4001 → 401 ErrUnauthorized, 4003 → 403 ErrForbidden, 4009 → 409 ErrWsContextChanged (VTL-0009), any other code → 400. It is logged and reported to metrics, never passed to the Alarmer. A rejected token is not stored. An application that calls wv.UpdateAccessToken itself gets the error and closes the connection with CloseConn.
  • Reason is sent in the close frame, which holds at most 123 bytes of text: vatel cuts it on a UTF-8 character boundary (a longer text makes browsers drop the connection with 1006 and lose the code). It must not carry sensitive details: put them in Err (the log) or the error reply.
  • wv.CloseConn(cid, code, reason) bool closes a live connection, e.g. when its user was removed from the organization, logged out, or the token was revoked; it returns false if there is no such connection (or it is already closing). Its further messages are not served; OnClose is called once it has closed.
  • wv.VisitConns(func(cid uint64, tp vatel.AuthPayload) bool) walks the live connections with their payloads (nil before the first token), by ID, until the callback returns false. It runs on a snapshot without a lock, so the callback may call CloseConn, e.g. to close every connection of a user in an organization.
  • Token expiry. A payload that implements vatel.Expirer (ExpiresAt() time.Time) with a non-zero time closes its connection with 4001 when it expires. Each accepted updateAccessToken restarts the timer, so the client sends a fresh token before the current one expires. A payload that has already expired is rejected with 401 and the connection keeps its previous payload. A payload stored with UpdateAccessToken for an ID before its connection opened (UpgradeWithID) that has expired by then is dropped: the connection opens without a payload (its next token is the first, prev == nil, and the authentication timeout applies). Once the payload of a connection has expired, the connection is over even if its timer has not fired yet: a message on it (including updateAccessToken) is not served and closes it with 4001 "token expired", a direct UpdateAccessToken for it returns ErrWsTokenConflict without calling OnTokenUpdate, and VisitConns leaves it out. A late token never extends an expired connection nor changes its security context: refresh the token before it expires.
  • Authentication timeout. vatel.WithWsAuthTimeout(d) closes a connection with 4001 if no token was accepted within d after it opened (a rejected token does not count). Default 0: no limit. It has no effect without WithTokenDecoder.
  • Timers stop when the connection closes and on Shutdown.

Close codes (vatel uses the first three; applications may add their own in 4000–4999):

Code Constant Client reaction
4001 vatel.WsStatusAuthRequired refresh the token, reconnect, send it
4003 vatel.WsStatusForbidden do not reconnect (access denied or another user)
4009 vatel.WsStatusContextChanged get a token of the needed context (organization), reconnect with it, subscribe again
1001 (Shutdown, "going away") reconnect, possibly to another instance

Organizations. The organization of a message is taken only from the token payload of the connection (ctx.TokenPayload()), never from the message data. A subscription handler checks that the organization of the resource matches the organization of the token (the example reports a resource of another organization as not found, so its existence is not disclosed). The same holds for HTTP: the example's order endpoints take the organization from ctx.TokenPayload() only, filter lists by it, check it before reading, changing or deleting an order, and set it on created orders. The context belongs to the connection, not to the user: browser tabs working in different organizations use different connections.

Client contract. Send the token (updateAccessToken) right after connecting, and a fresh one before it expires. To switch the organization, close the WebSocket yourself (1000), open a new one with a token of the new organization and subscribe again. React to the close codes as in the table above. A complete server is in example/ws.go.

Other options

Vatel option Effect
WithUrlPrefix("/api/v1") Prefix joined to every HTTP endpoint path.
WithRequestID() Log reqId (fasthttp request ID); also stored in the context as reqId.
WithVerboseError(true) Stack, fields and wrapped errors in error responses.
WithClientErrorFields(names...) Error fields shown to the client.
WithDefaultLogOption(lo) Log level of endpoints without their own.
WithStaticLoggingLevel() Ignore the X-Set-Log-Level header.
WithDefaultTimeout(d) Request context timeout of endpoints without their own.
WithJsonMasker(jm) Masker for logged JSON (default jsonmask.New()).
WithMetricReporter(mr) ReportMetric(method, path, statusCode, durSeconds, size) per request; path is the template; size is 0 for HEAD.
WithAlarmer(a) Alarm(err) for errors with status >= 500.
WithWebsocketSupport(wv) Enables WS endpoints.
WithServerConfig(f) Configures the fasthttp.Server started by ListenAndServe.

License

MIT, see LICENSE.

Documentation

Index

Constants

View Source
const (
	LogUnknown      LogOption = 0
	LogFull                   = LogEnter | LogExit | LogReqBody | LogReqInput | LogRespBody
	LogFullOnExit             = LogExit | LogReqBody | LogReqInput | LogRespBody
	LogConfidential           = LogExit
)
View Source
const (
	// WsStatusAuthRequired (4001): the connection has no valid token (its
	// token expired, or none came within WithWsAuthTimeout). The client gets
	// a fresh token and reconnects.
	WsStatusAuthRequired websocket.StatusCode = 4001
	// WsStatusForbidden (4003): the connection may not continue (another
	// user, access revoked). The client does not reconnect.
	WsStatusForbidden websocket.StatusCode = 4003
	// WsStatusContextChanged (4009): the token is of another security
	// context (e.g. organization) than the connection. The client gets a
	// token of the context it needs and opens a new connection with it.
	WsStatusContextChanged websocket.StatusCode = 4009
)

Close statuses of the WebSocket connections closed by the server for authentication reasons; WsCloseError carries one. Applications may define their own in the 4000–4999 range. A client reacts to them as follows.

View Source
const DefaultIdleTimeout = 90 * time.Second

DefaultIdleTimeout is the IdleTimeout of the server started by ListenAndServe: a keep-alive connection without a new request is closed after it. Change it with WithServerConfig.

View Source
const DefaultWsWriteTimeout = 10 * time.Second

DefaultWsWriteTimeout is the default write timeout of a WebSocket frame (see WithWsWriteTimeout).

Variables

View Source
var (
	// ErrAuthorizationHeaderMissed was returned for a request without the
	// Authorization header.
	//
	// Deprecated: neither vatel nor vatel/auth returns it; a request without
	// a token gets ErrUnauthorized (VTL-0004). Kept for compatibility.
	ErrAuthorizationHeaderMissed = errors.Template("header Authorization missed").Code("VTL-0001").StatusCode(401).Severity(errors.Medium)
	ErrAccessTokenRevoked        = errors.Template("access token revoked").Code("VTL-0002").StatusCode(401).Severity(errors.Medium)
	ErrForbidden                 = errors.Template("forbidden").Code("VTL-0003").StatusCode(403).Severity(errors.Medium)
	ErrUnauthorized              = errors.Template("unauthorized").Code("VTL-0004").StatusCode(401).Severity(errors.Medium)
	ErrInternalError             = errors.Template("internal error").Code("VTL-0005").StatusCode(500).Severity(errors.Critical)
	ErrValidationFailed          = errors.Template("validation failed").Code("VTL-0006").StatusCode(400).Severity(errors.Tiny)
	ErrInvalidRequestBody        = errors.Template("invalid request body").Code("VTL-0007").StatusCode(400).Severity(errors.Tiny)
	// ErrUnknownPath is sent to a WebSocket client for a message whose path
	// names no WS endpoint.
	ErrUnknownPath = errors.Template("unknown path").Code("VTL-0008").StatusCode(404).Severity(errors.Tiny)
	// ErrWsContextChanged is sent to a WebSocket client for a message whose
	// WsCloseError with WsStatusContextChanged has no error of its own.
	ErrWsContextChanged = errors.Template("security context changed").Code("VTL-0009").StatusCode(409).Severity(errors.Medium)
	// ErrWsTokenConflict is returned by WebsocketVatel.UpdateAccessToken when
	// another payload was stored for the connection, or it closed, while the
	// OnTokenUpdate callback ran.
	ErrWsTokenConflict = errors.Template("concurrent token update").Code("VTL-0010").StatusCode(409).Severity(errors.Medium)
)

Sentinel errors.

View Source
var ErrServerClosed = stderrors.New("vatel: server closed")

ErrServerClosed is returned by ListenAndServe called after Shutdown.

Functions

func Func added in v1.25.0

func Func(f func(Context) error) func() Handler

Func wraps a plain function as a per-request Handler factory, suitable for simple stateless endpoints that do not need a full controller struct. It may be used on a path with {params}: read them with ctx.PathValue (they are not logged).

vatel.DELETE("/orders/{id}", vatel.Func(func(ctx vatel.Context) error {
    id := ctx.PathValue("id")
    return repo.Delete(id)
}), vatel.Perms("orders:write"))

func ToClientJSON added in v0.14.0

func ToClientJSON(err error) []byte

ToClientJSON serialises an error for transmission to the WebSocket client.

func WithAlarmer added in v0.10.0

func WithAlarmer(ala Alarmer) func(*Option)

WithAlarmer sets the receiver of errors with status >= 500.

func WithClientErrorFields added in v1.25.0

func WithClientErrorFields(fields ...string) func(*Option)

WithClientErrorFields lists error fields (set via errors.Set) that are safe to show to the client. They are written at the root of the error JSON even when verbose errors are off; all other fields stay in the server log only.

vatel.WithClientErrorFields("reason", "suggestedName")

func WithDefaultLogOption added in v0.7.0

func WithDefaultLogOption(lo LogOption) func(*Option)

WithDefaultLogOption sets the log verbosity of endpoints without their own WithLogOption. Without it only errors are logged.

func WithDefaultTimeout added in v1.25.0

func WithDefaultTimeout(d time.Duration) func(*Option)

WithDefaultTimeout sets the request context timeout of endpoints without their own WithTimeout. See WithTimeout.

func WithJsonMasker added in v0.10.0

func WithJsonMasker(jm JsonMasker) func(*Option)

WithJsonMasker sets the masker applied to logged request and response bodies, inputs and results of types with `mask` struct tags. Without it a default jsonmask.New() is used, so tagged fields are always masked in the log ("***" for a function name not registered with AddFunc).

func WithLogger added in v0.13.2

func WithLogger(log *zerolog.Logger) func(*WsOption)

WithLogger sets the logger of WS endpoints; their lines get "layer":"ws". Without it WS endpoints log to the logger passed to BuildHandlers.

func WithMetricReporter added in v0.11.0

func WithMetricReporter(mr MetricReporter) func(*Option)

WithMetricReporter sets the reporter of per-request metrics: method, path template, status, duration and response size (0 for HEAD).

func WithRequestID added in v0.8.0

func WithRequestID() func(*Option)

WithRequestID logs the fasthttp request ID as "reqId" (connectionId for WS messages); it is also stored in the request context under "reqId".

func WithServerConfig added in v1.25.0

func WithServerConfig(f func(*fasthttp.Server)) func(*Option)

WithServerConfig sets a function that configures the fasthttp.Server started by ListenAndServe, e.g. its ReadTimeout or MaxRequestBodySize. It is called after vatel has set Handler, IdleTimeout (DefaultIdleTimeout) and CloseOnShutdown (true). Do not replace Handler. Hooks such as ConnState are free for the application: Shutdown does not use them.

func WithStaticLoggingLevel added in v0.7.0

func WithStaticLoggingLevel() func(*Option)

WithStaticLoggingLevel makes vatel ignore the X-Set-Log-Level request header.

func WithTokenDecoder added in v0.13.5

func WithTokenDecoder(td TokenDecoder) func(*WsOption)

WithTokenDecoder sets the decoder of tokens sent to the built-in updateAccessToken endpoint. Without it the endpoint stores nothing.

func WithUrlPrefix added in v0.7.0

func WithUrlPrefix(s string) func(*Option)

WithUrlPrefix sets a prefix joined to the path of every HTTP endpoint, e.g. "/api/v1".

func WithVerboseError added in v0.7.2

func WithVerboseError(b bool) func(*Option)

WithVerboseError enables verbose error responses (stack traces, wrapped errors).

func WithWebsocketSupport added in v0.13.5

func WithWebsocketSupport(wv *WebsocketVatel) func(*Option)

WithWebsocketSupport enables WS endpoints, served by wv. The application routes an HTTP path to wv.Upgrade.

func WithWsAuthFunc added in v1.25.0

func WithWsAuthFunc(fn func(ctx WebsocketContext, path string, requiredPerms []string) error) func(*WsOption)

WithWsAuthFunc sets the per-message authorization function. It is called for every incoming message before the handler runs. ctx carries the token payload (set via UpdateAccessToken or a prior message), path is the endpoint path, and requiredPerms is the slice declared on the endpoint (may be empty). Return a non-nil error to reject the message.

func WithWsAuthTimeout added in v1.25.0

func WithWsAuthTimeout(d time.Duration) func(*WsOption)

WithWsAuthTimeout closes a connection that has no token payload d after it opened (no successful UpdateAccessToken, nor an unexpired payload stored for its ID before it opened) with WsStatusAuthRequired. A rejected token does not count. The default 0 means no limit. It has no effect without WithTokenDecoder.

func WithWsWriteTimeout added in v1.25.0

func WithWsWriteTimeout(d time.Duration) func(*WsOption)

WithWsWriteTimeout sets how long writing one frame to a WebSocket client may take; the default is DefaultWsWriteTimeout, 0 means no limit. A write of a client that does not read blocks once the outbound queue of its connection (128 frames) is full; when the timeout expires the write fails and the connection ends, so the handler writing to it does not block forever.

Types

type AccessTokenUpdateHandler added in v0.13.5

type AccessTokenUpdateHandler struct {
	// contains filtered or unexported fields
}

func (*AccessTokenUpdateHandler) Handle added in v0.13.5

Handle implements github.com/axkit/vatel Handler interface. The handler has no logic because if access token is invalid, middleware would not pass it to the handler.

func (*AccessTokenUpdateHandler) Input added in v0.13.5

func (c *AccessTokenUpdateHandler) Input() any

func (*AccessTokenUpdateHandler) Result added in v0.13.5

func (c *AccessTokenUpdateHandler) Result() any

type Alarmer added in v0.10.0

type Alarmer interface {
	Alarm(err error)
}

Alarmer receives critical errors for alerting.

type AuthMessageData deprecated added in v0.13.0

type AuthMessageData struct {
	AccessToken string `json:"accessToken"`
}

AuthMessageData is the payload for token-based authentication messages.

Deprecated: vatel does not use it; the updateAccessToken endpoint reads {"mid": ..., "at": ...}. Kept for compatibility.

type AuthPayload added in v1.25.0

type AuthPayload interface {
	Perms() []byte
	Debug() bool
}

AuthPayload is the application part of a decoded token, returned by Tokener.ApplicationPayload. The vatel core does not call its methods; they are used by vatel/auth: Perms for the permission check of RequirePerms and Debug to turn on verbose error responses for the request.

Handlers retrieve the payload via ctx.TokenPayload() and type-assert it to their concrete type (e.g. ctx.TokenPayload().(MyJWTClaims)).

type Body added in v1.25.0

type Body[T any] struct {
	In T
}

Body is an embeddable generic type that implements Inputer. Embed it and access input data via the In field. For POST/PUT/PATCH the JSON request body is decoded into In; for GET/DELETE/HEAD/OPTIONS the URL query string is decoded into the fields of In tagged `param:"name"` (prefer QueryParams for query input). The name Body avoids the naming conflict that would arise from an embedded type named Input having a promoted method also named Input().

type CreateOrder struct {
    api *API
    vatel.Body[struct {
        Product  string `json:"product"`
        Quantity int    `json:"quantity"`
    }]
}
// c.In.Product, c.In.Quantity

If T implements Validator, Validate() is called after decoding.

func (*Body[T]) Input added in v1.25.0

func (b *Body[T]) Input() any

Input implements the Inputer interface for query/body binding.

type Context

type Context interface {
	// Response writing
	BodyWriter() io.Writer
	SetContentType([]byte) Context
	SetStatusCode(code int) Context
	SetHeader(name, val []byte) Context

	// Request reading
	Header(name string) []byte
	RemoteAddr() string
	RequestTime() time.Time
	RequestBody() []byte
	PathValue(name string) string
	QueryValue(name string) []byte

	// File handling
	FormFile(key string) (*multipart.FileHeader, error)
	FormValue(key string) []byte
	SaveMultipartFile(fh *multipart.FileHeader, path string) error

	// Auth — the payload set by auth middleware, nil if unauthenticated.
	// Handlers cast it to their concrete type: ctx.TokenPayload().(*MyClaims).
	TokenPayload() any
	SetTokenPayload(tp any)

	// VerboseError reports whether the auth middleware requested verbose error
	// responses for this request (e.g. for debug/admin tokens).
	VerboseError() bool
	SetVerboseError(bool)

	// Per-request key-value store (for middleware ↔ handler communication).
	// Values stored with Set are written to the request log.
	Set(key string, val any) Context
	Get(key string) any
	// SetValue stores val under an arbitrary comparable key, e.g. an unexported
	// typed key of the application, so that Context().Value(key) returns it.
	// A string key is the same as Set; a value under a non-string key is not
	// returned by Get and is not written to the request log.
	SetValue(key, val any) Context
	VisitUserValues(func(key []byte, val any))

	// Context returns the request context. It is cancelled by Vatel.Shutdown
	// and, when the endpoint has a timeout (WithTimeout, WithDefaultTimeout),
	// when the timeout expires or the request ends. fasthttp cannot detect a
	// client disconnect, so it is not cancelled when the client goes away.
	//
	// Its Value(key) returns the request values (Set, SetValue, path params,
	// "reqId"), looked up at call time, and then the values of the parent
	// context. After the request has ended it returns the parent values only.
	Context() context.Context
}

Context is the per-request interface passed to every Handler, middleware, and response hook. It wraps the underlying HTTP request and response without exposing the fasthttp implementation.

func NewContext

func NewContext(fctx *fasthttp.RequestCtx) Context

NewContext creates a Context wrapping the given fasthttp request context. Its Context() is context.Background().

type Endpoint

type Endpoint struct {
	// EndpointMeta is the static definition of the endpoint — method, path,
	// required permissions, and log verbosity. It is passed as-is to every
	// middleware so middleware can make decisions based on endpoint configuration
	// without accessing internal state.
	EndpointMeta

	// Compress enables gzip compression of the response. See WithCompress.
	Compress bool

	// ManualStatusCode leaves the success status to the handler
	// (ctx.SetStatusCode). See WithManualStatusCode.
	ManualStatusCode bool

	// SuccessStatusCode is the status of a successful response; zero keeps
	// 200. See WithSuccessStatusCode.
	SuccessStatusCode int

	// ResponseContentType is the Content-Type of a successful response; empty
	// means "application/json; charset=utf-8". See WithContentType.
	ResponseContentType string

	// OptionalBody allows an empty request body on POST/PUT/PATCH.
	// See WithOptionalBody.
	OptionalBody bool

	// Timeout of the request context; zero takes the Vatel default
	// (WithDefaultTimeout), if any. See WithTimeout.
	Timeout time.Duration

	// Controller factories — exactly one must be set.
	Controller          func() Handler
	WebsocketController func() WebsocketHandler
	// contains filtered or unexported fields
}

Endpoint describes a single REST or WebSocket endpoint and its configuration.

func DELETE added in v1.25.0

func DELETE(path string, ctrl func() Handler, opts ...EndpointOption) Endpoint

DELETE creates a DELETE endpoint with the given path, controller factory, and options.

func GET added in v1.25.0

func GET(path string, ctrl func() Handler, opts ...EndpointOption) Endpoint

GET creates a GET endpoint with the given path, controller factory, and options.

func HEAD(path string, ctrl func() Handler, opts ...EndpointOption) Endpoint

HEAD creates a HEAD endpoint with the given path, controller factory, and options. Input is decoded from the URL query string, like GET. The fasthttp server drops the response body of a HEAD request, so only the status code and headers reach the client.

func NewEndpoint added in v0.3.0

func NewEndpoint(method, path string, perms []string, c func() Handler) *Endpoint

NewEndpoint builds an Endpoint. Prefer the GET/POST/PUT/PATCH/DELETE builder functions from options.go for a more readable call site.

func OPTIONS added in v1.25.0

func OPTIONS(path string, ctrl func() Handler, opts ...EndpointOption) Endpoint

OPTIONS creates an OPTIONS endpoint with the given path, controller factory, and options. Input is decoded from the URL query string, like GET. An explicit OPTIONS endpoint takes precedence over router.GlobalOPTIONS for its path. Do not put RequireAuth/RequirePerms on an endpoint that serves a CORS preflight: browsers send the preflight without the Authorization header.

func PATCH added in v1.25.0

func PATCH(path string, ctrl func() Handler, opts ...EndpointOption) Endpoint

PATCH creates a PATCH endpoint with the given path, controller factory, and options.

func POST added in v1.25.0

func POST(path string, ctrl func() Handler, opts ...EndpointOption) Endpoint

POST creates a POST endpoint with the given path, controller factory, and options.

func PUT added in v1.25.0

func PUT(path string, ctrl func() Handler, opts ...EndpointOption) Endpoint

PUT creates a PUT endpoint with the given path, controller factory, and options.

func WS added in v1.25.0

func WS(path string, ctrl func() WebsocketHandler, opts ...EndpointOption) Endpoint

WS creates a WebSocket endpoint with the given path, controller factory, and options.

func (*Endpoint) AddMiddleware added in v1.25.0

func (e *Endpoint) AddMiddleware(pos MiddlewarePos, f ...func(Context, EndpointMeta) error)

AddMiddleware adds per-endpoint middleware at the given pipeline position. Called from EndpointOption functions (e.g. auth.RequirePerms).

type EndpointMeta added in v1.25.0

type EndpointMeta struct {
	Method     string
	Path       string
	Perms      []string
	LogOptions LogOption
}

EndpointMeta carries read-only metadata about the endpoint being served. It is passed to every middleware function so middleware can make decisions based on the endpoint's static configuration (route, permissions, log level) without accessing internal endpoint state.

type EndpointOption added in v1.25.0

type EndpointOption func(*Endpoint)

EndpointOption is a functional option for configuring an Endpoint.

func Perms added in v1.25.0

func Perms(p ...string) EndpointOption

Perms appends permission names to the endpoint's metadata (EndpointMeta.Perms).

On HTTP endpoints Perms is metadata only: vatel does not check it. The check is done by middleware, e.g. auth.RequirePerms, which also appends its permission names here. On WS endpoints non-empty Perms require WithWsAuthFunc, which receives them; without it BuildHandlers fails.

func WithCompress added in v1.25.0

func WithCompress() EndpointOption

WithCompress enables gzip compression for the endpoint response.

func WithContentType added in v1.25.0

func WithContentType(ct string) EndpointOption

WithContentType sets a custom response Content-Type header. Default is "application/json; charset=utf-8".

func WithLogOption added in v1.25.0

func WithLogOption(lo LogOption) EndpointOption

WithLogOption sets the log verbosity bitmask for the endpoint.

func WithManualStatusCode added in v1.25.0

func WithManualStatusCode() EndpointOption

WithManualStatusCode disables automatic status code setting, allowing the handler to control it explicitly via ctx.SetStatusCode().

func WithOptionalBody added in v1.25.0

func WithOptionalBody() EndpointOption

WithOptionalBody allows an empty request body on a POST/PUT/PATCH endpoint. When the body is empty or contains only whitespace, decoding is skipped: the input stays zero and Validator.Validate() is not called. A non-empty body is decoded and validated as usual; invalid JSON is still rejected with 400. Using it on a method without a request body (GET, DELETE, HEAD, OPTIONS) is a compile error returned from BuildHandlers.

func WithSuccessStatusCode added in v1.25.0

func WithSuccessStatusCode(code int) EndpointOption

WithSuccessStatusCode sets a custom HTTP status code for successful responses. Default is 200.

func WithTimeout added in v1.25.0

func WithTimeout(d time.Duration) EndpointOption

WithTimeout sets the timeout of the request context returned by Context.Context(); it overrides WithDefaultTimeout. The timeout does not interrupt the handler by itself: only code that listens to the context (database queries, HTTP calls, select on Done()) stops. A handler returning context.DeadlineExceeded gets 504, context.Canceled gets 503.

type Endpointer

type Endpointer interface {
	Endpoints() []Endpoint
}

Endpointer is implemented by any type that exposes a set of endpoints.

type Endpoints added in v1.25.0

type Endpoints []Endpoint

Endpoints is a slice of Endpoint that implements the Endpointer interface. Use it to group related endpoints in one place without defining a named type.

func (a *OrdersAPI) Endpoints() []vatel.Endpoint {
    return vatel.Endpoints{
        vatel.GET("/orders",      func() Handler { return &ListOrders{api: a} }, vatel.Perms("orders:read")),
        vatel.POST("/orders",     func() Handler { return &CreateOrder{api: a} }, vatel.Perms("orders:write")),
        vatel.GET("/orders/{id}", func() Handler { return &GetOrder{api: a} },   vatel.Perms("orders:read")),
    }
}

func (Endpoints) Endpoints added in v1.25.0

func (e Endpoints) Endpoints() []Endpoint

Endpoints implements the Endpointer interface.

type Expirer added in v1.25.0

type Expirer interface {
	ExpiresAt() time.Time
}

Expirer is implemented by a token payload (AuthPayload) that expires. The WebSocket connection whose payload has a non-zero ExpiresAt is closed with WsStatusAuthRequired at that time, unless the client sends a new token (updateAccessToken) before: once the payload has expired, the connection is not served and takes no new token, even before its expiry timer fires (a message or a token update on it closes it with WsStatusAuthRequired at once). An already expired new payload is rejected by UpdateAccessToken with ErrUnauthorized, and a payload stored for an ID before its connection opened (UpgradeWithID) that has expired by then is dropped: the connection opens without a payload. An expired payload never authorizes a message.

type Handler

type Handler interface {
	Handle(Context) error
}

Handler is the core interface every controller must implement.

type Inputer

type Inputer interface {
	Input() any
}

Inputer is implemented by controllers that expect decoded query or body input. For GET/DELETE/HEAD/OPTIONS the framework decodes URL query parameters (fields tagged `param:"name"`); for POST/PUT/PATCH it decodes the JSON body. To read the query string on POST/PUT/PATCH as well, implement Querier (embed QueryParams). The returned pointer must be stable across the request lifetime (i.e. a field of the controller struct, not a local variable).

type JsonMasker added in v0.10.0

type JsonMasker interface {
	Fields(structure any, tag string) jsonmask.Fields
	Mask(src []byte, fields jsonmask.Fields) ([]byte, error)
}

JsonMasker masks sensitive fields in JSON payloads for logging.

type LogOption added in v0.7.0

type LogOption uint32

LogOption is a bitmask controlling per-endpoint log verbosity.

const (
	LogSilent     LogOption = 1 << iota // log nothing but 5xx errors; overrides all other bits
	LogEnter                            // log when a request arrives
	LogExit                             // log when a request completes
	LogReqBody                          // log the request body as compacted JSON (masked by `mask` tags)
	LogReqInput                         // log the decoded input struct (masked by `mask` tags)
	LogRespBody                         // log the response body (masked by `mask` tags) under "respBody"
	LogRespOutput                       // log the response body under "result" when LogRespBody is not set
)

type MetricReporter added in v0.11.0

type MetricReporter interface {
	ReportMetric(method, path string, statusCode int, dur float64, size int)
}

MetricReporter submits per-request metrics (e.g. to Prometheus).

type MiddlewarePos added in v0.8.0

type MiddlewarePos int

MiddlewarePos identifies the pipeline position where a middleware executes.

const (
	OnRequest         MiddlewarePos = iota // first: IP rate limit, request logging
	OnAuthentication                       // decode token, populate request context
	OnAuthorization                        // check required permissions per endpoint
	OnHandle                               // pre-handler: per-user rate limit, audit, tx setup
	OnSuccessResponse                      // after a successful response is written; errors are ignored
	OnErrorResponse                        // after an error response is written; errors are ignored

)

type Option added in v0.7.0

type Option struct {
	// contains filtered or unexported fields
}

Option holds configuration for a Vatel instance.

type OptionalOutput added in v1.25.0

type OptionalOutput[T any] struct {
	Out *T
}

OptionalOutput is an embeddable generic type for controllers that may or may not produce a response body depending on runtime logic. Set Out to a non-nil pointer to include a body; leave it nil to produce no body (e.g. 204/304).

type FindController struct {
    vatel.OptionalOutput[SearchResult]
}
func (c *FindController) Handle(_ vatel.Context) error {
    if result, ok := search(); ok {
        c.Out = &result
    }
    return nil
}

func (*OptionalOutput[T]) Result added in v1.25.0

func (o *OptionalOutput[T]) Result() any

Result implements the Resulter interface. Returns nil when Out is unset, signalling to the framework that no response body should be written.

type Output added in v1.25.0

type Output[T any] struct {
	Out T
}

Output is an embeddable generic type that provides response serialization for struct-based controllers. Assign Out directly — no pointer needed. The controller struct is already heap-allocated per request, so Out lives inside it at no extra allocation cost.

type GetOrder struct {
    vatel.Output[Order]
}
func (c *GetOrder) Handle(_ vatel.Context) error {
    c.Out = order
    return nil
}

func (*Output[T]) Result added in v1.25.0

func (o *Output[T]) Result() any

Result implements the Resulter interface. Always returns a non-nil pointer to Out so the framework serializes the response body.

type Paramer

type Paramer interface {
	Param() any
}

Paramer is implemented by controllers that bind URL path parameters. The returned pointer must be stable across the request lifetime.

type PathParams added in v1.25.0

type PathParams[T any] struct {
	Params T
}

PathParams is an embeddable generic type that provides path parameter binding for struct-based controllers. Embed it and access parameters via the Params field.

type GetOrder struct {
    api *API
    vatel.PathParams[struct {
        ID int `param:"id"`
    }]
}
// c.Params.ID

func (*PathParams[T]) Param added in v1.25.0

func (p *PathParams[T]) Param() any

Param implements the Paramer interface for path parameter binding.

type Querier added in v1.25.0

type Querier interface {
	QueryInput() any
}

Querier is implemented by controllers that bind URL query string variables (fields tagged `param:"name"`) independently of the request body. The query string is decoded for every HTTP method, so a POST controller can implement both Querier and Inputer to receive query variables and a JSON body. Embed QueryParams to implement it. The returned pointer must be stable across the request lifetime.

type QueryParams added in v1.25.0

type QueryParams[T any] struct {
	Query T
}

QueryParams is an embeddable generic type that provides URL query string variable binding for struct-based controllers. Embed it and access query variables via the Query field. It implements Querier, so the query string is decoded for every HTTP method, including POST/PUT/PATCH, and can be combined with Body to receive both query variables and a JSON body.

type ListOrders struct {
    api *API
    vatel.PathParams[struct{ CustomerID int `param:"customerId"` }]
    vatel.QueryParams[struct {
        Status string `param:"status"`
        Page   int    `param:"page"`
    }]
}
// c.Params.CustomerID, c.Query.Status, c.Query.Page

If T implements Validator, Validate() is called after decoding, before the validation of Body input. Declare Validate() on a named type: an anonymous struct cannot have methods.

func (*QueryParams[T]) QueryInput added in v1.25.0

func (q *QueryParams[T]) QueryInput() any

QueryInput implements the Querier interface for URL query string binding.

type Resulter

type Resulter interface {
	Result() any
}

Resulter is implemented by controllers that produce a JSON response body. Returning nil from Result() suppresses the response body (useful for 304 / 204).

type RevokeTokenChecker added in v0.3.0

type RevokeTokenChecker interface {
	IsTokenRevoked(accessToken string) (bool, error)
}

RevokeTokenChecker reports whether an access token has been revoked.

type TokenDecoder

type TokenDecoder interface {
	Decode(encodedToken []byte) (Tokener, error)
}

TokenDecoder decodes a raw token into a Tokener.

type Tokener

type Tokener interface {
	SystemPayload() map[string]any
	ApplicationPayload() AuthPayload
}

Tokener wraps the system and application parts of a decoded JWT.

type Upload added in v1.25.0

type Upload struct {
	Key    string                // multipart form field name
	Header *multipart.FileHeader // populated by BindFile
}

Upload is an embeddable type that provides multipart file upload handling for struct-based controllers. Unlike PathParams/Body, binding is not automatic — call BindFile(ctx) at the start of Handle().

type ImportHandler struct {
    vatel.Upload  // Upload: vatel.Upload{Key: "file"}
    vatel.Output[ImportResult]
}

func (h *ImportHandler) Handle(ctx vatel.Context) error {
    if err := h.BindFile(ctx); err != nil { return err }
    f, err := h.Open()
    if err != nil { return err }
    defer f.Close()
    _, err = io.Copy(s3Writer, f)
    return err
}

func (*Upload) BindFile added in v1.25.0

func (u *Upload) BindFile(ctx Context) error

BindFile reads the uploaded file from the request and stores it in Header. A missing field or a form that cannot be parsed is a client error: ErrInvalidRequestBody (400, VTL-0007) with field "field" set to Key.

func (*Upload) Open added in v1.25.0

func (u *Upload) Open() (multipart.File, error)

Open returns the uploaded file as a multipart.File for streaming. The caller is responsible for closing it. Must be called after BindFile.

func (*Upload) SaveTo added in v1.25.0

func (u *Upload) SaveTo(ctx Context, path string) error

SaveTo saves the uploaded file to the given filesystem path. Must be called after BindFile.

func (*Upload) StreamTo added in v1.25.0

func (u *Upload) StreamTo(w io.Writer) (int64, error)

StreamTo copies the uploaded file to w. Must be called after BindFile. Returns the number of bytes written.

type Validator added in v1.25.0

type Validator interface {
	Validate() error
}

Validator is an optional interface for input structs. When QueryInput() or Input() returns a value implementing Validator, the framework calls Validate() automatically after all input (path, query, body) is decoded and before invoking Handle(): first on the Querier input, then on the Inputer input. The first non-nil error rejects the request with ErrValidationFailed (400, VTL-0006) wrapping it; the code of a returned *errors.Error, if it has one, replaces VTL-0006. Declare Validate() on a named type: an anonymous struct cannot have methods.

For a WebSocket endpoint, Validate() of the Inputer input is called after the message data is decoded, before Handle(); an error is sent to the client the same way.

Validate() is not called when the endpoint uses WithOptionalBody and the request body is empty: the input stays zero, so a Validate() that fills in default values does not run.

type Vatel

type Vatel struct {
	// contains filtered or unexported fields
}

Vatel is the central API gateway. It registers endpoints, configures shared services, and builds fasthttp handlers.

func NewVatel

func NewVatel(optFunc ...func(*Option)) *Vatel

NewVatel returns a new Vatel instance. The root GET / endpoint (table of contents) is registered automatically.

func (*Vatel) Add

func (v *Vatel) Add(e ...Endpointer)

Add appends endpoints to the registry. Validation happens in BuildHandlers.

func (*Vatel) AddMiddleware added in v0.3.0

func (v *Vatel) AddMiddleware(pos MiddlewarePos, f ...func(Context, EndpointMeta) error)

AddMiddleware registers middleware at the given pipeline position. Middleware functions are called in the order they are added.

func (*Vatel) BuildHandlers

func (v *Vatel) BuildHandlers(mux *router.Router, l *zerolog.Logger) error

BuildHandlers registers all endpoints with the router. It returns an error instead of panicking: an invalid endpoint (e.g. a Param(), QueryInput() or GET Input() that does not return a pointer to a struct), a path that does not begin with "/", a route the router rejects (a duplicate or conflicting path, or an own GET "/", which is taken by the built-in endpoint list), a WS endpoint without WithWebsocketSupport or a duplicate WS path.

Middleware (AddMiddleware) and endpoints (Add) must be added before BuildHandlers: it copies them, later additions have no effect.

BuildHandlers may be called only once. Registration is not transactional: after a failed build the router may already hold some of the routes and they cannot be removed, so the Vatel is unusable. A further BuildHandlers or ListenAndServe returns a "previous build failed" error; create a new Vatel and router instead.

func (*Vatel) Endpoints

func (v *Vatel) Endpoints() []Endpoint

Endpoints returns all registered endpoints.

func (*Vatel) ListenAndServe added in v1.25.0

func (v *Vatel) ListenAndServe(addr string, mux *router.Router, l *zerolog.Logger) error

ListenAndServe compiles all endpoints (unless BuildHandlers was already called), starts the HTTP server on addr, and blocks until the server stops. The caller retains ownership of mux so that non-vatel routes can be added before calling this method. The server is configured with DefaultIdleTimeout and CloseOnShutdown; see WithServerConfig.

Use Shutdown to stop the server gracefully; ListenAndServe then returns nil. Called after Shutdown, it returns ErrServerClosed without starting.

func (*Vatel) MustBuildHandlers

func (v *Vatel) MustBuildHandlers(mux *router.Router, l *zerolog.Logger)

MustBuildHandlers registers all endpoints with the router. Panics on error.

func (*Vatel) Shutdown added in v1.25.0

func (v *Vatel) Shutdown(ctx context.Context) error

Shutdown gracefully stops the server:

  1. It starts closing all active WebSocket connections (clients receive a "going away" frame and can reconnect; see WebsocketVatel.Shutdown) and stops accepting new connections. ListenAndServe returns nil; called later, it returns ErrServerClosed.
  2. It waits until no HTTP request is in flight and every WebSocket connection has closed. Requests being handled and requests still being received (headers or body being read) finish normally, and their responses carry "Connection: close". Idle keep-alive connections do not hold it up: they are closed. A new connection that has sent nothing yet gets about 5 seconds to start its request (as in net/http), then it is closed too. A WebSocket message being handled is finished; a WebSocket client that does not answer the close frame is disconnected after about one second.
  3. It cancels the request contexts (Context.Context()) and returns nil.

When ctx is done before step 2 ends, the request contexts of requests still in flight are cancelled at once, so handlers listening to them can stop (a context.Canceled error gives 503). Shutdown then waits up to one more second for them to finish and returns ctx.Err(); requests still running after that are not interrupted, and their responses carry "Connection: close".

Without a server started by ListenAndServe the request contexts are cancelled at once, and Shutdown waits, within ctx, only for the WebSocket connections. Requests that start after Shutdown get a cancelled context. Calling Shutdown again, also concurrently, is safe: each call waits with its own ctx.

Pass a context with a deadline to bound the graceful phase:

ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
v.Shutdown(ctx)

type VatelContext

type VatelContext struct {
	// contains filtered or unexported fields
}

VatelContext is the concrete Context implementation wrapping fasthttp.RequestCtx.

func (*VatelContext) BodyWriter

func (c *VatelContext) BodyWriter() io.Writer

func (*VatelContext) Context added in v1.25.0

func (c *VatelContext) Context() context.Context

func (*VatelContext) FormFile

func (c *VatelContext) FormFile(key string) (*multipart.FileHeader, error)

func (*VatelContext) FormValue

func (c *VatelContext) FormValue(key string) []byte

func (*VatelContext) Get added in v0.7.0

func (c *VatelContext) Get(key string) any

func (*VatelContext) Header

func (c *VatelContext) Header(name string) []byte

func (*VatelContext) PathValue added in v1.25.0

func (c *VatelContext) PathValue(name string) string

func (*VatelContext) QueryValue added in v1.25.0

func (c *VatelContext) QueryValue(name string) []byte

func (*VatelContext) RemoteAddr added in v1.25.0

func (c *VatelContext) RemoteAddr() string

func (*VatelContext) RequestBody added in v1.25.0

func (c *VatelContext) RequestBody() []byte

func (*VatelContext) RequestTime added in v1.25.0

func (c *VatelContext) RequestTime() time.Time

func (*VatelContext) SaveMultipartFile

func (c *VatelContext) SaveMultipartFile(fh *multipart.FileHeader, path string) error

func (*VatelContext) Set added in v0.7.0

func (c *VatelContext) Set(key string, val any) Context

func (*VatelContext) SetContentType

func (c *VatelContext) SetContentType(ct []byte) Context

func (*VatelContext) SetHeader

func (c *VatelContext) SetHeader(name, val []byte) Context

func (*VatelContext) SetStatusCode

func (c *VatelContext) SetStatusCode(code int) Context

func (*VatelContext) SetTokenPayload

func (c *VatelContext) SetTokenPayload(tp any)

func (*VatelContext) SetValue added in v1.25.0

func (c *VatelContext) SetValue(key, val any) Context

func (*VatelContext) SetVerboseError added in v1.25.0

func (c *VatelContext) SetVerboseError(v bool)

func (*VatelContext) TokenPayload

func (c *VatelContext) TokenPayload() any

func (*VatelContext) VerboseError added in v1.25.0

func (c *VatelContext) VerboseError() bool

func (*VatelContext) VisitUserValues added in v0.7.0

func (c *VatelContext) VisitUserValues(f func(key []byte, val any))

type WebsocketContext added in v0.13.0

type WebsocketContext interface {
	BodyWriter() io.Writer
	TokenPayload() any
	SetTokenPayload(tp any)
	Set(key string, val any) WebsocketContext
	Get(key string) any
	VisitValues(func(key []byte, val any))
	Created() time.Time
	ID() uint64
}

WebsocketContext is the per-message interface passed to WebSocket handlers and middleware.

func NewWsContext added in v0.13.0

func NewWsContext(c *websocket.Conn) WebsocketContext

NewWsContext creates a WebsocketContext for the given connection.

type WebsocketHandler added in v0.13.0

type WebsocketHandler interface {
	Handle(WebsocketContext) error
}

WebsocketHandler is the core interface for WebSocket message controllers.

type WebsocketRequest added in v0.13.5

type WebsocketRequest struct {
	Path string          `json:"path"`
	Data json.RawMessage `json:"data,omitempty"`
}

WebsocketRequest is the envelope for all incoming WebSocket messages.

type WebsocketVatel added in v0.13.5

type WebsocketVatel struct {
	// contains filtered or unexported fields
}

WebsocketVatel manages WebSocket endpoint registration and message routing.

func NewWebsocketVatel added in v0.13.5

func NewWebsocketVatel(optFunc ...func(*WsOption)) *WebsocketVatel

NewWebsocketVatel creates a WebsocketVatel and registers the built-in updateAccessToken endpoint.

func (*WebsocketVatel) CloseConn added in v1.25.0

func (wv *WebsocketVatel) CloseConn(cid uint64, status websocket.StatusCode, reason string) bool

CloseConn closes the live connection cid with status and reason, e.g. when its user was removed from the organization, logged out, or its token was revoked; its further messages are not served (the message being handled is finished), and OnClose is called once it has closed. Reason is sent to the client: it is cut to 123 bytes and must not carry sensitive details. It reports false if there is no such connection or it is already closing. It does not wait for the close and is safe from any goroutine, including a VisitConns callback.

func (*WebsocketVatel) Endpoints added in v0.13.5

func (wv *WebsocketVatel) Endpoints() []Endpoint

Endpoints returns the built-in WebSocket endpoints exposed by WebsocketVatel.

func (*WebsocketVatel) OnClose added in v0.13.5

func (wv *WebsocketVatel) OnClose(f func(*websocket.Conn, error))

OnClose registers a callback invoked when a WebSocket connection closes.

func (*WebsocketVatel) OnOpen added in v0.13.5

func (wv *WebsocketVatel) OnOpen(f func(*websocket.Conn))

OnOpen registers a callback invoked when a new WebSocket connection is established.

func (*WebsocketVatel) OnTokenUpdate added in v1.25.0

func (wv *WebsocketVatel) OnTokenUpdate(f func(cid uint64, prev, next AuthPayload) error)

OnTokenUpdate registers a callback invoked by UpdateAccessToken after the token is decoded and before its payload next is stored for the connection cid; prev is the payload stored for it so far, nil for the first token. Returning an error rejects the token: the payload is not stored (the connection keeps its previous payload, if any) and the error is returned from UpdateAccessToken, and so sent to the client of the updateAccessToken endpoint. A *WsCloseError then also closes the connection after the reply, e.g. to keep one security context (user, organization) per connection:

wv.OnTokenUpdate(func(_ uint64, prev, next vatel.AuthPayload) error {
	if prev == nil {
		return nil // first token
	}
	p, n := prev.(*Claims), next.(*Claims)
	switch {
	case p.UserID != n.UserID:
		return vatel.NewWsCloseError(vatel.WsStatusForbidden, "user changed", nil)
	case p.OrgID != n.OrgID:
		return vatel.NewWsCloseError(vatel.WsStatusContextChanged, "organization changed", nil)
	}
	return nil
})

The callback runs without a lock held; if another payload is stored for cid meanwhile (a concurrent UpdateAccessToken), or the connection closes, UpdateAccessToken returns ErrWsTokenConflict and stores nothing.

func (*WebsocketVatel) RegisterEndpoint added in v0.13.5

func (wv *WebsocketVatel) RegisterEndpoint(v *Vatel, e *Endpoint, l *zerolog.Logger) error

RegisterEndpoint is called by Vatel.BuildHandlers (registerEndpoints) for every endpoint with method "WS".

func (*WebsocketVatel) Shutdown added in v1.25.0

func (wv *WebsocketVatel) Shutdown()

Shutdown closes all active WebSocket connections with a "going away" status, so clients can reconnect to another instance; their further messages are not served, the message being handled is finished (its writes after the close fail), and a connection opened later is closed at once. It does not wait for the connections to close: a client that does not answer the close frame (or does not read) is disconnected after about one second. Vatel.Shutdown calls it and waits, within its context, until every connection has closed; calling it again does nothing.

func (*WebsocketVatel) UpdateAccessToken added in v0.13.5

func (wv *WebsocketVatel) UpdateAccessToken(cid uint64, token string) error

UpdateAccessToken decodes a new access token for the connection cid and calls the OnTokenUpdate callback, if any, with the payload stored so far; unless the callback returns an error, it stores the new payload for cid. If the connection is closing, or its payload has expired (the connection is then closed with WsStatusAuthRequired, "token expired", even if its expiry timer has not fired yet), it returns ErrWsTokenConflict at once: the token is not decoded and the callback is not called. A token that cannot be decoded, or whose payload (Expirer) has expired, is rejected with ErrUnauthorized; if another payload was stored for cid while the callback ran, or the connection closed, started closing or its payload expired, it returns ErrWsTokenConflict. A rejected token is not stored: the connection keeps its payload. Storing a payload arms the expiry of the connection (Expirer) and ends its WithWsAuthTimeout. A payload stored for a cid without a live connection becomes the payload of the connection opened with that ID (UpgradeWithID), unless it has expired by then: the connection then opens without a payload. Without WithTokenDecoder it does nothing.

A *WsCloseError returned by the callback is returned as is: the updateAccessToken endpoint then closes the connection after its reply; a direct caller closes it with CloseConn.

func (*WebsocketVatel) Upgrade added in v0.13.5

func (wv *WebsocketVatel) Upgrade(ctx *fasthttp.RequestCtx)

Upgrade upgrades an HTTP connection to WebSocket.

func (*WebsocketVatel) UpgradeWithID added in v0.13.5

func (wv *WebsocketVatel) UpgradeWithID(ctx *fasthttp.RequestCtx, id uint64)

UpgradeWithID upgrades an HTTP connection to WebSocket with a caller-supplied ID. The ID must not be the ID of a live connection: such a connection is closed at once with a "policy violation" status (1008).

func (*WebsocketVatel) VisitConns added in v1.25.0

func (wv *WebsocketVatel) VisitConns(f func(cid uint64, tp AuthPayload) bool)

VisitConns calls f for each live connection (not being closed), in the order of IDs, with its ID and its token payload (nil without one), until f returns false. f is called on a snapshot without a lock held, so it may call CloseConn; a connection may close meanwhile. Use it to find the connections of a user or an organization from their payloads.

type WsCloseError added in v1.25.0

type WsCloseError struct {
	Code   websocket.StatusCode
	Reason string
	Err    error // optional
}

WsCloseError is an error that closes the WebSocket connection. When the handler of a message (including the built-in updateAccessToken, through OnTokenUpdate, and the WithWsAuthFunc function) returns an error with a *WsCloseError in its chain, the client first gets the error reply to the message, then the connection is closed with Code and Reason.

The reply is built from Err when its chain has an *errors.Error with a status (other errors joined or wrapped next to the WsCloseError do not count); otherwise from Code: WsStatusAuthRequired gives ErrUnauthorized (401), WsStatusForbidden ErrForbidden (403), WsStatusContextChanged ErrWsContextChanged (409), any other code 400. It is never alarmed.

Reason is sent to the client in the close frame, cut to 123 bytes: it must not carry sensitive details; put them in Err.

func NewWsCloseError added in v1.25.0

func NewWsCloseError(code websocket.StatusCode, reason string, err error) *WsCloseError

NewWsCloseError returns a WsCloseError closing the connection with code and reason; err (may be nil) is the cause.

func (*WsCloseError) Error added in v1.25.0

func (e *WsCloseError) Error() string

func (*WsCloseError) Unwrap added in v1.25.0

func (e *WsCloseError) Unwrap() error

type WsContext added in v0.13.0

type WsContext struct {
	// contains filtered or unexported fields
}

WsContext is the concrete WebsocketContext implementation.

func (*WsContext) BodyWriter added in v0.13.0

func (c *WsContext) BodyWriter() io.Writer

BodyWriter returns the writer of messages to the client. A write after the connection is closed fails with websocket.ErrConnClosed; a write to a client that does not read fails after the write timeout (WithWsWriteTimeout).

func (*WsContext) Created added in v0.13.0

func (c *WsContext) Created() time.Time

func (*WsContext) Get added in v0.13.0

func (c *WsContext) Get(key string) any

func (*WsContext) ID added in v0.13.0

func (c *WsContext) ID() uint64

func (*WsContext) Set added in v0.13.0

func (c *WsContext) Set(key string, val any) WebsocketContext

func (*WsContext) SetTokenPayload added in v0.13.0

func (c *WsContext) SetTokenPayload(tp any)

func (*WsContext) TokenPayload added in v0.13.0

func (c *WsContext) TokenPayload() any

func (*WsContext) VisitValues added in v0.13.0

func (c *WsContext) VisitValues(f func(key []byte, v any))

type WsOption added in v0.13.2

type WsOption struct {
	// contains filtered or unexported fields
}

WsOption holds WebSocket-specific configuration.

Directories

Path Synopsis
Package auth provides JWT-based authentication and authorization middleware for vatel HTTP endpoints.
Package auth provides JWT-based authentication and authorization middleware for vatel HTTP endpoints.
Package main demonstrates a complete vatel API server setup.
Package main demonstrates a complete vatel API server setup.
Package jsonmask masks sensitive values in JSON documents according to struct tags.
Package jsonmask masks sensitive values in JSON documents according to struct tags.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL