Documentation
¶
Overview ¶
Package muzak is a type-safe web framework for Go that brings FastAPI's developer experience to the language without giving up the compiler.
A handler is an ordinary typed function. Its input type is the request, its return type is the response body, and both are checked when the program is compiled rather than when a request arrives:
type Params struct {
Username string `path:"username" doc:"The username to look up"`
}
type UserOut struct {
Username string `json:"username"`
}
r.Get("/users/{username}", func(ctx *muzak.Context, in Params) (UserOut, error) {
return UserOut{Username: in.Username}, nil
})
There is no wrapper type around the response and no filtering step at run time. What the handler returns is what the client receives, which means a field that should not be exposed cannot be exposed by accident: it is not part of the type.
Composing an application ¶
Routers are built independently and mounted where the application decides, which is the Go counterpart of FastAPI's APIRouter and include_router. A package exports its own routes and stays unaware of the prefix, tags and guards under which it will eventually run:
app := muzak.New(muzak.AppOptions{
Title: "Bigger Applications Example",
Version: "1.0.0",
Addr: ":8080",
}, muzak.WithDependencies(GetQueryToken))
app.Include(users.NewRouter())
app.Include(items.NewRouter())
app.Include(admin.NewRouter(),
muzak.WithPrefix("/admin"),
muzak.WithTags("admin"),
muzak.WithDependencies(GetTokenHeader),
muzak.WithResponseDoc(418, "I'm a teapot"),
)
log.Fatal(app.RunSignals())
App embeds Router, so routes can be registered on it directly with the same generic methods any nested router uses.
Static declarations, imperative decisions ¶
Anything fixed for a route is declared once, at registration. Anything that depends on what happens at run time is decided in the handler. A status code that never varies is Status; one that does is Context.SetStatus. The two never compete, so returning a value and choosing a status stay independent concerns.
Dependencies ¶
Dependencies come in two shapes. A guard validates and produces nothing:
func GetQueryToken(ctx *muzak.Context) error {
if ctx.Query("token") == "" {
return muzak.NewHTTPError(400, "token is required")
}
return nil
}
A provider produces a typed value, retrieved in the handler with From and checked by the compiler, with no cast anywhere in application code:
r.Get("/items/{id}", func(ctx *muzak.Context, in Params) (ItemOut, error) {
user := muzak.From[CurrentUser](ctx)
return ItemOut{ID: in.ID, Owner: user.Username}, nil
}, muzak.Needs(GetCurrentUser))
Guards attach to an application or a router with WithDependencies and cover everything beneath them; providers attach per route with Needs. Resolved values live on the request's Context and are cleared when it is released, so two concurrent requests never see each other's values. Values that should outlive a request are published with WithSingleton, and those that need opening and closing implement Lifecycle.
Uploads and forms ¶
A field tagged `file:"name"` is bound from a multipart upload, and its Go type decides what the handler is handed. File carries the metadata and leaves the content where it is, which is what a large upload wants, while []byte reads it straight into memory:
type UploadFileIn struct {
File muzak.File `file:"file" doc:"A file read as an upload"`
}
r.Post("/uploadfile/", func(ctx *muzak.Context, in UploadFileIn) (UploadFileOut, error) {
return UploadFileOut{Filename: in.File.Filename}, nil
})
Declaring the field as []muzak.File, or as [][]byte, accepts every file sent under the name instead of one:
type MultiUploadIn struct {
Files []muzak.File `file:"files"`
}
A field tagged `form:"name"` is bound from a form value, converted by the same setters that convert a query parameter. A route may bind form values with no file at all, which is what a sign-in form is:
type LoginIn struct {
Username string `form:"username"`
Password string `form:"password"`
}
Such a route accepts application/x-www-form-urlencoded as well as multipart, so a plain HTML form posts to it without an enctype. A route that binds a file accepts only multipart, because urlencoded cannot carry one. Files and form values are body content, so both are required unless the field carries `required:"false"` or a default. Two limits bound what a route accepts: MaxUploadSize for the whole body and MaxFileSize for any single file.
A handler that returns HTML writes an HTML document instead of JSON, which is what serving an upload form from the same application takes:
r.Get("/", func(ctx *muzak.Context, _ muzak.Empty) (muzak.HTML, error) {
return muzak.HTML(`<form action="/files/" enctype="multipart/form-data" method="post">` +
`<input name="files" type="file" multiple><input type="submit"></form>`), nil
})
Middleware ¶
The built-in chain assigns a request identifier, recovers panics, writes the access log and sets the security headers before any route runs. App.Use installs more inside that chain, so anything added there already has an identifier and is already covered by recovery:
app.Use(muzak.Compress(muzak.CompressionOptions{}))
Two are ready to use. CORS is configured rather than installed: a policy on AppOptions.CORS installs it, and no policy at all means no CORS header is ever emitted, so a browser refuses every cross-origin read until the policy is written down. A wildcard origin combined with credentials is refused as a configuration error rather than served.
Compress negotiates gzip or deflate from Accept-Encoding and leaves alone what is not worth compressing: a body under DefaultCompressionMinSize, a media type that is already compressed, an event stream, a range, and anything the handler encoded itself. Vary records the dependency on every response either way, so a cache cannot hand a compressed body to a client that cannot read it.
Anything else is an ordinary func(http.Handler) http.Handler, so writing one takes no framework knowledge. One thing does differ from frameworks in other languages, and it fails quietly: Go puts the header block on the wire at the first WriteHeader, so a header set after the next handler returns is dropped without a word. Middleware that reports something only known at the end, such as how long the request took, has to wrap the writer and fill the value in as the response starts.
Rate limiting ¶
Rate limiting is built in and off until a policy names a Quota. A policy is several quotas at once, because one number cannot tell a burst from sustained abuse:
app := muzak.New(muzak.AppOptions{Title: "Shop"},
muzak.WithRateLimit(muzak.RateLimitOptions{
Storage: NewRedisRateLimitStorage(settings.RedisAddr),
Tracker: UserOrIPTracker,
Quotas: []muzak.Quota{
{Name: "short", Window: time.Second, Limit: 3},
{Name: "medium", Window: 10 * time.Second, Limit: 20},
{Name: "long", Window: time.Minute, Limit: 100},
},
}),
)
Every quota is counted for every request, so a client that overruns the short window still accrues against the long one and cannot launder a flood by pausing between bursts. A refused request is answered with 429, a Retry-After and the RateLimit headers describing the whole policy.
Three decisions are the application's. RateLimitStorage says where the counters live, defaulting to a bounded in-process table that is right for one process and wrong for several. RateLimitTracker says whose budget a request is spent from, defaulting to IPTracker, and is where an API key, a tenant or a resolved user identity belongs instead. ClientIPOptions says which address a request is attributed to, believing no forwarding header until a proxy is named, because a header any client can write is a budget any client can escape.
Narrowing works like everything else here. RateLimit replaces the quotas for one route, which is what a login route wants, and SkipRateLimit exempts one, which is what a health check wants:
r.Get("/health", health, muzak.SkipRateLimit())
r.Post("/login", login, muzak.RateLimit(muzak.Quota{Name: "login", Window: time.Minute, Limit: 5}))
The count happens before the route's guards and dependencies, so a client past its limit is refused before anything expensive runs on its behalf and a request a guard rejects is still counted, which is the half that matters for brute force. RateLimitOptions.AfterDependencies moves it after them, for a tracker that keys on an identity a dependency produced, and gives up the other half. A storage that cannot answer refuses the request with 503 unless RateLimitOptions.FailOpen trades that for availability.
WSOptions.MessageLimits applies the same quotas, storage and tracker to the messages a connected peer sends, which is the one thing the other WebSocket bounds do not cover.
Versioning ¶
Versioning is off until AppOptions.Versioning names a VersioningType. A route or router opts into a version with WithVersion, and a route without one answers no request at all once versioning is on, unless VersioningOptions.DefaultVersion supplies it or the route is deliberately marked VersionNeutral, which answers every version, including a request naming none:
app := muzak.New(muzak.AppOptions{
Versioning: muzak.VersioningOptions{Type: muzak.VersioningURI},
})
r.Get("/cats", findAllV1, muzak.WithVersion("1"))
r.Get("/cats", findAllV2, muzak.WithVersion("2"))
r.Get("/health", health, muzak.WithVersion(muzak.VersionNeutral))
VersioningURI reads the version from the path itself, inserting a prefix ("v" by default; see VersioningOptions.Prefix) in front of every route that declares one, so findAllV1 above answers "/v1/cats". A route naming more than one version is registered once per version, each at its own path, because there the version is part of routing rather than something read off the request. VersioningHeader, VersioningMediaType and VersioningCustom instead leave the path alone and read the version from a header, from a parameter of the Accept header, or from VersioningOptions.Extractor, matching whichever registered route answers it; more than one such route may share a path so long as their versions never overlap, which is checked when the application is built. VersioningCustom alone can offer several versions in order of preference, matched from most to least preferred against whatever a route actually answers.
WebSockets ¶
Router.WS registers a WebSocket route. The handshake is an ordinary GET, so everything that applies to a route applies to it: middleware runs, guards run, dependencies resolve, and the input struct is bound and validated before a single byte is upgraded. What is different is the third argument, which is the connection the handler owns until it returns:
type WSItemIn struct {
ItemID string `path:"item_id"`
Q *int `query:"q"`
}
r.WS("/items/{item_id}/ws", func(ctx *muzak.Context, in WSItemIn, conn *muzak.WSConn) error {
session := muzak.From[SessionOrToken](ctx)
for {
message, err := conn.ReadText(ctx.Context())
if err != nil {
return nil
}
if err := conn.WriteText(ctx.Context(), "you said "+message); err != nil {
return err
}
_ = session
}
}, muzak.Needs(GetSessionOrToken))
A request that fails to bind, or a guard that refuses, is answered with the usual JSON error and never becomes a connection at all, which is what makes a rejection something a client can read rather than a socket that closes a moment after it opened.
Reading and writing are message oriented: a message split across frames is delivered once and whole, a ping is answered without the handler knowing, and a close is answered and then reported as a *WSCloseError, which is why the loop above ends on any error. Writes are serialized, so any number of goroutines may write to one connection. The protocol is implemented here rather than delegated: RFC 6455 framing, masking, UTF-8 validation and the close handshake, with every rule the specification lays down enforced and every violation answered with the status it calls for.
What a hostile peer cannot do ¶
A WebSocket is the longest-lived thing an unauthenticated stranger can ask a server for, so every direction a peer controls is bounded, and each bound is there to stop something specific.
- A message larger than WSOptions.ReadLimit is refused before any of it is buffered, and a frame is taken a chunk at a time as the bytes arrive, so a six byte header cannot buy an allocation the size of the limit.
- A message that begins and does not finish is closed after WSOptions.ReadTimeout, which is what a peer dribbling one out a byte at a time looks like. Waiting between messages is not bounded, because waiting is what most connections are for.
- A message fragmented endlessly, or interleaved with an endless run of pings, is closed once too many frames have arrived without one completing. Neither grows the message, so no size limit would ever catch them.
- A write to a peer that has stopped reading gives up after WSOptions.WriteTimeout rather than pinning a goroutine and a buffer.
- The application holds at most WSOptions.MaxConnections connections at once, and answers 503 with a Retry-After beyond that, because file descriptors run out before anything else does.
- A handshake from another origin is refused outright, because a WebSocket handshake is not subject to the same-origin policy and is never preflighted, which is what makes cross-site hijacking possible in the first place. AppOptions.CORS does not cover it and never could.
- A handshake carrying a body is refused, because whatever went unread would sit on the connection and be taken for frames the moment it was upgraded.
- No extension is negotiated, so no peer can ask the server to keep decompression state on its behalf.
Nothing a peer sends is echoed into a response header: only a subprotocol the route itself offered can be answered with, and a route that offers one which is not a token is refused when the application is built. Nothing a handler fails with is disclosed either; the peer is closed with WSStatusInternalError and the reason goes to the log.
Configure the rest with WithWebSocket, and see WSOptions for what each limit is there to stop.
WSDial is the other end of the same engine, which is what lets a route be tested over a real connection rather than against a second implementation. It checks what a server answers rather than trusting it, and never follows a redirect, because following one would send the headers of the handshake to whatever host the answer named. The test client wraps it as muzak.dev/framework/testclient.Client.WS.
Server-sent events ¶
Router.SSE registers a route whose response is a stream rather than a body. It is the other half of what a WebSocket is usually reached for, and it is the simpler half: the server sends, the client listens, and a browser reads it natively with EventSource, reconnecting on its own when the stream drops.
type StreamIn struct {
Room string `path:"room"`
}
r.SSE("/rooms/{room}/stream", func(ctx *muzak.Context, in StreamIn, stream *muzak.SSEStream[MessageOut]) error {
for message := range room(in.Room).Messages(stream.Context()) {
if err := stream.Send(message); err != nil {
return err
}
}
return nil
})
The type parameter is the contract: nothing but a MessageOut can be sent, and the generated document describes the stream with that type, in the same way a handler's return type describes an ordinary response. Everything else about the route is ordinary too, so middleware runs, guards run, dependencies resolve, and the input is bound and validated before a byte of the stream is written. A request that fails any of that is answered with the usual JSON error and never becomes a stream at all.
Nothing here takes a context, unlike WSConn, because a stream belongs to one request: SSEStream.Context governs every send and is cancelled when the client disconnects or the server begins shutting down, so a handler watches one thing and every send after it reports ErrSSEStreamEnded. Writes are serialized, so any number of goroutines may write to one stream.
SSEStream.SendEvent carries what a bare value cannot: a name to dispatch under, an identifier to resume from, a reconnection delay, or a payload that is not JSON, such as the "[DONE]" sentinel some protocols end with. A browser sends the last identifier it saw back in the Last-Event-ID header when it reconnects, which SSEStream.LastEventID reads, and that is what turns a dropped connection into a stream that picks up where it left off.
A stream is not tied to GET. Router.SSEHandle registers one for any method, which is what a protocol that streams its answer to a posted document needs, and there the input binds a request body like any other route.
What a stream bounds ¶
An event stream costs a connection and a goroutine for as long as a client cares to hold it, so the same reasoning applies as to a WebSocket.
- A client that stops reading is given up on after SSEOptions.WriteTimeout, rather than pinning a goroutine and a growing socket buffer for as long as it likes.
- The application serves at most SSEOptions.MaxStreams streams at once, and answers 503 with a Retry-After beyond that.
- The listener's own timeouts are cleared for the stream and replaced with a deadline per event, because a stream is a response that does not end and would otherwise die at ServerOptions.WriteTimeout however healthy it was. The read deadline goes too: it would cancel the request, and with it the stream, at ServerOptions.ReadTimeout and blame the client.
- An event name or identifier carrying a line break is refused rather than repaired. An event stream is a sequence of lines, so a break in one of those fields would end it and let whatever followed be read as fields of its own, which on a stream carrying one client's input to another is event forgery.
- A payload spanning several lines is written as several data lines and arrives whole, which is both what the format asks for and what stops a value from ending its own field.
- A comment goes out every SSEOptions.KeepAlive on a stream that has said nothing, because a proxy that sees an idle connection for long enough closes it, and because a silent stream is indistinguishable from a dead one.
- Nothing a handler fails with is disclosed: the stream ends and the reason goes to the log. The response header was written before the handler ran, which is what lets a client see the stream open immediately, so anything that decides whether to serve a stream at all belongs in a guard or a dependency, where there is still a response to say it in.
Compression leaves an event stream alone, because holding events in a compressor's window until something forces them out is the one thing a stream cannot survive. The origin policy a WebSocket needs has no counterpart here either: an EventSource is subject to the same-origin policy and to CORS like any other request, so AppOptions.CORS governs it.
SSEDial is the reading end of the same engine, so a stream route is tested over a real connection rather than against a second implementation, and the test client wraps it as muzak.dev/framework/testclient.Client.SSE.
Serving a frontend ¶
Router.Frontend serves the static output of a frontend build, which is what React, Vue, Svelte, Angular, Solid and Astro produce:
app.Frontend("/", muzak.FrontendOptions{Dir: "dist"})
Routes win. A request is matched against every registered route first and reaches the frontend only when none of them answered, so mounting at the root cannot shadow an API. Middleware applies, and so do the guards of the router the frontend was registered on, which is what lets a frontend sit behind the same authentication as everything else.
A path with no file behind it falls back to one, chosen from what the build produced: a 404.html is served with 404, and failing that an index.html is served with 200 for a browser navigation, which is what a client-side router needs in order to take over. A missing script or stylesheet still answers 404, because handing those an HTML document turns a missing file into a parse error somewhere further from the cause. Name the file with FrontendOptions.Fallback or FrontendOptions.NotFound to decide instead, or set FrontendOptions.NoFallback for a plain 404.
FrontendOptions.FS serves the frontend from an io/fs.FS rather than from disk, which is how it gets built into the binary and the deployment becomes one file:
//go:embed all:dist
var assets embed.FS
app.Frontend("/", muzak.FrontendOptions{FS: assets, Dir: "dist"})
Nothing is rendered on the server and nothing is built here. A directory is never listed, a symbolic link cannot lead out of the build output, a method other than GET or HEAD on a file is refused with 405 rather than served, and a directory that does not exist is reported when the application is built rather than on the first request.
Router.Static mounts a directory of files on the same machinery, without the part that makes a frontend work:
app.Static("/static", muzak.StaticOptions{Dir: "static"})
Nothing stands in for a path with no file behind it, so a miss is a 404 and stays one, and a directory is served by its index.html only when StaticOptions.Index asks. That is the whole difference: reach for Static to publish assets, and for Frontend to serve an application whose routing happens in the browser.
What is generated ¶
The OpenAPI 3.1 document at /openapi.json and the documentation UI at /docs are derived from the registrations themselves: path templates, tags, summaries, the schemas of the In and Out types, declared statuses and the entries added by WithResponseDoc and WithResponseModel. The return type describes the response a route succeeds with; every other status code it answers is described by one of those two, either as the standard error envelope or as a model of its own, so a single operation can carry a different schema per status code. All of that reflection happens once, while the application is being built. Nothing on the request path inspects a type, because the binding plan and the response schema were both compiled at start-up.
The page at /docs is embedded in the module and loads nothing from anywhere. It reads this application's own document and renders the reference grouped by tag, every schema as an outline, and a console that sends a request from the page and reports the status, the timing, the headers and the body, or writes that same request out as a curl command. Operations are grouped by the tags WithTags puts on a router or a route; OpenAPIOptions.Tags describes those groups and decides the order they are presented in.
Both documents are rendered, hashed and compressed while the application is built, so a request for either is a few header writes and a copy of bytes that never change: each carries an entity tag the client revalidates against, and the page is served under a content security policy that names the page's own script by hash and permits no network access beyond this origin.
AppOptions.DocsPath and AppOptions.OpenAPIPath decide where the two are served, and AppOptions.DisableDocs turns both off for a deployment that must not describe itself. Where they ended up is reported as the socket opens:
INFO [Docs] Documentation at http://localhost:8080/docs openapi=http://localhost:8080/openapi.json
A path that is not absolute, that is the same as the other one, or that one of the application's own routes already answers is a build error rather than an address nobody can reach.
Errors ¶
Every failure renders as one envelope, carrying a machine-readable code, a message safe to disclose, the status, per-field details and the request identifier that ties the response to the server's log:
{
"error": {
"code": "validation_error",
"message": "The request could not be validated.",
"status": 422,
"details": [
{ "field": "limit", "location": "query", "issue": "must be a valid integer" }
]
},
"request_id": "0611f4b2-2f0a-4b57-9c1a-6e6a2e2f9b31"
}
An error that describes itself, such as one from NewHTTPError, reaches the client as written. Anything else becomes an opaque 500 with the real cause logged and never transmitted. Replace the shape entirely with AppOptions.ErrorRenderer.
There is a constructor for each outcome worth a name of its own, so that returning the right response does not mean remembering the right number:
return schemas.UserOut{}, muzak.NotFound("no user goes by that name")
return schemas.UserOut{}, muzak.Forbidden("") // the standard sentence
return schemas.UserOut{}, muzak.Conflict("that name is taken").Wrap(err)
BadRequest, Unauthorized, PaymentRequired, Forbidden, NotFound, MethodNotAllowed, NotAcceptable, RequestTimeout, Conflict, Gone, PreconditionFailed, PayloadTooLarge, UnsupportedMediaType, UnprocessableEntity, TooManyRequests, InternalServerError, NotImplemented, BadGateway, ServiceUnavailable and GatewayTimeout each return an *HTTPError carrying that status and its classifier, so HTTPError.Wrap, HTTPError.WithCode and HTTPError.WithDetails chain onto every one of them. Any other status is one NewHTTPError can produce.
Defaults worth knowing ¶
Muzak starts from settings that are safe rather than permissive. Every listener timeout is non-zero, request bodies are capped at one mebibyte and uploads at 32, WebSocket messages at one mebibyte, unknown JSON members are rejected, duplicate members and invalid UTF-8 are refused by encoding/json/v2, CORS denies every cross-origin request until it is configured, a WebSocket handshake from another origin is refused until it is allowed, the connections and the event streams one application holds are both capped, a write to a client that stopped reading gives up, no forwarding header is believed until a proxy is named, and a panic becomes a generic 500 with the stack recorded only in the log. Each of these can be relaxed deliberately; none of them is relaxed by omission.
Rate limiting is the deliberate exception, and is off until a quota is declared. There is no limit that is right for every application, and a default one would be a number nobody chose refusing traffic nobody expected. What is safe by default is what happens once one is declared: the counters are bounded, the address is not taken from a header anyone can write, and a storage that stops answering stops traffic rather than stopping the limit.
Testing ¶
The muzak.dev/framework/testclient package serves an application in-process and issues real requests against it, so a test exercises middleware, routing, binding, dependencies and error rendering together rather than any one of them in isolation.
Index ¶
- Constants
- Variables
- func BearerToken(ctx *Context) (string, bool)
- func CodeForStatus(status int) string
- func DefaultErrorRenderer(ctx *Context, err error) (int, any)
- func From[T any](ctx *Context) T
- func IPTracker(ctx *Context) (string, error)
- func LoadConfig[T any](opts ...ConfigOption) (T, error)
- func MustLoadConfig[T any](opts ...ConfigOption) T
- func NewLogger(opts LoggerOptions) *slog.Logger
- func RequestIDFromContext(ctx context.Context) (string, bool)
- func Scoped(logger *slog.Logger, scope string) *slog.Logger
- func SecureCompare(given, expected string) bool
- func TryFrom[T any](ctx *Context) (T, bool)
- type AccessLogOptions
- type App
- func (a *App) Addr() string
- func (a *App) Build() error
- func (a *App) Config() AppOptions
- func (a *App) Document() (*Document, error)
- func (a *App) Logger() *slog.Logger
- func (a *App) Options(opts ...RouterOption)
- func (a *App) Run() error
- func (a *App) RunContext(ctx context.Context) error
- func (a *App) RunSignals() error
- func (a *App) ServeHTTP(w http.ResponseWriter, r *http.Request)
- func (a *App) Shutdown(ctx context.Context) error
- func (a *App) StartLifecycle(ctx context.Context) error
- func (a *App) StopLifecycle(ctx context.Context) error
- func (a *App) Use(middleware ...Middleware)
- type AppOptions
- type CORSOptions
- type ClientIPOptions
- type Components
- type CompressionLevel
- type CompressionOptions
- type Condition
- type ConfigOption
- type ConfigSource
- type Contact
- type Context
- func (c *Context) AddHeader(name, value string)
- func (c *Context) ClientAddr() netip.Addr
- func (c *Context) ClientIP() string
- func (c *Context) Context() context.Context
- func (c *Context) Cookie(name string) (*http.Cookie, error)
- func (c *Context) Header(name string) string
- func (c *Context) Logger() *slog.Logger
- func (c *Context) LookupPath(name string) (string, bool)
- func (c *Context) LookupQuery(name string) (string, bool)
- func (c *Context) PathValue(name string) string
- func (c *Context) Query(name string) string
- func (c *Context) QueryValues(name string) []string
- func (c *Context) Request() *http.Request
- func (c *Context) RequestID() string
- func (c *Context) ResponseWriter() http.ResponseWriter
- func (c *Context) Route() *Route
- func (c *Context) SetCookie(cookie *http.Cookie)
- func (c *Context) SetHeader(name, value string)
- func (c *Context) SetStatus(code int)
- func (c *Context) Status() int
- type Document
- type Empty
- type ErrorBody
- type ErrorDetail
- type ErrorRenderer
- type ErrorResponse
- type File
- type FrontendOptions
- type Guard
- type HTML
- type HTTPError
- func BadGateway(message string) *HTTPError
- func BadRequest(message string) *HTTPError
- func Conflict(message string) *HTTPError
- func Forbidden(message string) *HTTPError
- func GatewayTimeout(message string) *HTTPError
- func Gone(message string) *HTTPError
- func InternalServerError(message string) *HTTPError
- func MethodNotAllowed(message string) *HTTPError
- func NewHTTPError(status int, message string) *HTTPError
- func NewHTTPErrorf(status int, format string, args ...any) *HTTPError
- func NotAcceptable(message string) *HTTPError
- func NotFound(message string) *HTTPError
- func NotImplemented(message string) *HTTPError
- func PayloadTooLarge(message string) *HTTPError
- func PaymentRequired(message string) *HTTPError
- func PreconditionFailed(message string) *HTTPError
- func RequestTimeout(message string) *HTTPError
- func ServiceUnavailable(message string) *HTTPError
- func TooManyRequests(message string) *HTTPError
- func Unauthorized(message string) *HTTPError
- func UnprocessableEntity(message string) *HTTPError
- func UnsupportedMediaType(message string) *HTTPError
- func (e *HTTPError) Error() string
- func (e *HTTPError) ErrorCode() string
- func (e *HTTPError) HTTPStatus() int
- func (e *HTTPError) Unwrap() error
- func (e *HTTPError) WithCode(code string) *HTTPError
- func (e *HTTPError) WithDetails(details ...ErrorDetail) *HTTPError
- func (e *HTTPError) Wrap(err error) *HTTPError
- type Handler
- type Info
- type License
- type Lifecycle
- type LogFormat
- type LoggerOptions
- type MediaType
- type MemoryRateLimitOptions
- type MemoryRateLimitStorage
- func (s *MemoryRateLimitStorage) Increment(_ context.Context, quota, key string, window time.Duration) (int, time.Duration, error)
- func (s *MemoryRateLimitStorage) Len() int
- func (s *MemoryRateLimitStorage) Name() string
- func (s *MemoryRateLimitStorage) Start(context.Context) error
- func (s *MemoryRateLimitStorage) Stop(context.Context) error
- type Middleware
- type NumberField
- type OpenAPIOptions
- type Operation
- type Parameter
- type PathItem
- type Quota
- type RateLimitOptions
- type RateLimitStorage
- type RateLimitTracker
- type RequestBody
- type RequestIDOptions
- type Response
- type Route
- type RouteOption
- type Router
- func (r *Router) Delete[In, Out any](path string, h Handler[In, Out], opts ...RouteOption) *Route
- func (r *Router) Frontend(mountPath string, opts FrontendOptions)
- func (r *Router) Get[In, Out any](path string, h Handler[In, Out], opts ...RouteOption) *Route
- func (r *Router) Handle[In, Out any](method, path string, h Handler[In, Out], opts ...RouteOption) *Route
- func (r *Router) Head[In, Out any](path string, h Handler[In, Out], opts ...RouteOption) *Route
- func (r *Router) Include(child *Router, opts ...RouterOption)
- func (r *Router) Patch[In, Out any](path string, h Handler[In, Out], opts ...RouteOption) *Route
- func (r *Router) Post[In, Out any](path string, h Handler[In, Out], opts ...RouteOption) *Route
- func (r *Router) Put[In, Out any](path string, h Handler[In, Out], opts ...RouteOption) *Route
- func (r *Router) Routes() []*Route
- func (r *Router) SSE[In, Out any](path string, h SSEHandler[In, Out], opts ...RouteOption) *Route
- func (r *Router) SSEHandle[In, Out any](method, path string, h SSEHandler[In, Out], opts ...RouteOption) *Route
- func (r *Router) Static(mountPath string, opts StaticOptions)
- func (r *Router) WS[In any](path string, h WSHandler[In], opts ...RouteOption) *Route
- type RouterOption
- type SSEDialOptions
- type SSEEvent
- type SSEHandler
- type SSEMessage
- type SSEOptions
- type SSEReader
- type SSEStream
- type Schema
- type Server
- type ServerOptions
- type SharedOption
- func AllowUnknownFields() SharedOption
- func Deprecated() SharedOption
- func Hidden() SharedOption
- func MaxBodySize(bytes int64) SharedOption
- func MaxFileSize(bytes int64) SharedOption
- func MaxUploadSize(bytes int64) SharedOption
- func Needs[T any](provide func(ctx *Context) (T, error)) SharedOption
- func RateLimit(quotas ...Quota) SharedOption
- func Singleton[T any](provide func(ctx *Context) (T, error)) SharedOption
- func SkipRateLimit() SharedOption
- func WithDependencies(guards ...Guard) SharedOption
- func WithLifecycle(components ...Lifecycle) SharedOption
- func WithRateLimit(opts RateLimitOptions) SharedOption
- func WithResponseDoc(code int, description string) SharedOption
- func WithResponseModel[T any](code int, description string) SharedOption
- func WithSSE(opts SSEOptions) SharedOption
- func WithSingleton[T any](value T, opts ...SingletonOption) SharedOption
- func WithTags(tags ...string) SharedOption
- func WithVersion(versions ...Version) SharedOption
- func WithWebSocket(opts WSOptions) SharedOption
- type SingletonOption
- type StaticOptions
- type StatusCoder
- type StringField
- type Tag
- type TimeField
- type Validatable
- type Validation
- func (v *Validation) Nested(model Validatable)
- func (v *Validation) Number[T NumberField](ptr *T) *validate.NumberRules
- func (v *Validation) Reject(target any, issue string)
- func (v *Validation) Slice[E any](ptr *[]E) *validate.SliceRules[E]
- func (v *Validation) String[T StringField](ptr *T) *validate.StringRules
- func (v *Validation) Time[T TimeField](ptr *T) *validate.TimeRules
- func (v *Validation) Value[T any](ptr *T) *validate.ValueRules[T]
- func (v *Validation) When(condition bool) *Condition
- type ValidationError
- type Version
- type VersionExtractor
- type VersioningOptions
- type VersioningType
- type WSCloseError
- type WSConn
- func (c *WSConn) Close(status WSStatus, reason string) error
- func (c *WSConn) Ping(ctx context.Context) error
- func (c *WSConn) Read(ctx context.Context) (WSMessageType, []byte, error)
- func (c *WSConn) ReadBinary(ctx context.Context) ([]byte, error)
- func (c *WSConn) ReadJSON(ctx context.Context, target any) error
- func (c *WSConn) ReadText(ctx context.Context) (string, error)
- func (c *WSConn) Subprotocol() string
- func (c *WSConn) Write(ctx context.Context, typ WSMessageType, payload []byte) error
- func (c *WSConn) WriteBinary(ctx context.Context, payload []byte) error
- func (c *WSConn) WriteJSON(ctx context.Context, value any) error
- func (c *WSConn) WriteText(ctx context.Context, message string) error
- type WSDialOptions
- type WSHandler
- type WSMessageType
- type WSOptions
- type WSStatus
Examples ¶
Constants ¶
const ( // CodeBadRequest classifies a malformed request that could not be read at // all. CodeBadRequest = "bad_request" // credentials. CodeUnauthorized = "unauthorized" // CodePaymentRequired classifies a request refused until an account is in // good standing. CodePaymentRequired = "payment_required" // CodeForbidden classifies a request whose credentials were understood // but insufficient. CodeForbidden = "forbidden" // CodeNotFound classifies a request for a path or resource that does not // exist. CodeNotFound = "not_found" // CodeMethodNotAllowed classifies a request whose method is not served at // an otherwise valid path. CodeMethodNotAllowed = "method_not_allowed" // CodeNotAcceptable classifies a request whose Accept header rules out // every representation the route can produce. CodeNotAcceptable = "not_acceptable" // CodeRequestTimeout classifies a request that arrived too slowly to be // waited for. CodeRequestTimeout = "request_timeout" // CodeConflict classifies a request that collides with the current state // of the resource. CodeConflict = "conflict" // CodeGone classifies a resource that existed and was deliberately // removed. CodeGone = "gone" // CodePreconditionFailed classifies a conditional request whose // precondition did not hold. CodePreconditionFailed = "precondition_failed" // CodePayloadTooLarge classifies a request body over the route's limit. CodePayloadTooLarge = "payload_too_large" // CodeUnsupportedMediaType classifies a body sent under a media type the // route cannot decode. CodeUnsupportedMediaType = "unsupported_media_type" // CodeValidationError classifies a request whose fields failed binding or // validation. Responses carrying it always populate Details. CodeValidationError = "validation_error" // CodeTooManyRequests classifies a rate-limited request. CodeTooManyRequests = "too_many_requests" // CodeNotImplemented classifies a route that exists but does nothing yet. CodeNotImplemented = "not_implemented" // CodeBadGateway classifies a dependency answering with something // unusable. CodeBadGateway = "bad_gateway" // application shedding load. CodeServiceUnavailable = "service_unavailable" // CodeGatewayTimeout classifies a dependency that did not answer in time. CodeGatewayTimeout = "gateway_timeout" // CodeInternalError classifies an unexpected server-side fault. It is the // code used for every error that does not describe itself, and the // response never carries anything derived from the underlying cause. CodeInternalError = "internal_error" // CodeClientError classifies a 4xx that Muzak has no more specific code // for. CodeClientError = "client_error" )
Error codes Muzak uses by default. An HTTPError that does not set a code of its own is given the one that matches its status.
const ( // ScopeServer covers start-up, listening and shutdown. ScopeServer = "Server" // ScopeRouter covers route registration and the routing table. ScopeRouter = "Router" // ScopeRequest covers the per-request access log. ScopeRequest = "Request" // ScopeDocs covers the OpenAPI document and the documentation UI. ScopeDocs = "Docs" )
Scopes used by the framework itself, so that application logs can be told apart from Muzak's own at a glance.
const ( // DefaultMaxBodySize is the largest request body accepted by a route that // does not override it, at one mebibyte. DefaultMaxBodySize int64 = 1 << 20 // DefaultMaxUploadSize is the largest form body accepted by a route that // binds files or form values and does not override it, at 32 mebibytes. DefaultMaxUploadSize int64 = 32 << 20 // DefaultMaxHeaderBytes is the largest request header block accepted, at // one mebibyte. DefaultMaxHeaderBytes = 1 << 20 )
Default limits applied when AppOptions leaves them unset. Every one of them is a bound on work an unauthenticated client can ask the server to do, so none of them defaults to zero.
const ( // HeaderRateLimitLimit reports the quota that is closest to being spent. HeaderRateLimitLimit = "RateLimit-Limit" // HeaderRateLimitRemaining reports how many requests are left in that // quota's current window. HeaderRateLimitRemaining = "RateLimit-Remaining" // HeaderRateLimitReset reports how many seconds remain before that window // starts again. HeaderRateLimitReset = "RateLimit-Reset" // HeaderRateLimitPolicy describes every quota the route enforces, as a // list of "limit;w=seconds" entries. It is fixed for a route, so a client // can learn the whole policy from one response. HeaderRateLimitPolicy = "RateLimit-Policy" // HeaderRetryAfter tells a refused client how long to wait, in seconds. HeaderRetryAfter = "Retry-After" )
The response headers a rate-limited route sets, following the shape the HTTP working group's RateLimit header fields draft settled on and that most clients already understand.
const ( // DefaultRateLimitMaxEntries is how many counters one storage holds before // it starts discarding them, at 100000. A counter is a short key and two // words, so a full table costs a few megabytes, which is a bound worth // having: the table is keyed by something the client influences, so // without one the limiter becomes the exhaustion it was added to prevent. DefaultRateLimitMaxEntries = 100_000 // DefaultRateLimitSweepInterval is how often expired counters are // discarded, at one minute. DefaultRateLimitSweepInterval = time.Minute )
Defaults applied to a memory rate limit storage when MemoryRateLimitOptions leaves them unset.
const ( // DefaultReadHeaderTimeout bounds how long a client may take to send the // request headers. DefaultReadHeaderTimeout = 5 * time.Second // DefaultReadTimeout bounds how long a client may take to send the // headers and the body together. DefaultReadTimeout = 30 * time.Second // DefaultWriteTimeout bounds how long a handler may take to write its // response. DefaultWriteTimeout = 30 * time.Second // DefaultIdleTimeout bounds how long a keep-alive connection may sit // unused before it is closed. DefaultIdleTimeout = 120 * time.Second // DefaultShutdownTimeout bounds how long a graceful shutdown waits for // in-flight requests before connections are closed. DefaultShutdownTimeout = 15 * time.Second )
Default listener timeouts. Every one of them is non-zero on purpose: an http.Server left with the standard library's zero values will hold a connection open indefinitely, which is all a slow-loris client needs to exhaust the server's connection budget.
const ( // DefaultSSEReadLimit is the largest single event a reader accepts, at one // mebibyte. It bounds what a server can make a client hold in memory, // which matters because a client cannot choose what it is sent. DefaultSSEReadLimit int64 = 1 << 20 // DefaultSSEReadTimeout bounds how long one event may take to arrive once // it has begun, at thirty seconds. It does not bound how long a stream may // sit idle between events, because waiting is what a stream is for. DefaultSSEReadTimeout = 30 * time.Second )
Defaults applied by SSEDial when SSEDialOptions leaves them unset.
const ( // DefaultSSEKeepAlive is how often a comment is written to an otherwise // idle stream, at fifteen seconds. The HTML specification suggests exactly // this, because a proxy that sees nothing on a connection for long enough // closes it, and a stream that says nothing for minutes at a time is // indistinguishable from one that has died. DefaultSSEKeepAlive = 15 * time.Second // DefaultSSEWriteTimeout bounds how long one event may take to reach the // client, at ten seconds. It is what stops a client that has stopped // reading from pinning a goroutine and a buffer for as long as it likes. DefaultSSEWriteTimeout = 10 * time.Second // DefaultSSEMaxStreams is how many event streams one application serves at // once by default, at 1024. Each stream holds a connection and a goroutine // for as long as the client cares to keep it, so the file descriptor // budget is what runs out first. DefaultSSEMaxStreams = 1024 // DefaultSSEMaxStreamsPerIP is how many event streams a single client // address holds open at once by default, at 64, for the same reasons as // [DefaultWSMaxConnectionsPerIP]: generous for a legitimate browser // session, but small enough that one address can never take more than a // slice of the process-wide budget. DefaultSSEMaxStreamsPerIP = 64 )
Defaults applied to an event stream when SSEOptions leaves them unset. Each of them bounds something a client can hold on to, so none of them defaults to zero.
const ( // DefaultWSReadLimit is the largest message accepted on a connection that // does not override it, at one mebibyte. DefaultWSReadLimit int64 = 1 << 20 // DefaultWSWriteTimeout bounds how long a single message may take to // reach a peer, at ten seconds. DefaultWSWriteTimeout = 10 * time.Second // DefaultWSCloseGracePeriod is how long a closing connection waits for the // peer's own close frame before the transport goes, at 250 milliseconds. DefaultWSCloseGracePeriod = 250 * time.Millisecond // DefaultWSPongTimeout is how long a keepalive ping waits for its answer, // at ten seconds. It applies only when [WSOptions.PingInterval] asks for // keepalive at all. DefaultWSPongTimeout = 10 * time.Second // DefaultWSReadTimeout bounds how long one message may take to arrive once // it has begun, at thirty seconds. DefaultWSReadTimeout = 30 * time.Second // DefaultWSMaxConnections is how many WebSocket connections one // application holds open at once by default, at 1024. It is close to the // file descriptor budget a process is usually given, which is the resource // that runs out first. DefaultWSMaxConnections = 1024 // DefaultWSMaxConnectionsPerIP is how many WebSocket connections a single // client address holds open at once by default, at 64. It is far above // what a legitimate browser session needs, even one holding dozens of // tabs each open to their own connection, and far below // DefaultWSMaxConnections, so a single misbehaving or attacking address // can take a meaningful slice of the process budget but never all of it. DefaultWSMaxConnectionsPerIP = 64 )
Defaults applied to a WebSocket connection when WSOptions leaves them unset. Each of them bounds work a connected peer can ask the server to do, so none of them defaults to zero.
const DefaultCompressionMinSize = 1400
DefaultCompressionMinSize is the smallest body Compress will compress unless told otherwise.
It is a little under one ethernet MTU: a response that already fits in a single packet cannot be made to arrive sooner by shrinking it, and compressing it spends CPU on both ends to save nothing.
const DefaultForwardedHeader = "X-Forwarded-For"
DefaultForwardedHeader is the header consulted for the client's address when the request arrived from a trusted proxy and ClientIPOptions.Header does not name a different one.
const HeaderRequestID = "X-Request-Id"
HeaderRequestID is the response header carrying the identifier assigned to each request.
const OpenAPIVersion = "3.1.0"
OpenAPIVersion is the specification version Muzak emits.
const RedactedPlaceholder = "[redacted]"
RedactedPlaceholder is written in place of a redacted value.
const RequestIDKey = "request_id"
RequestIDKey is the log attribute key under which the request identifier is recorded. The console handler shortens values under this key to their first eight characters, keeping development output narrow while JSON output keeps the identifier in full.
const ScopeKey = "scope"
ScopeKey is the attribute key that names the subsystem a log record came from. The console handler lifts it out of the attribute list and renders it as the bracketed column after the level, which is what produces lines like
14:32:07.492 INFO [Server] Listening on :8080
Set it with Scoped rather than by hand.
Variables ¶
var ( // ErrCORSWildcardCredentials reports a CORS policy that pairs a wildcard // origin with credentials. Browsers reject that combination, so accepting // it here would only hide the mistake until it reached a browser. ErrCORSWildcardCredentials = errors.New("muzak: a wildcard CORS origin cannot be combined with AllowCredentials") // ErrNotBuilt reports an operation attempted on an application whose // routes failed to build. ErrNotBuilt = errors.New("muzak: the application could not be built") )
Errors reported for configurations Muzak refuses to serve.
var DefaultRedactedKeys = []string{
"authorization",
"proxy-authorization",
"cookie",
"set-cookie",
"password",
"passwd",
"secret",
"token",
"access-token",
"refresh-token",
"api-key",
"apikey",
"private-key",
"client-secret",
"session",
"credentials",
}
DefaultRedactedKeys lists the attribute keys whose values are replaced with RedactedPlaceholder before a record is written.
Matching is case-insensitive and ignores '-' and '_', so "API-Key", "api_key" and "apikey" are all caught. The list exists because credentials reach logs by accident far more often than by design, most often through an attribute carrying a whole header map or request struct.
var ErrNoFile = errors.New("muzak: no file was uploaded for this field")
ErrNoFile reports an operation on a File that was never bound from a request, which is what an optional file field holds when the client sent nothing under its name.
var ErrSSEStreamEnded = errors.New("muzak: the event stream has ended")
ErrSSEStreamEnded reports an event stream that is no longer being written to: the client disconnected, the request was cancelled, the server is shutting down, or the handler has already returned.
Every send on an ended stream reports it, so a handler's loop ends on the first one it sees:
for update := range updates {
if err := stream.Send(update); err != nil {
return err
}
}
A handler that returns it is treated as a stream that finished rather than as one that failed, so a client going away is not logged as an error. Test for it with errors.Is; the reason the stream ended is wrapped inside.
Functions ¶
func BearerToken ¶
BearerToken returns the token from the request's Authorization header and reports whether a well-formed bearer credential was present. The scheme is matched case-insensitively, as RFC 9110 requires.
func CodeForStatus ¶
CodeForStatus returns the default error code for an HTTP status.
Statuses Muzak recognises get a specific classifier such as "not_found"; any other 4xx becomes CodeClientError and everything else becomes CodeInternalError. Use it when writing a custom ErrorRenderer that should stay consistent with the built-in codes.
func DefaultErrorRenderer ¶
DefaultErrorRenderer produces Muzak's standard ErrorResponse envelope.
A *ValidationError becomes a 422 classified "validation_error" carrying one detail per field. An error implementing StatusCoder, including *HTTPError, keeps its status, code, message and details. Everything else becomes a 500 whose message and code are fixed constants, so that an unexpected fault (a nil dereference, a database driver error, a wrapped file path) cannot leak internal state through the response. The request identifier is copied from the context in every case.
func From ¶
From returns the value dependency of type T resolved for the current request.
The type argument is checked at compile time and no assertion appears in calling code. T must have been declared for the route being executed, via Needs or Singleton on the route itself or on any router that encloses it; asking for a type the route never declared is a programming error rather than a runtime condition to handle, so From panics. The panic is caught by the recovery middleware and reported as a 500 with the details logged, but it signals a bug to fix rather than an error to recover from. Use TryFrom when the absence of a dependency is a legitimate state.
Example ¶
ExampleFrom retrieves a value dependency inside a handler. The type argument is checked by the compiler and no cast appears in application code.
type CurrentUser struct{ Username string }
type ItemOut struct {
ID string `json:"id"`
Owner string `json:"owner"`
}
getCurrentUser := func(ctx *muzak.Context) (CurrentUser, error) {
if ctx.Header("Authorization") == "" {
return CurrentUser{}, muzak.NewHTTPError(http.StatusUnauthorized, "unauthorized")
}
return CurrentUser{Username: "fakecurrentuser"}, nil
}
app := muzak.New(muzak.AppOptions{
LoggerOptions: muzak.LoggerOptions{Format: muzak.LogFormatNone},
})
app.Get("/items/{id}", func(ctx *muzak.Context, in struct {
ID string `path:"id"`
}) (ItemOut, error) {
user := muzak.From[CurrentUser](ctx)
return ItemOut{ID: in.ID, Owner: user.Username}, nil
}, muzak.Needs(getCurrentUser))
req := httptest.NewRequest(http.MethodGet, "/items/plumbus", nil)
req.Header.Set("Authorization", "Bearer token")
rec := httptest.NewRecorder()
app.ServeHTTP(rec, req)
fmt.Println(rec.Body.String())
Output: {"id":"plumbus","owner":"fakecurrentuser"}
func IPTracker ¶
IPTracker keys a rate limit on the client's address, and is what a policy that does not name a tracker uses.
The address is the one Context.ClientIP resolves, so it is the peer's unless the application names a trusted proxy. A request whose address cannot be parsed is refused rather than counted anonymously, because counting every such request under one key would give them all a single shared budget.
func LoadConfig ¶
func LoadConfig[T any](opts ...ConfigOption) (T, error)
LoadConfig reads a configuration struct from the environment and any additional sources.
Each exported field is read from the variable named by its env tag, falling back to the field name upper-cased with underscores between words. A default tag supplies the value used when no source holds the variable, and required:"true" turns an absent variable into an error. Field types are converted with the same rules the request binder uses, so strings, booleans, numbers, durations, slices and any encoding.TextUnmarshaler all work:
type Settings struct {
AppName string `env:"APP_NAME" default:"Awesome API"`
AdminEmail string `env:"ADMIN_EMAIL" required:"true"`
ItemsPerUser int `env:"ITEMS_PER_USER" default:"50"`
}
settings, err := muzak.LoadConfig[Settings](muzak.EnvFile(".env"))
Every problem found is reported together, so a first run in a new environment lists all the missing variables at once instead of one per attempt. Mark a field secret:"true" to keep its value out of the error messages produced when it fails to parse.
Example ¶
ExampleLoadConfig reads settings from an explicit set of values, which is what a test supplies in place of the environment.
type Settings struct {
AppName string `env:"APP_NAME" default:"Awesome API"`
AdminEmail string `env:"ADMIN_EMAIL" required:"true"`
ItemsPerUser int `env:"ITEMS_PER_USER" default:"50"`
}
settings, err := muzak.LoadConfig[Settings](
muzak.WithoutEnvironment(),
muzak.ConfigValues(map[string]string{"ADMIN_EMAIL": "admin@example.com"}),
)
if err != nil {
fmt.Println("could not load:", err)
return
}
fmt.Println(settings.AppName, settings.AdminEmail, settings.ItemsPerUser)
Output: Awesome API admin@example.com 50
func MustLoadConfig ¶
func MustLoadConfig[T any](opts ...ConfigOption) T
MustLoadConfig is LoadConfig for a program that cannot run without its configuration.
It panics when loading fails, which is the right behaviour in a main function: a service missing a required setting should stop immediately and visibly rather than start in an undefined state. Prefer LoadConfig anywhere the failure can be handled.
func NewLogger ¶
func NewLogger(opts LoggerOptions) *slog.Logger
NewLogger builds a *slog.Logger from the given options.
The result is safe for concurrent use, redacts sensitive attributes, and performs no formatting work for records below its level.
func RequestIDFromContext ¶
RequestIDFromContext returns the identifier assigned to the request carried by ctx, and reports whether one was assigned. Use it in code that has a context.Context but no Muzak Context, such as a repository or a client wrapper that wants to propagate the identifier downstream.
func Scoped ¶
Scoped returns a logger that tags every record with the given scope name.
Give each subsystem its own scope so its lines line up in the console and can be filtered in production:
log := muzak.Scoped(app.Logger(), "UsersService")
log.Info("user created", "user_id", 42)
It returns logger unchanged when logger is nil, so it is safe to call on an application that has not been built yet.
func SecureCompare ¶
SecureCompare reports whether two secrets are equal, in time that does not depend on where they first differ.
Comparing a credential with == leaks its contents: the comparison returns as soon as two bytes differ, so an attacker who can time the response can recover the secret one byte at a time. SecureCompare hashes both inputs first and compares the digests with subtle.ConstantTimeCompare, which also keeps the length of the expected secret from leaking through the comparison.
Use it for tokens, API keys and signatures. It is not a password verification function: a password must be checked against a slow, memory-hard hash such as the one golang.org/x/crypto/argon2 provides.
Types ¶
type AccessLogOptions ¶
type AccessLogOptions struct {
// Level is the level used for successful responses. Server errors are
// always logged at error level and client errors at warn level, so that a
// quiet production level still surfaces failures. It defaults to
// slog.LevelInfo.
Level slog.Level
// SkipPaths lists exact paths that produce no log line, which keeps a
// health check polled every second from drowning out real traffic.
SkipPaths []string
}
AccessLogOptions configures AccessLog.
type App ¶
type App struct {
*Router
// contains filtered or unexported fields
}
App is a Muzak application: a root router plus the server, middleware, generated documentation and error handling that turn it into something that serves HTTP.
App embeds *Router, so routes can be registered on it directly with the same generic methods any nested router uses, and other routers can be mounted with Router.Include.
func New ¶
func New(opts AppOptions, routerOpts ...RouterOption) *App
New creates an application.
Options given after the AppOptions value configure the root router and are inherited by every route and every included router, which is how an application-wide guard is declared:
app := muzak.New(muzak.AppOptions{
Title: "Bigger Applications Example",
Version: "1.0.0",
Addr: ":8080",
}, muzak.WithDependencies(GetQueryToken))
New never fails. Problems with the routes, such as a duplicate path or an unbindable input type, are reported by App.Build and by the methods that call it.
Example ¶
ExampleNew builds an application, registers a route on it directly, and serves one request.
app := muzak.New(muzak.AppOptions{
Title: "Bigger Applications Example",
Version: "1.0.0",
Addr: ":8080",
LoggerOptions: muzak.LoggerOptions{Format: muzak.LogFormatNone},
})
app.Get("/users/me", func(ctx *muzak.Context, _ muzak.Empty) (UserOut, error) {
return UserOut{Username: "fakecurrentuser"}, nil
})
rec := httptest.NewRecorder()
app.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/users/me", nil))
fmt.Println(rec.Code)
fmt.Println(rec.Body.String())
Output: 200 {"username":"fakecurrentuser"}
func (*App) Addr ¶
Addr returns the address the server is listening on, which is how a test that asked for ":0" discovers the port that was assigned. It returns an empty string before the server has started.
func (*App) Build ¶
Build resolves the routing tree, compiles every binding plan and generates the OpenAPI document.
It is called automatically by App.ServeHTTP and by the run methods, so calling it explicitly is only necessary to surface configuration errors early, which is what a test or a start-up check wants. Building is idempotent: the work happens once and later calls return the same result.
The returned error joins every problem found, so a misconfigured application reports all of them at once rather than one per attempt.
func (*App) Config ¶
func (a *App) Config() AppOptions
Config returns the fully defaulted options the application was built with, which is the reliable way to read a value such as the listen address after New has filled in the blanks.
func (*App) Document ¶
Document returns the generated OpenAPI description of the application, building it if necessary. It returns nil when documentation is disabled with AppOptions.DisableDocs, and an error when the application does not build.
func (*App) Logger ¶
Logger returns the application's logger. Derive a scoped child from it for each subsystem with Scoped.
func (*App) Options ¶
func (a *App) Options(opts ...RouterOption)
Options applies further router options to the application after it was created, which is how a dependency discovered later is published:
app.Options(muzak.WithSingleton(models, muzak.LifecycleFunc("ml-model", start, stop)))
Options must be called before the application is built. Calls made afterwards have no effect, because the routing tree and the dependency chains are resolved once.
func (*App) Run ¶
Run starts the server and blocks until it stops.
It builds the application first, so a configuration error is reported before any socket is opened. The server listens on AppOptions.Addr, serving TLS when a certificate pair or a TLS configuration was supplied.
Run returns nil after a graceful shutdown and an error if the listener could not be opened or the application could not be built. Use App.RunContext for a server that should stop when a context is cancelled, or App.RunSignals for one that should stop on an interrupt.
func (*App) RunContext ¶
RunContext starts the server and blocks until ctx is cancelled or the server fails.
When ctx is cancelled the server stops accepting new connections and waits up to ServerOptions.ShutdownTimeout for in-flight requests to finish before closing the rest. A shutdown triggered this way returns nil, because stopping on request is the expected outcome rather than a failure.
func (*App) RunSignals ¶
RunSignals starts the server and blocks until it is interrupted.
It stops on SIGINT or SIGTERM, which is what a terminal, a container runtime and an init system all send to ask a process to stop, and then shuts down gracefully. It is the method a main function usually wants.
func (*App) ServeHTTP ¶
func (a *App) ServeHTTP(w http.ResponseWriter, r *http.Request)
ServeHTTP implements http.Handler, building the application on first use.
A build failure is reported as a 500 for every request, with the reason logged once at error level rather than sent to clients. Use App.Build to detect the failure before serving.
func (*App) Shutdown ¶
Shutdown stops the server gracefully.
It stops accepting new connections and waits for in-flight requests to finish, giving up after ServerOptions.ShutdownTimeout and closing whatever remains. The ctx argument can cut the wait short; pass context.Background to use the configured timeout alone.
Shutdown is safe to call more than once and from more than one goroutine; only the first call does the work. Calling it on a server that was never started returns nil.
func (*App) StartLifecycle ¶
StartLifecycle brings up every registered lifecycle component.
The run methods call it automatically, between building the application and opening the listening socket, so a program that uses App.Run never needs it. Call it directly when driving an application by hand, as a test does when it serves the app through httptest, and pair it with App.StopLifecycle.
Starting twice is a no-op, so it is safe to call defensively.
func (*App) StopLifecycle ¶
StopLifecycle releases every lifecycle component that was started.
App.Shutdown calls it after the HTTP server has drained, which is the order that matters: components stay usable for as long as a request might still be using them. Calling it on an application whose components were never started returns nil.
func (*App) Use ¶
func (a *App) Use(middleware ...Middleware)
Use installs middleware that runs for every request, including those for the documentation UI and the OpenAPI document.
Middleware installed here runs inside the built-in chain, so it already has a request identifier available and is already covered by panic recovery. Calls to Use after the application has been built have no effect, because the chain is assembled once.
type AppOptions ¶
type AppOptions struct {
// OpenAPIOptions describes the API in the generated document. Its fields
// may be set directly in an AppOptions literal.
OpenAPIOptions
// Addr is the TCP address the server listens on, defaulting to ":8080".
Addr string
// ServerOptions carries the listener timeouts and shutdown behaviour. Its
// fields may be set directly in an AppOptions literal.
ServerOptions
// MaxBodySize is the default request body limit in bytes, overridable per
// route with [MaxBodySize]. It defaults to [DefaultMaxBodySize]. A
// negative value removes the limit, which is only appropriate behind a
// proxy that imposes its own.
MaxBodySize int64
// MaxUploadSize is the default limit in bytes on a form body, overridable
// per route with [MaxUploadSize]. It applies to every route that binds
// `form` or `file` fields, in place of MaxBodySize, and defaults to
// [DefaultMaxUploadSize]. A negative value removes the limit, which is
// only appropriate behind a proxy that imposes its own.
MaxUploadSize int64
// MaxFileSize is the default limit in bytes on any single uploaded file,
// overridable per route with [MaxFileSize]. Zero, the default, leaves each
// file bounded only by MaxUploadSize.
MaxFileSize int64
// Logger is the logger the application uses. When nil, one is built from
// LoggerOptions.
Logger *slog.Logger
// LoggerOptions configures the logger built when Logger is nil.
LoggerOptions LoggerOptions
// ErrorRenderer converts errors into responses. It defaults to
// [DefaultErrorRenderer]; set it to change the error envelope.
ErrorRenderer ErrorRenderer
// DocsPath is where the documentation UI is served, defaulting to
// "/docs". It is an absolute path on this application's own origin, so
// it begins with a slash and is matched exactly:
//
// muzak.AppOptions{DocsPath: "/reference", OpenAPIPath: "/reference/openapi.json"}
//
// Both paths are reported when the server starts listening, as URLs that
// can be opened from the terminal. A path that is not absolute, that
// collides with the other one, or that an application route already
// answers is a build error rather than a page nobody can reach. Set
// [AppOptions.DisableDocs] to serve neither.
DocsPath string
// OpenAPIPath is where the OpenAPI document is served, defaulting to
// "/openapi.json". It follows the same rules as [AppOptions.DocsPath],
// and the page reads the document from wherever this puts it.
OpenAPIPath string
// DisableDocs stops the OpenAPI document and the documentation UI from
// being served, for deployments that must not describe themselves.
// Neither path is registered and no document is generated, so
// [App.Document] returns nil and both paths answer as any other unknown
// path does.
DisableDocs bool
// DisableAccessLog stops the per-request access log from being installed.
DisableAccessLog bool
// AccessLogOptions configures the access log when it is installed.
AccessLogOptions AccessLogOptions
// TrustRequestIDHeader accepts a client-supplied X-Request-Id when it
// parses as a UUID, instead of always generating one. See
// [RequestIDOptions] for why this is off by default.
TrustRequestIDHeader bool
// DisableSecurityHeaders stops [SecurityHeaders] from being installed.
DisableSecurityHeaders bool
// CORS configures cross-origin sharing. The zero value denies every
// cross-origin request, and no CORS middleware is installed unless an
// origin or an origin function is configured.
CORS CORSOptions
// WebSocket configures the WebSocket connections of every route registered
// with [Router.WS], and is narrowed for a router or a route with
// [WithWebSocket]. The zero value bounds message and write sizes and
// refuses a cross-origin handshake.
WebSocket WSOptions
// SSE configures the event streams of every route registered with
// [Router.SSE], and is narrowed for a router or a route with [WithSSE].
// The zero value bounds writes, holds an idle stream open with a periodic
// keepalive, and caps how many streams the application serves at once.
SSE SSEOptions
// RateLimit bounds how fast a client may make requests, and is narrowed
// for a router or a route with [WithRateLimit], [RateLimit] and
// [SkipRateLimit]. The zero value limits nothing: a policy has to name at
// least one [Quota] before any request is counted.
RateLimit RateLimitOptions
// ClientIP decides which address a request is attributed to, which
// matters wherever a decision is made per client rather than per request.
// The zero value believes no forwarding header, so behind a proxy it
// attributes every request to the proxy until the proxy is named in
// [ClientIPOptions.TrustedProxies].
ClientIP ClientIPOptions
// Versioning configures how requests declare which version of the API
// they want. The zero value leaves versioning off, which is what every
// route gets unless this is set; see [VersioningOptions] and
// [WithVersion].
Versioning VersioningOptions
}
AppOptions configures an application.
The zero value is usable: it produces an application that listens on :8080, serves its OpenAPI document and documentation UI, applies the default timeouts and body limit, and logs to standard error. Because OpenAPIOptions is embedded, its fields can be set inline:
app := muzak.New(muzak.AppOptions{
Title: "Bigger Applications Example",
Version: "1.0.0",
Addr: ":8080",
})
type CORSOptions ¶
type CORSOptions struct {
// AllowedOrigins lists the exact origins permitted, such as
// "https://app.example.com". The single entry "*" allows any origin and
// is rejected outright when AllowCredentials is also set.
AllowedOrigins []string
// AllowOriginFunc decides dynamically whether an origin is permitted. It
// is consulted only when AllowedOrigins does not already allow the
// origin, and must not have side effects; it runs on every cross-origin
// request.
AllowOriginFunc func(origin string) bool
// AllowedMethods lists the methods a cross-origin request may use. It
// defaults to GET, HEAD, POST, PUT, PATCH, DELETE and OPTIONS.
AllowedMethods []string
// AllowedHeaders lists the request headers a client may send. It defaults
// to Content-Type, Authorization and X-Request-Id.
AllowedHeaders []string
// ExposedHeaders lists the response headers a client may read. Browsers
// expose only a small safelist unless a header appears here.
ExposedHeaders []string
// AllowCredentials permits cookies and Authorization headers on
// cross-origin requests. It cannot be combined with a wildcard origin.
AllowCredentials bool
// MaxAge is how long a browser may cache the preflight result. It
// defaults to ten minutes; browsers cap it regardless.
MaxAge time.Duration
}
CORSOptions configures CORS. The zero value denies every cross-origin request, which is the only safe default: a permissive policy set by accident hands any web page on the internet the ability to read authenticated responses from the browser of anyone visiting it.
type ClientIPOptions ¶
type ClientIPOptions struct {
// TrustedProxies lists the addresses whose forwarding header is believed,
// as plain addresses ("10.1.2.3") or CIDR prefixes ("10.0.0.0/8"). Both
// address families are accepted.
//
// An entry that cannot be parsed is reported when the application is
// built, rather than quietly widening or narrowing the policy.
TrustedProxies []string
// Header names the forwarding header to read, defaulting to
// [DefaultForwardedHeader]. Set it to the header your proxy actually
// writes, such as "CF-Connecting-IP" or "X-Real-IP", when that is not
// X-Forwarded-For. It is consulted only for a request whose peer is
// trusted.
Header string
}
ClientIPOptions decides how Muzak works out which address a request came from.
The zero value trusts nothing: the address of the peer that opened the connection is the client's address, and every forwarding header is ignored. That is the only safe default, because a forwarding header is a request header like any other, and a server that believes one without knowing who wrote it lets any client claim any address it likes. For a rate limiter, a deny list or an audit log, that is the whole game.
Behind a proxy the default is wrong in the other direction, since every request then appears to come from the proxy. Naming the proxy in TrustedProxies is what makes the header believable:
app := muzak.New(muzak.AppOptions{
ClientIP: muzak.ClientIPOptions{TrustedProxies: []string{"10.0.0.0/8"}},
})
type Components ¶
type Components struct {
// Schemas maps a schema name to its definition.
Schemas map[string]*Schema `json:"schemas,omitzero"`
}
Components holds the reusable schemas an operation refers to by name.
type CompressionLevel ¶
type CompressionLevel uint8
CompressionLevel selects how hard the compressor works.
It is a named type with three values rather than an integer, because the underlying levels are a range with invalid values in it and there is nothing useful to do with a level of 42 at run time. Choosing between the three is a judgement about CPU against bytes; there is no level that is wrong.
const ( // CompressionDefault balances speed against size, which is the right // choice until a measurement says otherwise. CompressionDefault CompressionLevel = iota // CompressionFastest spends the least CPU per response, for a service that // is CPU bound or serving large bodies to a fast network. CompressionFastest // CompressionBest spends the most CPU for the smallest body, for a service // whose clients are on slow or metered connections. CompressionBest )
type CompressionOptions ¶
type CompressionOptions struct {
// Level selects how hard the compressor works. It defaults to
// [CompressionDefault].
Level CompressionLevel
// MinSize is the smallest body worth compressing, in bytes. It defaults to
// [DefaultCompressionMinSize]. A response whose length is not known in
// advance is buffered up to this size before the decision is made.
MinSize int
// ContentTypes limits compression to responses whose media type matches
// one of these entries. An entry is matched as a prefix, so "text/" covers
// every text type, and an entry beginning with "+" matches a structured
// syntax suffix, so "+json" covers "application/problem+json". When empty,
// a built-in list of text-like types is used.
ContentTypes []string
}
CompressionOptions configures Compress.
The zero value is usable and compresses the common text types above DefaultCompressionMinSize at the default level.
type Condition ¶
type Condition struct {
// contains filtered or unexported fields
}
Condition is a pending cross-field check produced by Validation.When.
type ConfigOption ¶
type ConfigOption func(*configLoader)
ConfigOption configures how LoadConfig resolves values.
func ConfigValues ¶
func ConfigValues(values map[string]string) ConfigOption
ConfigValues adds an explicit set of values as a source, which is what a test uses to supply configuration without touching the environment.
func EnvFile ¶
func EnvFile(path string) ConfigOption
EnvFile adds a dotenv file as a configuration source.
The file holds one KEY=VALUE pair per line. Blank lines and lines beginning with '#' are ignored, an optional leading "export " is stripped, and a value may be wrapped in single or double quotes to preserve surrounding spaces or a '#'. Escape sequences are interpreted only inside double quotes.
The real environment takes precedence over the file, so a value exported by a container runtime overrides the one checked into a development .env. A missing file is not an error, which lets the same code run in development, where the file exists, and in production, where the environment supplies everything. A file that exists but cannot be parsed is an error.
func EnvPrefix ¶
func EnvPrefix(prefix string) ConfigOption
EnvPrefix requires every variable name to carry the given prefix, so that "APP_NAME" is read as "MYAPP_APP_NAME" under EnvPrefix("MYAPP_"). It keeps one service's settings from colliding with another's on a shared host.
func WithConfigSource ¶
func WithConfigSource(source ConfigSource) ConfigOption
WithConfigSource adds a custom source, consulted in the order added.
func WithoutEnvironment ¶
func WithoutEnvironment() ConfigOption
WithoutEnvironment stops the process environment from being consulted, leaving only the sources given explicitly. It exists so that a test can pin configuration exactly, without inheriting whatever the developer has exported.
type ConfigSource ¶
type ConfigSource interface {
// Name identifies the source in error messages, as "the environment" or
// the path of a file.
Name() string
// Lookup returns the value stored under key and reports whether it was
// present. A present but empty value must be reported as present, so that
// an explicitly blank setting can override a default.
Lookup(key string) (string, bool)
}
ConfigSource supplies configuration values by name.
Sources are consulted in the order they are given to LoadConfig, and the first one holding a name wins. Implement it to read from somewhere Muzak does not know about, such as a secret manager or a configuration service.
type Contact ¶
type Contact struct {
// Name is the person or team to contact.
Name string `json:"name,omitzero"`
// URL points at a contact page.
URL string `json:"url,omitzero"`
// Email is a contact address.
Email string `json:"email,omitzero"`
}
Contact identifies the people responsible for an API.
type Context ¶
type Context struct {
// contains filtered or unexported fields
}
Context carries the request-scoped state for a single HTTP request, including the captured path parameters, the resolved dependency container, and the status code that will be written for the response.
A Context is pooled and reused across requests. It must not be retained or used after its handler returns; copy out anything you need instead. In particular, do not capture a Context in a goroutine that outlives the handler; pass Context.Context to that goroutine if it needs cancellation.
func (*Context) AddHeader ¶
AddHeader appends a value to a response header without removing values already present, which is what repeated headers such as Set-Cookie or Vary require.
func (*Context) ClientAddr ¶
ClientAddr returns the address the request came from, as a net/netip.Addr, for callers that want to compare it against a prefix rather than render it. The zero Addr, whose IsValid reports false, means the connection has no address that can be parsed.
func (*Context) ClientIP ¶
ClientIP returns the address the request came from, as a string.
It is the peer that opened the connection unless ClientIPOptions names that peer as a trusted proxy, in which case it is the address the proxy reported. The result is normalised, so an address written as an IPv4-in-IPv6 form and the same address written plainly are one value rather than two, which is what stops a client from being counted twice, or from evading a count, by rewriting its own address.
It returns the empty string when the connection has no address that can be parsed, which happens on a listener that is not addressed by IP, such as a Unix socket. Code that keys on the result must handle that; see IPTracker for how the rate limiter does.
func (*Context) Context ¶
Context returns the request's context.Context, which is cancelled when the client disconnects or the server begins shutting down. It is shorthand for c.Request().Context() and is the correct value to hand to any operation that may outlive the handler.
func (*Context) Cookie ¶
Cookie returns the named cookie from the request. It returns http.ErrNoCookie when no such cookie was sent.
func (*Context) Header ¶
Header returns the first value of the named request header, or the empty string when it is absent. The name is matched case-insensitively, as HTTP requires.
func (*Context) Logger ¶
Logger returns the structured logger associated with the application, annotated by the logging middleware with per-request attributes such as the method, path and request identifier when that middleware is enabled.
func (*Context) LookupPath ¶
LookupPath returns the value captured for the named path parameter and reports whether the route template declared it. Use it to tell an absent parameter from one that matched an empty string.
func (*Context) LookupQuery ¶
LookupQuery returns the first value of the named query parameter and reports whether it was present at all, distinguishing "?token=" from a missing "token".
func (*Context) PathValue ¶
PathValue returns the value captured for the named path parameter, or the empty string if the route template declares no such parameter. Values are percent-decoded.
func (*Context) Query ¶
Query returns the first value of the named query parameter, or the empty string when it is absent.
func (*Context) QueryValues ¶
QueryValues returns every value supplied for the named query parameter, in the order they appeared. It returns nil when the parameter is absent.
func (*Context) Request ¶
Request returns the underlying *http.Request. Mutating it is allowed but the router has already finished matching, so changes to the URL have no effect on which handler runs.
func (*Context) RequestID ¶
RequestID returns the identifier assigned to this request, which appears in the X-Request-Id response header, in the "request_id" member of an error response, and in every log line the request produces. It is empty only when the RequestID middleware was removed from the chain.
func (*Context) ResponseWriter ¶
func (c *Context) ResponseWriter() http.ResponseWriter
ResponseWriter returns the http.ResponseWriter for the response.
Writing to it directly bypasses Muzak's response encoding, which means the handler's return value will not be serialized and Context.SetStatus stops having an effect. Use it for streaming, server-sent events or file downloads, and return the zero Out value with a nil error afterwards. The returned writer supports http.ResponseController, so flushing and hijacking work as usual.
func (*Context) Route ¶
Route returns the route being executed, exposing its method, path template, tags and declared documentation. It is never nil inside a handler.
func (*Context) SetCookie ¶
SetCookie adds a Set-Cookie header for the given cookie. The caller is responsible for setting Secure, HttpOnly and SameSite appropriately; Muzak does not modify the cookie.
func (*Context) SetHeader ¶
SetHeader sets a response header, replacing any previously set value. Headers must be set before the handler returns; once the response has begun, further changes are ignored by net/http.
func (*Context) SetStatus ¶
SetStatus sets the HTTP status code that will be written for the current response.
It exists for statuses that depend on runtime logic; a status that is fixed for a route belongs in the route's declaration instead, via Status. Calling SetStatus after the response body has already started writing has no effect and is treated as a programming error logged at warn level, because the status line has by then been sent. If SetStatus is never called, the route's declared default status is used, or 200 if none was declared. Codes outside the 100 to 599 range are clamped to 500.
func (*Context) Status ¶
Status returns the status code that will be written when the handler returns, which is the route's declared default until Context.SetStatus changes it.
type Document ¶
type Document struct {
// OpenAPI is the specification version, always [OpenAPIVersion].
OpenAPI string `json:"openapi"`
// Info carries the title, version and other API metadata.
Info Info `json:"info"`
// Servers lists the base URLs the API is served from.
Servers []Server `json:"servers,omitzero"`
// Paths maps each path template to the operations available on it.
Paths map[string]*PathItem `json:"paths"`
// Components holds the reusable schemas referenced from operations.
Components *Components `json:"components,omitzero"`
// Tags lists the groups operations are sorted into.
Tags []Tag `json:"tags,omitzero"`
}
Document is a complete OpenAPI 3.1 description of an application. It is produced once when the application is built and served at AppOptions.OpenAPIPath.
type Empty ¶
type Empty struct{}
Empty is the sentinel input type for routes that bind nothing from the request. Declare a handler as func(ctx *Context, _ Empty) (Out, error) when the route takes no path, query, header or body parameters; binding is skipped entirely for it.
type ErrorBody ¶
type ErrorBody struct {
// Code is a stable, machine-readable classifier such as
// "validation_error" or "not_found". Clients should branch on it rather
// than on the status code or the message text.
Code string `json:"code"`
// Message is a human-readable summary, safe to display and safe to
// disclose. It never contains internal state.
Message string `json:"message"`
// Status repeats the HTTP status code, so that a response body which has
// been logged or forwarded remains self-describing.
Status int `json:"status"`
// Details lists the individual problems found. It is omitted when there
// are none.
Details []ErrorDetail `json:"details,omitzero"`
}
ErrorBody is the "error" member of an error response.
type ErrorDetail ¶
type ErrorDetail struct {
// Field is the name of the parameter or JSON member at fault, such as
// "limit". It is empty when the problem concerns the request as a whole
// rather than one field.
Field string `json:"field"`
// Location is where the field was read from: "path", "query", "header",
// "cookie" or "body".
Location string `json:"location"`
// Issue explains what was wrong, phrased to read after the field name, as
// in "is required" or "must be a valid integer".
Issue string `json:"issue"`
}
ErrorDetail describes one specific thing that was wrong with a request.
Details are what turn a bare "the request could not be validated" into something a client can act on, naming the offending field, where it was read from and what it should have been instead.
type ErrorRenderer ¶
ErrorRenderer converts an error into the response written for it.
It receives the request Context, whose route, request identifier and headers are all available, and the error the request failed with. It returns the status code to write and the value to serialize as the body; returning a nil body writes the status with no content.
A renderer must not leak internal state. The error it receives may be anything a handler returned, including a wrapped database or filesystem error, so a custom renderer should classify the errors it recognises and fall back to an opaque response for the rest, exactly as DefaultErrorRenderer does. Install one with AppOptions.ErrorRenderer.
type ErrorResponse ¶
type ErrorResponse struct {
// Error carries the classification, summary and per-field details.
Error ErrorBody `json:"error"`
// RequestID correlates the response with the server-side log entry
// written for the same request.
RequestID string `json:"request_id,omitzero"`
}
ErrorResponse is the JSON body Muzak writes for every unsuccessful request.
The shape is deliberately fixed and self-describing:
{
"error": {
"code": "validation_error",
"message": "The request could not be validated.",
"status": 422,
"details": [
{ "field": "name", "location": "body", "issue": "is required" },
{ "field": "limit", "location": "query", "issue": "must be a valid integer" }
]
},
"request_id": "0611f4b2-2f0a-4b57-9c1a-6e6a2e2f9b31"
}
Set AppOptions.ErrorRenderer to replace it with a shape of your own.
type File ¶
type File struct {
// Filename is the name the client reported for the file. It is arbitrary
// client-supplied text and must never be used as a path, a database key or
// anything else with meaning on the server without being checked first.
Filename string
// ContentType is the media type declared for the part, which is likewise
// what the client claimed rather than what the bytes contain. It is empty
// when the part carried no Content-Type header.
ContentType string
// Size is the number of bytes the file holds.
Size int64
// contains filtered or unexported fields
}
File is one file received in a multipart request.
A field tagged `file:"name"` and typed File is bound from the part the client sent under that name, with the metadata copied out of the part header and the content left where the parser put it:
type UploadFileIn struct {
File muzak.File `file:"file" doc:"A file read as an upload"`
}
The content is reachable through File.Open for streaming and through File.Bytes for the whole thing at once. Both are only valid while the handler runs: a file that spilled to a temporary file is removed once the handler returns, so anything that must outlive the request has to be copied out of it first, with File.Save or otherwise.
func (File) Bytes ¶
Bytes reads the whole file into memory. The result is a fresh slice sized to the file, so it stays valid after the request ends.
It returns ErrNoFile when no file was uploaded. Prefer File.Open for anything large enough that holding all of it at once matters.
func (File) Header ¶
func (f File) Header() textproto.MIMEHeader
Header returns the MIME headers of the part the file arrived in, for the occasional client that sends more than a filename and a content type. It returns nil when no file was uploaded.
func (File) Open ¶
Open returns a reader over the file's content, positioned at the start. The caller owns the returned file and must close it. Opening the same File more than once is allowed, and each reader has its own position.
It returns ErrNoFile when no file was uploaded.
func (File) Present ¶
Present reports whether a file was actually uploaded. It is only ever false for a field marked `required:"false"`, since a required file that is missing fails the request before the handler runs.
func (File) Save ¶
Save copies the file to the given path, creating or truncating it, and returns the number of bytes written.
The path is the caller's to choose, and File.Filename is not a safe source for one: a client may send "../../etc/passwd" or a name that means something to the local filesystem. Build the destination from a directory the server controls and a name the server generates.
type FrontendOptions ¶
type FrontendOptions struct {
// Dir is the directory holding the built frontend, or the subdirectory
// within FS when both are set.
Dir string
// FS serves the frontend from a filesystem rather than from disk. An
// [embed.FS] is the usual one, which makes the binary the whole
// deployment.
FS fs.FS
// Fallback is the file served, with 200, when a browser navigates to a
// path with no file behind it, so that a client-side router can take over.
// It is the entry document of a single page application, normally
// "index.html".
//
// It is used only for a GET or HEAD that asks for HTML, which is what a
// navigation does. A missing script, stylesheet or image still answers
// 404, because handing those an HTML document only turns a missing file
// into a confusing parse error.
Fallback string
// NotFound is the file served, with 404, when nothing else matched. It is
// the error page of a site whose pages are files, normally "404.html".
//
// It takes precedence over Fallback, and is used for any GET or HEAD.
NotFound string
// NoFallback serves a plain 404 for anything with no file behind it,
// turning off the automatic resolution described on [Router.Frontend].
NoFallback bool
// SkipCheck stops the frontend being verified when the application is
// built, for a directory that something else fills in later. A request
// arriving before that happens fails with 500 and the reason logged.
SkipCheck bool
}
FrontendOptions describes a built frontend to serve.
Exactly one source is required. Dir names a directory on disk, which is what a build step such as "npm run build" produces:
app.Frontend("/", muzak.FrontendOptions{Dir: "dist"})
FS serves the frontend from an io/fs.FS instead, which is how a frontend gets built into the binary. Setting both reads Dir as a subdirectory of FS, which is what the directory embed produces:
//go:embed all:dist
var assets embed.FS
app.Frontend("/", muzak.FrontendOptions{FS: assets, Dir: "dist"})
The zero value of the remaining fields resolves the fallback from what the build actually produced, which is what most frontends want; see Router.Frontend.
type Guard ¶
Guard is a dependency that validates or authorizes a request without producing a value.
A guard runs before the handler and before any value dependency declared after it. Returning a non-nil error aborts the request, and the error is mapped to a response exactly as one returned from a handler would be, so returning NewHTTPError(401, "unauthorized") is the idiomatic way to reject. Guards are attached to an application or a router with WithDependencies, mirroring router-level dependencies in FastAPI.
func RequireBearerToken ¶
RequireBearerToken returns a guard that rejects any request not carrying the given bearer token.
The comparison is constant-time, and a missing credential is answered with a WWW-Authenticate header so that a client knows which scheme to use. It is meant for the shared-secret case, such as an internal service or a webhook receiver; anything involving per-user credentials wants a value dependency that resolves the user instead.
app.Include(admin.NewRouter(),
muzak.WithPrefix("/admin"),
muzak.WithDependencies(muzak.RequireBearerToken(settings.AdminToken)),
)
func RequireHeaderToken ¶
RequireHeaderToken returns a guard that rejects any request whose named header does not carry the expected value, compared in constant time.
It covers the shared-secret headers that are not bearer credentials, such as the X-Token header in the FastAPI tutorial or a webhook signing key:
muzak.WithDependencies(muzak.RequireHeaderToken("X-Token", "coneofsilence"))
type HTML ¶
type HTML string
HTML is a response body written as an HTML document instead of as JSON.
A handler that returns it bypasses JSON encoding entirely: the string is written verbatim under a text/html content type, and the generated document describes the response as text/html rather than as a JSON schema. Everything else about the route is unchanged, so its status, headers and errors work exactly as they do for a JSON route.
r.Get("/", func(ctx *muzak.Context, _ muzak.Empty) (muzak.HTML, error) {
return muzak.HTML("<h1>Hello</h1>"), nil
})
The value is written as given. Muzak does not escape it, because it cannot tell markup the handler meant from text it did not: interpolating anything a client supplied is the handler's job to escape, with html/template or html.EscapeString.
type HTTPError ¶
type HTTPError struct {
// Status is the HTTP status code to write.
Status int
// Code is the machine-readable classifier. When empty, the code matching
// Status is used; see [CodeForStatus].
Code string
// Message is the client-visible explanation. Keep it free of internal
// detail, because it is transmitted verbatim.
Message string
// Details lists the individual problems behind the error, if any.
Details []ErrorDetail
// contains filtered or unexported fields
}
HTTPError is an error that carries an HTTP status code and a message that is safe to disclose to the client.
Returning an *HTTPError from a handler, a guard dependency or a value dependency short-circuits the request: nothing further down the chain runs, and the status, code and message become the response. Any other error type is deliberately opaque to the client; see StatusCoder.
func BadGateway ¶ added in v0.1.1
BadGateway returns a 502 classified CodeBadGateway, for a service this request depends on answering with something that cannot be used.
func BadRequest ¶ added in v0.1.1
BadRequest returns a 400 classified CodeBadRequest, for a request that could not be understood at all.
A request that was understood but asks for something the application will not do is better answered with UnprocessableEntity, and one whose fields failed validation is answered by Muzak itself before a handler runs.
func Conflict ¶ added in v0.1.1
Conflict returns a 409 classified CodeConflict, for a request that collides with the current state of the resource, such as a name already taken or an edit made against a version that has since moved.
func Forbidden ¶ added in v0.1.1
Forbidden returns a 403 classified CodeForbidden, for a request whose credentials were understood but are not enough.
Answering 404 instead is the right call when admitting the resource exists would itself disclose something; that is a decision to make deliberately.
func GatewayTimeout ¶ added in v0.1.1
GatewayTimeout returns a 504 classified CodeGatewayTimeout, for a service this request depends on not answering in time.
func Gone ¶ added in v0.1.1
Gone returns a 410 classified CodeGone, for a resource that existed and was deliberately removed. It differs from NotFound in saying so on purpose, which lets a client stop asking.
func InternalServerError ¶ added in v0.1.1
InternalServerError returns a 500 classified CodeInternalError.
Returning it is a deliberate act, so unlike an unexpected fault its message reaches the client. Keep that message free of internal state and put the real cause in HTTPError.Wrap, which is logged and never transmitted.
func MethodNotAllowed ¶ added in v0.1.1
MethodNotAllowed returns a 405 classified CodeMethodNotAllowed.
Muzak already answers an unrouted method with a 405 and an accurate Allow header, so reach for this only when a handler decides that for itself.
func NewHTTPError ¶
NewHTTPError returns an *HTTPError with the given status code and client-visible message, as in NewHTTPError(401, "unauthorized"). The error code is derived from the status unless HTTPError.WithCode overrides it.
Example ¶
ExampleNewHTTPError shows the error envelope a rejected request produces.
app := muzak.New(muzak.AppOptions{
LoggerOptions: muzak.LoggerOptions{Format: muzak.LogFormatNone},
})
app.Get("/items/{id}", func(ctx *muzak.Context, in struct {
ID string `path:"id"`
}) (UserOut, error) {
return UserOut{}, muzak.NewHTTPError(http.StatusNotFound, "Item not found")
})
rec := httptest.NewRecorder()
app.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/items/missing", nil))
// The request identifier varies per request, so only the error itself is
// printed here.
var envelope muzak.ErrorResponse
if err := json.Unmarshal(rec.Body.Bytes(), &envelope); err != nil {
fmt.Println("decode failed:", err)
return
}
fmt.Println(rec.Code)
fmt.Printf("%s %s\n", envelope.Error.Code, envelope.Error.Message)
Output: 404 not_found Item not found
func NewHTTPErrorf ¶
NewHTTPErrorf returns an *HTTPError whose message is built with fmt.Sprintf. Because the formatted result is sent to the client, avoid interpolating internal state such as file paths or driver errors; use HTTPError.Wrap for those instead, which keeps them server-side.
func NotAcceptable ¶ added in v0.1.1
NotAcceptable returns a 406 classified CodeNotAcceptable, for a request whose Accept header rules out every representation available.
func NotFound ¶ added in v0.1.1
NotFound returns a 404 classified CodeNotFound, for a resource that does not exist.
Example ¶
ExampleNotFound shows the named constructors, one per outcome an application reaches for, and what chaining onto one adds to the response.
app := muzak.New(muzak.AppOptions{
LoggerOptions: muzak.LoggerOptions{Format: muzak.LogFormatNone},
})
app.Get("/users/{name}", func(ctx *muzak.Context, in struct {
Name string `path:"name"`
}) (UserOut, error) {
// The cause is logged and never sent; the message is what the client
// reads. Passing "" instead would use the standard sentence for a 404.
return UserOut{}, muzak.NotFound("no user goes by that name").
Wrap(errors.New("sql: no rows in result set")).
WithDetails(muzak.ErrorDetail{Field: "name", Location: "path", Issue: "does not exist"})
})
rec := httptest.NewRecorder()
app.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/users/nobody", nil))
var envelope muzak.ErrorResponse
if err := json.Unmarshal(rec.Body.Bytes(), &envelope); err != nil {
fmt.Println("decode failed:", err)
return
}
fmt.Println(rec.Code)
fmt.Printf("%s %s\n", envelope.Error.Code, envelope.Error.Message)
fmt.Println(envelope.Error.Details[0].Field, envelope.Error.Details[0].Issue)
fmt.Println(strings.Contains(rec.Body.String(), "sql:"))
Output: 404 not_found no user goes by that name name does not exist false
func NotImplemented ¶ added in v0.1.1
NotImplemented returns a 501 classified CodeNotImplemented, for a route that exists but does not do anything yet.
func PayloadTooLarge ¶ added in v0.1.1
PayloadTooLarge returns a 413 classified CodePayloadTooLarge.
Muzak enforces AppOptions.MaxBodySize and the per-route limits itself, so this is for a bound a handler applies of its own.
func PaymentRequired ¶ added in v0.1.1
PaymentRequired returns a 402 classified CodePaymentRequired, for a request refused until an account is in good standing.
func PreconditionFailed ¶ added in v0.1.1
PreconditionFailed returns a 412 classified CodePreconditionFailed, for a conditional request whose precondition did not hold, as when an If-Match header names an entity tag the resource no longer carries.
func RequestTimeout ¶ added in v0.1.1
RequestTimeout returns a 408 classified CodeRequestTimeout, for a client that took too long to send its request.
func ServiceUnavailable ¶ added in v0.1.1
ServiceUnavailable returns a 503 classified CodeServiceUnavailable, for a dependency that is down or an application that is shedding load.
func TooManyRequests ¶ added in v0.1.1
TooManyRequests returns a 429 classified CodeTooManyRequests.
Muzak's own rate limiting answers with this status and the accompanying headers; this is for a quota an application counts for itself.
func Unauthorized ¶ added in v0.1.1
Unauthorized returns a 401 classified CodeUnauthorized, for a request that carried no usable credentials.
It is the answer to "I do not know who you are". When the caller is known but not allowed, use Forbidden.
func UnprocessableEntity ¶ added in v0.1.1
UnprocessableEntity returns a 422 classified CodeValidationError, for a request that was well-formed and understood but asks for something that cannot be done.
Muzak produces this status itself for a request whose fields failed validation, with one entry per field. Attach HTTPError.WithDetails to say which field is at fault here too, so that a client sees the same shape whichever produced it.
func UnsupportedMediaType ¶ added in v0.1.1
UnsupportedMediaType returns a 415 classified CodeUnsupportedMediaType, for a body sent under a media type the route cannot read.
func (*HTTPError) Error ¶
Error implements the error interface, rendering the status, message and cause in a form suited to server-side logs rather than to clients.
func (*HTTPError) ErrorCode ¶
ErrorCode returns the machine-readable classifier for the error, falling back to the one implied by its status.
func (*HTTPError) HTTPStatus ¶
HTTPStatus implements StatusCoder.
func (*HTTPError) Unwrap ¶
Unwrap returns the error passed to HTTPError.Wrap, letting errors.Is and errors.As see through to the underlying cause. It returns nil when no cause was attached.
func (*HTTPError) WithCode ¶
WithCode overrides the machine-readable classifier, for cases where the status alone is too coarse, such as telling "card_declined" from "insufficient_funds" behind the same 402. It returns e for inline use.
func (*HTTPError) WithDetails ¶
func (e *HTTPError) WithDetails(details ...ErrorDetail) *HTTPError
WithDetails attaches per-field details to the error and returns e for inline use. Details appear in the "details" member of the response, so keep their text free of internal state.
type Handler ¶
Handler is the shape every Muzak route handler takes.
In is the fully typed request: its fields are bound from the path, query string, headers and JSON body according to their struct tags, and it is Empty for routes that read nothing. Out is the response body, serialized exactly as returned: there is no wrapper type and no runtime filtering, so the handler's return type is the response model and the compiler enforces it. Returning a non-nil error abandons Out and produces an error response instead; see HTTPError for choosing the status.
type Info ¶
type Info struct {
// Title names the API.
Title string `json:"title"`
// Version is the API's version.
Version string `json:"version"`
// Description is a long-form description of the API.
Description string `json:"description,omitzero"`
// TermsOfService is a URL to the terms of service.
TermsOfService string `json:"termsOfService,omitzero"`
// Contact identifies who to approach about the API.
Contact *Contact `json:"contact,omitzero"`
// License states the licence the API is offered under.
License *License `json:"license,omitzero"`
}
Info carries the metadata describing an API.
type License ¶
type License struct {
// Name is the licence name, such as "Apache 2.0".
Name string `json:"name"`
// Identifier is the SPDX identifier, such as "Apache-2.0". It is mutually
// exclusive with URL in OpenAPI 3.1.
Identifier string `json:"identifier,omitzero"`
// URL points at the licence text.
URL string `json:"url,omitzero"`
}
License states the licence an API is offered under.
type Lifecycle ¶
type Lifecycle interface {
// Name identifies the component in start-up and shutdown logs. Keep it
// short and lowercase, such as "redis" or "database".
Name() string
// Start acquires the resource. It is called once, before the server
// begins accepting requests, and must return only when the resource is
// ready to use. The context is cancelled when a sibling component fails,
// so a long-running dial should honour it and give up.
Start(ctx context.Context) error
// Stop releases the resource. It is called once, after the HTTP server
// has finished draining in-flight requests, and is called even for a
// start-up that failed part way, for every component that did start.
Stop(ctx context.Context) error
}
Lifecycle is implemented by a resource that must be opened before the server accepts traffic and closed after it stops.
A database pool, a cache client, a message consumer and a loaded model all fit the same shape: something expensive to create, shared by every request, and needing an orderly release. Publish the resource with WithSingleton and, if the value implements Lifecycle, Muzak takes care of the rest:
app := muzak.New(muzak.AppOptions{Title: "Shop"},
muzak.WithSingleton(redisClient), // *Redis implements Lifecycle
muzak.WithSingleton(db), // *DB implements Lifecycle
)
Use NewLifecycle when a resource does not warrant its own type.
func NewLifecycle ¶
NewLifecycle builds a Lifecycle from a name and a pair of functions, for resources that do not need a type of their own.
Either function may be nil, which makes that half a no-op. This is the lightweight counterpart to implementing the interface:
models := map[string]func(float64) float64{}
muzak.NewLifecycle("ml-model",
func(ctx context.Context) error {
models["answer"] = func(x float64) float64 { return x * 42 }
return nil
},
func(ctx context.Context) error {
clear(models)
return nil
},
)
type LogFormat ¶
type LogFormat int
LogFormat selects how log records are rendered.
const ( // LogFormatAuto writes the human-readable console format when the output // is an interactive terminal and JSON otherwise, which gives readable // local development and machine-parseable production logs with no // configuration. LogFormatAuto LogFormat = iota // LogFormatConsole always writes the aligned, optionally coloured console // format. LogFormatConsole // LogFormatJSON always writes one JSON object per record. LogFormatJSON // LogFormatNone discards every record. It is the fastest option and is // what tests use to keep output clean. LogFormatNone )
type LoggerOptions ¶
type LoggerOptions struct {
// Level is the minimum level to emit. It defaults to slog.LevelInfo. Pass
// a *slog.LevelVar to be able to change it while the process runs.
Level slog.Leveler
// Format selects the rendering. It defaults to [LogFormatAuto].
Format LogFormat
// Output is where records are written. It defaults to os.Stderr, which
// keeps logs out of a program's data output.
Output io.Writer
// Color forces ANSI colour on or off for the console format. When nil,
// colour is used only if the output is a terminal and the NO_COLOR
// environment variable is unset.
Color *bool
// TimeFormat is the layout used for timestamps in the console format. It
// defaults to "15:04:05.000", which is compact enough to scan and precise
// enough to order events within a request.
TimeFormat string
// ScopeWidth is the column width reserved for the bracketed scope,
// defaulting to 16. Records with a longer scope push the message right
// rather than being truncated.
ScopeWidth int
// AddSource records the source file and line of the call site. It costs a
// stack walk per record, so it defaults to off.
AddSource bool
// RedactKeys replaces [DefaultRedactedKeys] when non-nil. Pass an empty,
// non-nil slice to disable redaction, which is only appropriate when
// nothing sensitive can reach the logger.
RedactKeys []string
// ShortRequestID truncates request identifiers to their first eight
// characters in the console format, which keeps lines narrow while
// remaining unambiguous in a development session. It never applies to
// JSON output, where the full identifier is always written. It defaults
// to true.
ShortRequestID *bool
}
LoggerOptions configures the logger returned by NewLogger.
The zero value is usable and yields an info-level logger that writes the console format to standard error when attached to a terminal and JSON otherwise, with DefaultRedactedKeys redacted.
type MediaType ¶
type MediaType struct {
// Schema describes the payload.
Schema *Schema `json:"schema"`
}
MediaType pairs a media type with the schema of its payload.
type MemoryRateLimitOptions ¶
type MemoryRateLimitOptions struct {
// MaxEntries is how many counters the storage holds at once, defaulting to
// [DefaultRateLimitMaxEntries]. Once it is full, admitting a counter
// discards the one closest to expiring, which is the one whose loss costs
// least. A negative value removes the bound, which is only appropriate
// when the set of keys is known to be small and closed.
MaxEntries int
// SweepInterval is how often expired counters are discarded, defaulting to
// [DefaultRateLimitSweepInterval]. Sweeping is housekeeping rather than
// safety, since MaxEntries is what actually bounds the table, and it
// happens only while the storage is started. A negative value turns it
// off.
SweepInterval time.Duration
}
MemoryRateLimitOptions configures NewMemoryRateLimitStorage.
type MemoryRateLimitStorage ¶
type MemoryRateLimitStorage struct {
// contains filtered or unexported fields
}
MemoryRateLimitStorage counts requests in the process that serves them.
It is what an application that names no storage of its own gets, and it is the right answer for a single process. It is the wrong answer for several: counters held in one process are not shared with the next, so a limit of a hundred a minute becomes a hundred a minute per process. Reach for a storage backed by something the processes share once there is more than one.
The table is bounded in two ways, because it is keyed by something the client influences and an unbounded one would be a memory leak with a name. Expired counters are swept periodically, and a table at MemoryRateLimitOptions.MaxEntries discards the counter closest to expiring to make room for a new one.
It implements Lifecycle, so an application that uses it starts and stops it as part of its own start-up and shutdown. Stopping releases every counter it holds, so no key outlives the server that was counting it.
func NewMemoryRateLimitStorage ¶
func NewMemoryRateLimitStorage(opts MemoryRateLimitOptions) *MemoryRateLimitStorage
NewMemoryRateLimitStorage returns a rate limit storage that counts in memory.
app := muzak.New(muzak.AppOptions{Title: "Shop"},
muzak.WithRateLimit(muzak.RateLimitOptions{
Storage: muzak.NewMemoryRateLimitStorage(muzak.MemoryRateLimitOptions{MaxEntries: 10_000}),
Quotas: []muzak.Quota{{Name: "default", Window: time.Minute, Limit: 60}},
}),
)
Naming one is only necessary to change its bounds; a policy that leaves RateLimitOptions.Storage unset is given one of these with its defaults.
func (*MemoryRateLimitStorage) Increment ¶
func (s *MemoryRateLimitStorage) Increment(_ context.Context, quota, key string, window time.Duration) (int, time.Duration, error)
Increment implements RateLimitStorage.
A counter whose window has run out is reused rather than replaced, so a key that keeps being seen does not churn the table, and the window is only ever set when a counter starts: extending it on every request would produce a limit that never resets.
func (*MemoryRateLimitStorage) Len ¶
func (s *MemoryRateLimitStorage) Len() int
Len reports how many counters the storage is holding, which is what a metric or a test asking whether anything is accumulating wants.
func (*MemoryRateLimitStorage) Name ¶
func (s *MemoryRateLimitStorage) Name() string
Name implements Lifecycle.
func (*MemoryRateLimitStorage) Start ¶
func (s *MemoryRateLimitStorage) Start(context.Context) error
Start begins sweeping expired counters. It implements Lifecycle and is idempotent, so a storage that has been registered twice is still swept by exactly one goroutine.
The context is deliberately not watched. A lifecycle Start is given a context that is cancelled as soon as start-up finishes, which is what lets a failing component abandon its siblings, so a sweeper that honoured it would stop the moment the server began serving.
func (*MemoryRateLimitStorage) Stop ¶
func (s *MemoryRateLimitStorage) Stop(context.Context) error
Stop ends the sweeper and discards every counter held. It implements Lifecycle and is idempotent.
Releasing the counters matters beyond the memory: a key is derived from whatever the tracker read, an address, a user identifier or an API key, and none of that should outlive the server that was counting it.
type Middleware ¶
Middleware wraps an http.Handler to run logic around every request.
Middleware operates below Muzak's typed layer, on the raw net/http types, which is what lets any middleware written for the standard library be used unchanged. Install it with App.Use; the first one installed is the outermost.
func AccessLog ¶
func AccessLog(logger *slog.Logger, opts AccessLogOptions) Middleware
AccessLog records one line per request with its method, path, status, duration and request identifier.
Only fixed, non-sensitive fields are recorded. Query strings, request bodies and headers are deliberately omitted, because each of them routinely carries credentials or personal data that should not be duplicated into a log store.
func CORS ¶
func CORS(opts CORSOptions) (Middleware, error)
CORS applies a cross-origin resource sharing policy.
The policy denies everything unless explicitly configured: with no allowed origins and no CORSOptions.AllowOriginFunc, no CORS headers are ever emitted and browsers refuse every cross-origin read. Combining a wildcard origin with credentials is refused as a configuration error, because browsers reject that pairing anyway and accepting it here would suggest it works.
The error returned describes a policy that cannot be served safely. A valid policy returns a nil error.
func Compress ¶
func Compress(opts CompressionOptions) Middleware
Compress returns middleware that compresses response bodies the client has said it can decode.
Encoding is negotiated from Accept-Encoding, preferring gzip over deflate and honouring an explicit refusal such as "gzip;q=0". A response is left alone when the client asked for neither encoding, when the handler encoded it already, when its media type is not one the policy compresses, when it carries no body, and when it is smaller than CompressionOptions.MinSize. Vary is set on every response either way, so a cache cannot hand a compressed body to a client that cannot read it.
Install it with App.Use:
app.Use(muzak.Compress(muzak.CompressionOptions{}))
Compression and secrecy interact badly. When a response mixes a secret with something the client controls, its compressed length leaks how much the two have in common, which is what the BREACH attack recovers a token from over many requests. Muzak's own responses do not mix the two, but a handler that reflects a query parameter back alongside a CSRF token does. Where that is possible, leave compression off for the route or stop reflecting the input.
func Recovery ¶
func Recovery(logger *slog.Logger) Middleware
Recovery catches a panic escaping any later handler, logs it with its stack trace, and returns a generic 500.
Nothing derived from the panic value reaches the client: a panic often carries a pointer address, a SQL fragment or a file path, and a stack trace maps out the server's internals. The full detail is written to the logger instead, correlated with the request identifier so it can be matched to the response the client saw.
http.ErrAbortHandler is re-panicked rather than swallowed, because net/http uses it to abort a response deliberately.
func RequestID ¶
func RequestID(opts RequestIDOptions) Middleware
RequestID assigns every request an identifier, records it in the request context, and echoes it in the HeaderRequestID response header.
The identifier is what ties a client's error response to the server-side log entry for the same request: DefaultErrorRenderer copies it into the "request_id" member of the error envelope, and the access log records it under RequestIDKey.
func SecurityHeaders ¶
func SecurityHeaders() Middleware
SecurityHeaders sets conservative response headers on every response.
It sets X-Content-Type-Options to stop a browser from guessing a content type other than the one declared, X-Frame-Options to prevent framing, and a referrer policy that keeps paths and query strings from leaking to third parties. Existing values are never overwritten, so a handler or a later middleware can opt out per response.
type NumberField ¶
type NumberField interface {
~int | ~int8 | ~int16 | ~int32 | ~int64 |
~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 |
~float32 | ~float64 |
*int | *int8 | *int16 | *int32 | *int64 |
*uint | *uint8 | *uint16 | *uint32 | *uint64 |
*float32 | *float64
}
NumberField matches a numeric field or an optional pointer to one.
type OpenAPIOptions ¶
type OpenAPIOptions struct {
// Title names the API. It defaults to "Muzak API".
Title string
// Version is the API's own version, not the OpenAPI version. It defaults
// to "0.1.0".
Version string
// Description is a long-form description of the API, rendered as
// CommonMark by documentation tools.
Description string
// TermsOfService is a URL to the terms the API is offered under.
TermsOfService string
// Contact identifies who to approach about the API.
Contact *Contact
// License states the licence the API is offered under.
License *License
// Servers lists the base URLs the API is served from. When empty,
// documentation tools treat the document's own origin as the server.
Servers []Server
// Tags describes the groups operations are sorted into, and decides the
// order they are presented in. A route joins a group by naming it with
// [WithTags]; describing it here is what gives the group a sentence of
// explanation and a place in the running order:
//
// Tags: []muzak.Tag{
// {Name: "items", Description: "Everything the catalogue holds."},
// {Name: "admin", Description: "Operations that need a staff token."},
// }
//
// A tag no route carries is left out of the document, since it would
// document an empty group, and a tag some route carries but nothing here
// describes still appears, after the described ones, in the order the
// routes named it.
Tags []Tag
}
OpenAPIOptions describes the API in the generated document.
It is embedded in AppOptions, so its fields can be set inline:
muzak.AppOptions{Title: "Bigger Applications Example", Version: "1.0.0"}
type Operation ¶
type Operation struct {
// Tags group the operation in the documentation.
Tags []string `json:"tags,omitzero"`
// Summary is the one-line description.
Summary string `json:"summary,omitzero"`
// Description is the long-form description.
Description string `json:"description,omitzero"`
// OperationID uniquely identifies the operation.
OperationID string `json:"operationId"`
// Parameters describes the path, query, header and cookie parameters.
Parameters []Parameter `json:"parameters,omitzero"`
// RequestBody describes the JSON body, when the operation takes one.
RequestBody *RequestBody `json:"requestBody,omitzero"`
// Responses maps status codes to the responses they carry.
Responses map[string]*Response `json:"responses"`
// Deprecated marks the operation as no longer recommended.
Deprecated bool `json:"deprecated,omitzero"`
}
Operation describes a single method at a single path.
type Parameter ¶
type Parameter struct {
// Name is the parameter name as it appears in the request.
Name string `json:"name"`
// In is where the parameter is read from: "path", "query", "header" or
// "cookie".
In string `json:"in"`
// Description explains the parameter, taken from its doc struct tag.
Description string `json:"description,omitzero"`
// Required reports whether the request must supply the parameter.
Required bool `json:"required,omitzero"`
// Schema describes the accepted values.
Schema *Schema `json:"schema"`
}
Parameter describes one path, query, header or cookie parameter.
type PathItem ¶
type PathItem struct {
// Get through Options are the operations registered for each method.
Get *Operation `json:"get,omitzero"`
Put *Operation `json:"put,omitzero"`
Post *Operation `json:"post,omitzero"`
Delete *Operation `json:"delete,omitzero"`
Patch *Operation `json:"patch,omitzero"`
Head *Operation `json:"head,omitzero"`
Options *Operation `json:"options,omitzero"`
}
PathItem lists the operations available at one path template.
type Quota ¶
type Quota struct {
// Name identifies the quota. It is the namespace its counters are stored
// under, so two quotas that share a name share a budget and must agree on
// their window and limit; the application refuses to build when they do
// not. It must be a valid HTTP token, because it is reported to clients.
Name string
// Window is how long one counting period lasts. It must be positive.
Window time.Duration
// Limit is how many requests are allowed within one window. It must be
// positive; a quota that allows nothing is a route that should not be
// registered.
Limit int
}
Quota is one rate limit: how many requests a client may make in a window.
Several quotas describe a policy together, which is what tells a burst from sustained abuse. Three requests a second is generous for a person clicking and impossible for a script, while a hundred a minute is the reverse, so a policy that means "quick but not tireless" needs both:
Quotas: []muzak.Quota{
{Name: "short", Window: time.Second, Limit: 3},
{Name: "medium", Window: 10 * time.Second, Limit: 20},
{Name: "long", Window: time.Minute, Limit: 100},
}
Every quota in a policy is counted for every request, so a client that overruns the short window still accrues against the long one and cannot escape a sustained limit by pausing between bursts.
type RateLimitOptions ¶
type RateLimitOptions struct {
// Quotas are the limits enforced, all of them, for every request. An empty
// list turns rate limiting off, which is the default. A narrower scope
// that declares quotas replaces the inherited list rather than adding to
// it, so a route states the whole policy it wants.
Quotas []Quota
// Storage counts the requests, defaulting to a process-local storage
// equivalent to [NewMemoryRateLimitStorage] with its own defaults. One
// storage is created for the whole application, so routes that do not name
// their own share its counters.
//
// A process-local storage means a per-process limit, which is a different
// limit from the one intended as soon as there are two processes. Name a
// shared storage for anything running more than once.
Storage RateLimitStorage
// Tracker decides whose budget a request is spent from, defaulting to
// [IPTracker].
Tracker RateLimitTracker
// FailOpen serves a request that the storage could not count.
//
// By default a storage that cannot answer refuses the request with 503,
// because a limiter that cannot count is a limiter that is not enforcing
// anything, and an attacker who can reach the storage can choose the
// moment it stops answering. Setting FailOpen trades that for
// availability: a storage outage lets traffic through unmetered instead of
// turning into an outage of its own. The failure is logged either way.
FailOpen bool
// DisableHeaders stops the RateLimit response headers from being set.
// They are set by default, because a client that can see its own budget is
// a client that can stay inside it.
DisableHeaders bool
// AfterDependencies counts the request after the route's guards and value
// dependencies have run, rather than before, so that a tracker can key on
// an identity a dependency produced:
//
// func UserOrIPTracker(ctx *muzak.Context) (string, error) {
// if user, ok := muzak.TryFrom[CurrentUser](ctx); ok {
// return "user:" + user.ID, nil
// }
// return "ip:" + ctx.ClientIP(), nil
// }
//
// It is off by default, and the reason is worth understanding before
// turning it on: a request rejected by a guard never reaches the limiter,
// so a route that defers the count does not limit failed authentication at
// all. That is exactly the traffic a login route needs to limit, so leave
// it off there and let the tracker fall back to the address.
AfterDependencies bool
}
RateLimitOptions configures rate limiting.
Rate limiting is off until a policy declares a quota. It can then be set application-wide through AppOptions.RateLimit and narrowed for a router or a single route with WithRateLimit or RateLimit, and turned off again for one route with SkipRateLimit. Layering works field by field, so a route that replaces the quotas keeps the application's storage and tracker.
type RateLimitStorage ¶
type RateLimitStorage interface {
// Increment counts one request against a quota for one client and returns
// the new count and how long the current window has left to run.
//
// The count includes the request being counted, so the first request in a
// window returns one. The key is opaque and may contain any bytes; it is
// derived from client-supplied data and must never be logged, because it
// routinely carries an API key or a user identifier.
//
// The window is how long a newly created counter should live. An
// implementation must not extend the life of a counter that already
// exists, because a limit whose window restarts on every request is a
// limit that never resets.
Increment(ctx context.Context, quota, key string, window time.Duration) (count int, reset time.Duration, err error)
}
RateLimitStorage counts requests.
It is the whole of what the limiter needs from the outside world, and it is an interface because where the counters live is an operational decision: a single process is well served by the storage NewMemoryRateLimitStorage returns, while several processes behind a load balancer need something they share, which is usually whatever they already run.
An implementation that talks to a shared store should do the increment and the expiry in one round trip, so that two requests arriving together cannot both create the window:
func (s *RedisRateLimitStorage) Increment(ctx context.Context, quota, key string, window time.Duration) (int, time.Duration, error) {
res, err := s.script.Run(ctx, s.client, []string{"ratelimit:" + quota + ":" + key}, window.Milliseconds()).Result()
// INCR, then PEXPIRE when the counter is new, then PTTL.
}
If the implementation also satisfies Lifecycle, the application starts it before serving and stops it after draining, so a pool or a sweeper needs no separate registration.
type RateLimitTracker ¶
RateLimitTracker decides whose budget a request is spent from.
Returning an error abandons the request, and the error becomes the response exactly as one returned from a handler would, so a tracker that requires a credential can insist on one:
func APIKeyTracker(ctx *muzak.Context) (string, error) {
key := ctx.Header("X-API-Key")
if key == "" {
return "", muzak.NewHTTPError(http.StatusUnauthorized, "an API key is required")
}
return "apikey:" + key, nil
}
The key must not be empty. Prefix keys that come from different sources differently, as the example above does, so that a user identifier and an address can never collide into one budget.
func IPPrefixTracker ¶
func IPPrefixTracker(ipv4Bits, ipv6Bits int) RateLimitTracker
IPPrefixTracker keys a rate limit on a prefix of the client's address rather than the whole of it.
IPTracker keys on the exact address, which stops fitting the address families it counts as soon as one of them is cheap to change: an IPv6 /64 is the block size most providers hand out, so a client holding one can present a different address on every request while never leaving a range only they hold, and each address is a fresh budget to IPTracker. Keying on a shorter prefix instead puts every address in that range back under one budget. ipv4Bits and ipv6Bits are the prefix lengths kept for each family; 32 and 64 keep IPv4 addresses exact while collapsing an IPv6 source down to the allocation it actually came from:
muzak.RateLimitOptions{Tracker: muzak.IPPrefixTracker(32, 64)}
It panics if either length is out of range for its family (0 to 32 for IPv4, 0 to 128 for IPv6), which is a mistake worth catching where the tracker is built rather than on the first request that reaches it.
type RequestBody ¶
type RequestBody struct {
// Description explains the body.
Description string `json:"description,omitzero"`
// Required reports whether the body must be present.
Required bool `json:"required,omitzero"`
// Content maps media types to their schemas.
Content map[string]MediaType `json:"content"`
}
RequestBody describes the body an operation accepts.
type RequestIDOptions ¶
type RequestIDOptions struct {
// TrustInboundHeader accepts a client-supplied X-Request-Id instead of
// generating one.
//
// It is off by default because an attacker-controlled identifier is an
// attacker-controlled log field, which invites log forging and, in a log
// store that indexes it, cross-tenant correlation. When enabled, an
// inbound value is honoured only if it parses as a UUID, so it can never
// carry newlines or control characters into a log line.
TrustInboundHeader bool
}
RequestIDOptions configures RequestID.
type Response ¶
type Response struct {
// Description explains when this response occurs. OpenAPI requires it.
Description string `json:"description"`
// Content maps media types to their schemas, and is absent for responses
// with no body.
Content map[string]MediaType `json:"content,omitzero"`
}
Response describes one outcome of an operation.
type Route ¶
type Route struct {
// Method is the uppercase HTTP method the route answers.
Method string
// Path is the full path template, including every prefix contributed by
// the routers the route was included through.
Path string
// Summary is the one-line description shown in the documentation.
Summary string
// Description is the long-form description shown in the documentation.
Description string
// OperationID uniquely identifies the operation in the OpenAPI document.
OperationID string
// Tags group the operation in the documentation.
Tags []string
// Deprecated marks the operation as deprecated in the OpenAPI document.
Deprecated bool
// Hidden omits the operation from the OpenAPI document.
Hidden bool
// Status is the status code written when the handler succeeds without
// calling [Context.SetStatus].
Status int
// Versions lists the versions this route answers, resolved from
// [WithVersion] and [VersioningOptions.DefaultVersion]. It is empty for
// an application that never enables versioning, and also, deliberately,
// for a route that answers no request at all because versioning is
// enabled but neither it nor anything it is declared under named a
// version; see [WithVersion].
Versions []Version
// contains filtered or unexported fields
}
Route is a single registered operation: one HTTP method at one path template, with the handler, dependencies and documentation attached to it.
A *Route is returned by the registration methods so that it can be inspected or referenced later. Its fields are filled in when the application is built and must be treated as read-only from that point on.
type RouteOption ¶
type RouteOption interface {
// contains filtered or unexported methods
}
RouteOption configures a single route at registration time.
func Description ¶
func Description(description string) RouteOption
Description sets the long-form description of the operation. The generated OpenAPI document carries it verbatim, and CommonMark is rendered by the documentation UI.
func OperationID ¶
func OperationID(id string) RouteOption
OperationID sets the operation's unique identifier in the OpenAPI document, which client generators use to name the method they emit. When unset, Muzak derives one from the method and path. Identifiers must be unique across the application; a collision is reported when the application is built.
func SkipValidation ¶
func SkipValidation() RouteOption
SkipValidation stops a route from running its input model's Validate method.
Validation is otherwise automatic: a model that declares rules has them applied, with no option to remember and so no way to leave a model unvalidated by forgetting one. Reach for this only where a route must accept input the model itself would reject, such as an administrative endpoint that repairs bad data.
func Status ¶
func Status(code int) RouteOption
Status declares the status code written when the handler returns without an error. It defaults to 200, and 201 is the conventional choice for a creating route. A handler that must vary its status at runtime calls Context.SetStatus instead; the two are orthogonal, and the imperative call wins when both are used.
Example ¶
ExampleStatus shows the two ways a status is chosen: declared once when it never changes, and set imperatively when it depends on the request.
type CreateBody struct {
Name string `json:"name"`
Async bool `json:"async,omitzero"`
}
type ItemOut struct {
ID string `json:"id"`
Name string `json:"name"`
}
app := muzak.New(muzak.AppOptions{
LoggerOptions: muzak.LoggerOptions{Format: muzak.LogFormatNone},
})
app.Post("/items/", func(ctx *muzak.Context, in CreateBody) (ItemOut, error) {
if in.Async {
ctx.SetStatus(http.StatusAccepted)
}
return ItemOut{ID: "42", Name: in.Name}, nil
}, muzak.Status(http.StatusCreated))
send := func(body string) {
req := httptest.NewRequest(http.MethodPost, "/items/", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
app.ServeHTTP(rec, req)
fmt.Println(rec.Code, rec.Body.String())
}
send(`{"name":"Portal Gun"}`)
send(`{"name":"Portal Gun","async":true}`)
Output: 201 {"id":"42","name":"Portal Gun"} 202 {"id":"42","name":"Portal Gun"}
func Summary ¶
func Summary(summary string) RouteOption
Summary sets the short, one-line description of the operation shown beside it in the generated documentation.
type Router ¶
type Router struct {
// contains filtered or unexported fields
}
Router groups related routes under a shared prefix, tag set and dependency chain.
Routers are built independently and composed with Router.Include, which mirrors FastAPI's APIRouter and include_router: a package exports a NewRouter function returning its own routes, and the application decides where to mount them and what guards apply.
func NewRouter() *muzak.Router {
r := muzak.NewRouter(muzak.WithTags("users"))
r.Get("/users/me", currentUser)
return r
}
A Router is not safe for concurrent registration, which is not a limitation in practice: routes are declared during start-up from a single goroutine and only read afterwards.
func NewRouter ¶
func NewRouter(opts ...RouterOption) *Router
NewRouter returns a router configured by the given options.
Options that apply to a whole subtree, namely WithTags, WithDependencies, Needs, WithResponseDoc and WithResponseModel, take effect for every route registered on this router and on any router included into it.
func (*Router) Delete ¶
Delete registers a handler for DELETE requests at the given path template. It behaves exactly like Router.Get except for the method.
func (*Router) Frontend ¶
func (r *Router) Frontend(mountPath string, opts FrontendOptions)
Frontend serves a built frontend at path.
It is for the static output of a frontend build, which is what React, Vue, Svelte, Angular, Solid, Astro and the rest produce. Nothing is rendered on the server, and nothing is built here: this serves files that already exist.
app.Frontend("/", muzak.FrontendOptions{Dir: "dist"})
Routes win. A request is matched against every registered route first, and reaches the frontend only when none of them answered, so mounting a frontend at "/" cannot shadow an API. Middleware still applies, as do the guards of the routers the frontend was registered under, which is what lets a frontend sit behind the same authentication as everything else.
A request for a path with no file behind it falls back to one, resolved from the build unless the options say otherwise: a 404.html in the frontend's root is served with 404, and failing that an index.html is served with 200 for a browser navigation, which is what a client-side router needs to take over. Set FrontendOptions.NoFallback for a plain 404 instead.
Mounting under a prefix works the way everything else does, through the router the frontend is registered on:
ui := muzak.NewRouter()
ui.Frontend("/", muzak.FrontendOptions{Dir: "dist"})
app.Include(ui, muzak.WithPrefix("/app"))
Problems with the mount, including a directory that does not exist, are reported when the application is built rather than on the first request.
func (*Router) Get ¶
Get registers a handler for GET requests at the given path template.
The input and output types are inferred from the handler literal, so the type arguments are never written at the call site:
r.Get("/users/{username}", func(ctx *muzak.Context, in Params) (UserOut, error) {
return UserOut{Username: in.Username}, nil
})
The path is relative to whatever prefixes the router is eventually mounted under, and may contain "{name}" parameters and one trailing "{name...}" wildcard. Registration errors (an unbindable input type, a duplicate route, or a path parameter no field binds) are collected and reported when the application is built.
Example ¶
ExampleRouter_Get registers a route whose input is bound from the path. The type arguments are inferred from the handler literal, so they never appear at the call site.
r := muzak.NewRouter(muzak.WithTags("users"))
r.Get("/users/{username}", func(ctx *muzak.Context, in Params) (UserOut, error) {
return UserOut{Username: in.Username}, nil
})
app := muzak.New(muzak.AppOptions{
LoggerOptions: muzak.LoggerOptions{Format: muzak.LogFormatNone},
})
app.Include(r)
rec := httptest.NewRecorder()
app.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/users/rick", nil))
fmt.Println(rec.Body.String())
Output: {"username":"rick"}
func (*Router) Handle ¶
func (r *Router) Handle[In, Out any](method, path string, h Handler[In, Out], opts ...RouteOption) *Route
Handle registers a handler for an arbitrary HTTP method, for the methods the named helpers do not cover. The method is upper-cased before use.
It is also how an OPTIONS route is registered. There is no Router.Options method, because App.Options applies configuration to an application and the two would shadow each other on an App; OPTIONS is answered automatically with an Allow header in any case, so registering one is only necessary to replace that behaviour:
r.Handle(http.MethodOptions, "/things", describeThings)
func (*Router) Head ¶
Head registers a handler for HEAD requests at the given path template. Registering one is rarely necessary: a GET route answers HEAD automatically, running the handler and discarding the body, and an explicit HEAD route takes precedence over that.
func (*Router) Include ¶
func (r *Router) Include(child *Router, opts ...RouterOption)
Include mounts a child router into this one, optionally adjusting it with options supplied at the point of inclusion.
The child keeps its own configuration and inherits everything from the parent, with the include's options layered in between:
app.Include(admin.NewRouter(),
muzak.WithPrefix("/admin"),
muzak.WithTags("admin"),
muzak.WithDependencies(GetTokenHeader),
muzak.WithResponseDoc(418, "I'm a teapot"),
)
Guards contributed by the parent run before those contributed here, which run before the child's own. A router may be included only once; including it twice is reported as an error when the application is built, because a route has a single resolved path.
func (*Router) Patch ¶
Patch registers a handler for PATCH requests at the given path template. It behaves exactly like Router.Get except for the method.
func (*Router) Post ¶
Post registers a handler for POST requests at the given path template. It behaves exactly like Router.Get except for the method; see that method for how In and Out are inferred and bound.
func (*Router) Put ¶
Put registers a handler for PUT requests at the given path template. It behaves exactly like Router.Get except for the method.
func (*Router) Routes ¶
Routes returns the routes registered directly on this router, excluding those of any included router. Before the application is built the paths are the ones supplied at registration, without inherited prefixes.
func (*Router) SSE ¶
func (r *Router) SSE[In, Out any](path string, h SSEHandler[In, Out], opts ...RouteOption) *Route
SSE registers a server-sent events handler for GET requests at the given path template.
The request is an ordinary one, so everything that applies to a route applies here: middleware runs, guards run, dependencies resolve, and the input struct is bound and validated before a single byte of the stream is written. A request that fails any of that is answered with the usual JSON error and never becomes a stream at all.
type StreamIn struct {
Room string `path:"room"`
}
r.SSE("/rooms/{room}/stream", func(ctx *muzak.Context, in StreamIn, stream *muzak.SSEStream[MessageOut]) error {
for message := range room(in.Room).Messages(stream.Context()) {
if err := stream.Send(message); err != nil {
return err
}
}
return nil
})
The stream is closed when the handler returns, so a handler owns it for as long as it runs and never has to arrange the teardown itself. Watch SSEStream.Context for the client going away or the server shutting down; it is cancelled for both, and every send after it reports ErrSSEStreamEnded, so a handler that only sends ends on its own.
The response header is written before the handler is called, which is what lets a client see the stream open immediately. A failure that should be answered with a status belongs in a guard or a dependency, where there is still a response to write it into.
Use Router.SSEHandle for a stream reached by a method other than GET, and WithSSE to configure the stream itself.
func (*Router) SSEHandle ¶
func (r *Router) SSEHandle[In, Out any](method, path string, h SSEHandler[In, Out], opts ...RouteOption) *Route
SSEHandle registers a server-sent events handler for an arbitrary HTTP method, which is what a protocol that streams its answer to a posted document needs:
r.SSEHandle(http.MethodPost, "/chat/stream", streamChat)
Unlike a WebSocket handshake, the request may carry a body, so the input type binds one exactly as it would for any other route. The method is upper-cased before use; everything else behaves as Router.SSE.
func (*Router) Static ¶
func (r *Router) Static(mountPath string, opts StaticOptions)
Static serves a directory of files at path.
app.Static("/static", muzak.StaticOptions{Dir: "static"})
It is the same machinery Router.Frontend is built on, without the part that makes a frontend work: nothing stands in for a path with no file behind it, so a miss is a 404 and stays one. Reach for it to publish assets, and for Router.Frontend to serve an application whose routing happens in the browser.
Everything else matches a frontend mount. Routes are matched first, the guards of the router apply, a directory is never listed, a symbolic link cannot lead out of the directory, and a method other than GET or HEAD on a file that exists is answered 405 rather than served.
func (*Router) WS ¶
WS registers a WebSocket handler at the given path template.
The handshake is an ordinary GET, so everything that applies to a route applies here: middleware runs, guards run, dependencies resolve, and the input struct is bound and validated before a single byte is upgraded. A request that fails any of that is answered with the usual JSON error and never becomes a connection at all.
type WSItemIn struct {
ItemID string `path:"item_id"`
Q *int `query:"q"`
}
r.WS("/items/{item_id}/ws", func(ctx *muzak.Context, in WSItemIn, conn *muzak.WSConn) error {
token := muzak.From[SessionOrToken](ctx)
for {
message, err := conn.ReadText(ctx.Context())
if err != nil {
return nil
}
if err := conn.WriteText(ctx.Context(), "you said: "+message); err != nil {
return err
}
_ = token
}
}, muzak.Needs(GetSessionOrToken))
The connection is closed when the handler returns, so a handler owns its connection for as long as it runs and never has to arrange for the teardown itself. The Context belongs to the request and must not outlive the handler either; pass Context.Context to anything that will.
Because a handshake carries no body, an input type with a body field is a registration error rather than a request that mysteriously never arrives. Configure the connection itself with WithWebSocket.
type RouterOption ¶
type RouterOption interface {
// contains filtered or unexported methods
}
RouterOption configures a router, an application, or the point at which one router is included into another.
func WithPrefix ¶
func WithPrefix(prefix string) RouterOption
WithPrefix mounts a router under a path prefix.
The prefix must begin with '/' and must not end with one, so that WithPrefix("/admin") combined with a route registered at "/" yields "/admin/" and one registered at "/reports" yields "/admin/reports". Prefixes nest: including a router that is itself included somewhere concatenates both prefixes.
type SSEDialOptions ¶
type SSEDialOptions struct {
// HTTPClient issues the request, which is how a reader reaches a server on
// a network of its own, such as the in-process one a test client serves
// over. It defaults to a fresh [net/http.Client].
//
// A client with a Timeout is used with that timeout removed, because it
// would otherwise apply to the whole life of the stream rather than to the
// request and cut it short.
HTTPClient *http.Client
// Method is the request method, defaulting to GET. A stream reached by
// POST is not unusual: it is how a protocol that streams its answer to a
// posted document works.
Method string
// Body is the request body, for a stream opened with a method that carries
// one. Set the Content-Type through Header alongside it.
Body io.Reader
// Header carries extra request headers, which is where an Authorization
// header belongs. Accept and Last-Event-ID are set afterwards from the
// fields below.
Header http.Header
// LastEventID resumes a stream from the identifier a previous reader last
// saw, sent as the Last-Event-ID header exactly as a browser sends it.
LastEventID string
// ReadLimit is the largest single event accepted, in bytes, defaulting to
// [DefaultSSEReadLimit]. An event larger than it ends the stream rather
// than being buffered, so a server cannot decide how much memory this
// process spends.
ReadLimit int64
// ReadTimeout bounds how long one event may take to arrive once its first
// line has, defaulting to [DefaultSSEReadTimeout]. A negative value
// removes the bound.
ReadTimeout time.Duration
// KeepComments delivers comment lines as messages of their own rather than
// skipping them, which is how a caller sees the keepalives a server sends.
KeepComments bool
}
SSEDialOptions configures SSEDial.
The zero value issues a GET with the default HTTP client and applies the bounds above.
type SSEEvent ¶
type SSEEvent[Out any] struct { // Name is the event's type, which a browser dispatches it under, as in // addEventListener("item_update", ...). An empty Name dispatches as the // default "message" event. It may not contain a line break. Name string // ID identifies the event. A browser remembers the last one it saw and // sends it back in the Last-Event-ID header when it reconnects, which is // what lets a stream resume where it left off; read it with // [SSEStream.LastEventID]. It may not contain a line break. ID string // Retry asks the client to wait this long before reconnecting after the // stream ends. It is sent as a whole number of milliseconds. Set // [SSEOptions.Retry] instead to say it once at the start of every stream. Retry time.Duration // Comment is text no client acts on, written as a comment line before the // event's fields. It exists for the same reason the keepalive does: to put // something on the wire that means nothing. Comment string // Data is the value the event carries, encoded as JSON into its data // field. It is a pointer so that an event carrying nothing can be told // from one carrying a zero value, and it cannot be combined with Text. Data *Out // Text is a payload written as it stands rather than encoded, for a stream // whose events are not JSON: a log line, or a sentinel such as the "[DONE]" // some protocols end with. A payload spanning several lines is written as // several data lines and arrives whole. Text string }
SSEEvent is one event, for the sends that need more than a value.
Use SSEStream.Send when the event is only data, which it usually is, and this when the event needs a name to be dispatched under, an identifier to resume from, a reconnection delay, or a payload that is not JSON:
stream.SendEvent(muzak.SSEEvent[ItemOut]{Name: "item_update", ID: "42", Data: &item})
The zero value carries nothing, which is a valid event: a client sees it dispatched with empty data.
type SSEHandler ¶
SSEHandler is the shape every Muzak server-sent events handler takes.
In is bound from the request exactly as it is for any other route. Out is the model each event carries: the compiler enforces that nothing else is sent, and the generated document describes the stream with it, which is the same contract an ordinary handler's return type gives.
There is no return value beyond the error, because what an SSE route produces is a stream rather than a body. Returning nil ends the stream normally. Returning an error ends it too, with nothing about the error disclosed to the client, since by then the response has long since begun.
type SSEMessage ¶
type SSEMessage struct {
// Name is the event's type, empty for the default one a browser dispatches
// as "message".
Name string
// ID is the last identifier the stream carried, which is what a reconnect
// resumes from. It is not necessarily set by this event: the identifier
// persists until another one arrives, exactly as it does in a browser.
ID string
// Data is the event's payload, with the data lines joined by newlines.
Data string
// Comment is the text of a comment line, set only on the messages
// [SSEDialOptions.KeepComments] asks for, which carry nothing else.
Comment string
}
SSEMessage is one event read from a stream.
func (SSEMessage) Decode ¶
func (m SSEMessage) Decode[T any]() (T, error)
Decode decodes the event's data as JSON into a value of type T.
The type argument is written at the call site, which keeps the expected shape visible and checked by the compiler:
item, err := message.Decode[ItemOut]()
type SSEOptions ¶
type SSEOptions struct {
// KeepAlive is how often a comment is written to a stream that has sent
// nothing, defaulting to [DefaultSSEKeepAlive]. A comment is ignored by
// every client and is what keeps a proxy from closing a connection it
// believes to be idle. A negative value turns keepalive off, which is only
// appropriate for a stream that is never quiet.
KeepAlive time.Duration
// WriteTimeout bounds how long one event may take to reach the client,
// defaulting to [DefaultSSEWriteTimeout]. It is the bound that matters
// most here: a client that opens a stream and never reads it costs a
// goroutine, a connection and a growing socket buffer until something
// gives up, and this is what gives up. A negative value removes it, which
// is only appropriate when something else imposes one.
WriteTimeout time.Duration
// Retry is the reconnection delay advertised to the client at the start of
// every stream, sent as the retry field of the event stream. A browser's
// EventSource reconnects on its own after a stream ends, and this is the
// only say a server has in how soon. It is unset by default, which leaves
// the client's own default in place.
Retry time.Duration
// MaxStreams is how many event streams the application serves at once,
// defaulting to [DefaultSSEMaxStreams]. A request arriving once the limit
// is reached is refused with 503 and a Retry-After header rather than
// accepted into a process that has no room for it.
//
// Unlike every other field here it may only be set on the application: the
// resource it protects is the process, not a route, so a router or a route
// that sets it is refused when the application is built. A negative value
// removes the limit, which is only appropriate where something else is
// counting.
MaxStreams int
// MaxStreamsPerIP is how many event streams a single client address may
// hold open at once, defaulting to [DefaultSSEMaxStreamsPerIP]. A request
// that would exceed it is refused with 503 and a Retry-After header, the
// same as MaxStreams, but for one client rather than for the process.
//
// MaxStreams alone bounds the process; it does not bound one client
// within it, so a single client opening streams in a tight loop can hold
// every one of MaxStreams' slots itself, leaving 503 for everyone else
// until it disconnects. This is what stops that: no matter how many
// streams the process has room for, one address can never hold more than
// this many of them.
//
// The address used is the one [Context.ClientIP] resolves; see
// [ClientIPOptions] to configure it behind a proxy. Like MaxStreams, this
// may only be set on the application, because the dimension it bounds is
// a client's share of the process, not of one route: a router or a route
// that sets it is refused when the application is built. A negative
// value removes the limit, which is only appropriate where something
// else is counting per client, such as a reverse proxy already capping
// connections per source address.
MaxStreamsPerIP int
}
SSEOptions configures the event streams of a route.
It can be set application-wide through AppOptions.SSE and narrowed for a router or a single route with WithSSE. Layering works field by field: whatever a narrower scope leaves at its zero value it inherits, so a route that only lengthens the keepalive keeps the application's write timeout.
The zero value is usable and safe: writes are bounded, an idle stream is held open by a periodic keepalive, and the number of streams one application serves at once is capped.
type SSEReader ¶
type SSEReader struct {
// contains filtered or unexported fields
}
SSEReader reads the events of one stream.
A reader is not safe for concurrent use: the events of a stream arrive in order and one loop should consume them. It is safe to SSEReader.Close from another goroutine, which is what ends a read that is waiting.
func SSEDial ¶
func SSEDial(ctx context.Context, rawURL string, opts SSEDialOptions) (*SSEReader, *http.Response, error)
SSEDial opens an event stream and returns a reader for it.
It is the client side of the same engine that serves streams, which is what makes an SSE route testable end to end without a second implementation to disagree with the first:
reader, _, err := muzak.SSEDial(ctx, "http://"+app.Addr()+"/items/stream", muzak.SSEDialOptions{})
if err != nil {
return err
}
defer reader.Close()
for {
message, err := reader.Next(ctx)
if errors.Is(err, muzak.ErrSSEStreamEnded) {
return nil
}
if err != nil {
return err
}
// ...
}
The response is returned alongside the reader so that a caller can read the headers, and on failure so that it can read the status and body the server refused with; a refused response has its body read into memory already and may be read again. The reader is nil unless the stream opened.
A response that is not 200 with a text/event-stream body is refused rather than parsed, because a stream reader that quietly accepts an HTML error page reports "no events" for what is actually a failure.
Cancelling ctx ends the stream, whether the handshake or a later read is waiting on it. Redirects are followed by whatever client is supplied; net/http drops an Authorization header when one crosses to another host.
func (*SSEReader) Close ¶
Close ends the stream and releases the connection. It is safe to call more than once and from more than one goroutine, and it releases a SSEReader.Next that is waiting.
func (*SSEReader) LastEventID ¶
LastEventID returns the identifier of the last event that carried one, which is what a reconnecting reader passes to SSEDialOptions.LastEventID to resume where this one left off.
func (*SSEReader) Next ¶
func (r *SSEReader) Next(ctx context.Context) (SSEMessage, error)
Next returns the next event, blocking until one arrives.
A stream that has ended reports ErrSSEStreamEnded, which is what a read loop stops on, whether the server finished, the connection dropped or ctx was cancelled. Comments are skipped unless SSEDialOptions.KeepComments asked for them.
An event with no data at all is not delivered, because a browser does not dispatch one either: such an event exists to carry an identifier or a reconnection delay, both of which are applied to the reader instead.
type SSEStream ¶
type SSEStream[Out any] struct { // contains filtered or unexported fields }
SSEStream is the open event stream a server-sent events handler writes to.
The type parameter is the model the stream's events carry, which is what makes the contract a compiler-checked one: nothing but an Out can be sent with SSEStream.Send, and the generated document describes the stream with that type.
Concurrency ¶
Writes are serialized, so any number of goroutines may write to one stream and each event goes out whole. A handler that fans out to several producers needs no lock of its own.
Lifetime ¶
A stream belongs to one request and ends when the handler returns, which is why nothing here takes a context: SSEStream.Context is the one context that governs every send, and it is cancelled when the client disconnects or the server begins shutting down. Neither the stream nor the Context may be used after the handler returns.
Failure ¶
A stream is single use. The first failure ends it, and every later send reports that same error rather than writing into a response that is no longer being read. Sends that report a mistake instead, such as an event name carrying a line break, leave the stream usable.
func (*SSEStream[Out]) Comment ¶
Comment writes a comment, which no client acts on and every client accepts. Muzak already sends one every SSEOptions.KeepAlive to hold an idle stream open, so this is for a stream that wants to say something to whoever is watching it by hand.
func (*SSEStream[Out]) Context ¶
Context returns the context that governs the stream. It is derived from the request's own and is cancelled when the client disconnects, when the request ends, or when the server begins shutting down, which makes it the one thing a handler has to watch:
select {
case <-stream.Context().Done():
return nil
case update := <-updates:
return stream.Send(update)
}
func (*SSEStream[Out]) Err ¶
Err returns the error that ended the stream, or nil while it is still usable. Every send reports the same error, so this is only needed by a handler that watches SSEStream.Context rather than a send's result.
func (*SSEStream[Out]) LastEventID ¶
LastEventID returns the identifier the client last saw, taken from the Last-Event-ID header a browser sends when its EventSource reconnects. It is empty for a stream opened for the first time, and is a value the client controls, so treat it as input rather than as a cursor to be trusted.
func (*SSEStream[Out]) Send ¶
Send writes one event carrying data, encoded as JSON into its data field. It is the whole of what most streams do.
func (*SSEStream[Out]) SendEvent ¶
SendEvent writes one event described in full, which is what a name, an identifier, a reconnection delay or a payload that is not JSON takes. An event that sets both SSEEvent.Data and SSEEvent.Text is refused, because only one of them can be the data field.
type Schema ¶
type Schema struct {
// Ref points at a named schema in the components section. When set, every
// other field is empty.
Ref string `json:"$ref,omitzero"`
// Type is the JSON type, or a list of types when the value is nullable.
Type any `json:"type,omitzero"`
// Format refines the type, as "date-time" or "uuid" do for strings.
Format string `json:"format,omitzero"`
// Title names the schema in generated documentation.
Title string `json:"title,omitzero"`
// Description explains the value, taken from its doc struct tag.
Description string `json:"description,omitzero"`
// Properties describes each member of an object.
Properties map[string]*Schema `json:"properties,omitzero"`
// Required lists the members an object must carry.
Required []string `json:"required,omitzero"`
// Items describes the elements of an array.
Items *Schema `json:"items,omitzero"`
// AnyOf lists alternative schemas, used to widen a reference so that null
// is also permitted.
AnyOf []*Schema `json:"anyOf,omitzero"`
// AdditionalProperties describes values of a map, or is false for an
// object that accepts no extra members.
AdditionalProperties any `json:"additionalProperties,omitzero"`
// Default is the value used when the input omits this one.
Default any `json:"default,omitzero"`
// Enum lists the permitted values.
Enum []any `json:"enum,omitzero"`
// Pattern is a regular expression a string must match.
Pattern string `json:"pattern,omitzero"`
// MinLength and MaxLength bound a string's length.
MinLength *int `json:"minLength,omitzero"`
MaxLength *int `json:"maxLength,omitzero"`
// Minimum and Maximum bound a number's value.
Minimum *float64 `json:"minimum,omitzero"`
Maximum *float64 `json:"maximum,omitzero"`
// MultipleOf requires a number to divide evenly by this value.
MultipleOf *float64 `json:"multipleOf,omitzero"`
// MinItems and MaxItems bound an array's length.
MinItems *int `json:"minItems,omitzero"`
MaxItems *int `json:"maxItems,omitzero"`
// UniqueItems requires an array's elements to differ.
UniqueItems bool `json:"uniqueItems,omitzero"`
// Deprecated marks the value as no longer recommended.
Deprecated bool `json:"deprecated,omitzero"`
}
Schema is a JSON Schema 2020-12 description of a value, which is the schema dialect OpenAPI 3.1 uses.
type Server ¶
type Server struct {
// URL is the base URL, which may be relative to the document.
URL string `json:"url"`
// Description explains what this server is, such as "production".
Description string `json:"description,omitzero"`
}
Server describes one base URL the API is served from.
type ServerOptions ¶
type ServerOptions struct {
// ReadHeaderTimeout bounds the time allowed to read request headers,
// defaulting to [DefaultReadHeaderTimeout].
ReadHeaderTimeout time.Duration
// ReadTimeout bounds the time allowed to read the entire request,
// defaulting to [DefaultReadTimeout].
ReadTimeout time.Duration
// WriteTimeout bounds the time allowed to write the response, defaulting
// to [DefaultWriteTimeout].
WriteTimeout time.Duration
// IdleTimeout bounds how long an idle keep-alive connection is kept,
// defaulting to [DefaultIdleTimeout].
IdleTimeout time.Duration
// ShutdownTimeout bounds how long [App.Shutdown] waits for in-flight
// requests, defaulting to [DefaultShutdownTimeout].
ShutdownTimeout time.Duration
// MaxHeaderBytes bounds the size of the request header block, defaulting
// to [DefaultMaxHeaderBytes].
MaxHeaderBytes int
// TLSConfig enables HTTPS when set. [App.Run] serves TLS whenever this or
// a certificate pair is supplied.
TLSConfig *tls.Config
// CertFile and KeyFile enable HTTPS from a certificate and key on disk.
CertFile string
KeyFile string
// BaseContext returns the base context for incoming requests. When nil,
// requests derive from context.Background.
BaseContext func(net.Listener) context.Context
}
ServerOptions configures the HTTP listener.
Its fields may be set directly inside an AppOptions literal, because AppOptions embeds it. Every timeout defaults to a non-zero value; setting one to a negative number disables it, which should be reserved for a server behind a proxy that enforces its own limits.
type SharedOption ¶
type SharedOption interface {
RouteOption
RouterOption
}
SharedOption is an option that is meaningful both for a whole router and for an individual route, such as WithTags or WithDependencies. Applying one to a router makes it apply to every route beneath it.
func AllowUnknownFields ¶
func AllowUnknownFields() SharedOption
AllowUnknownFields relaxes JSON decoding so that members with no corresponding field are ignored rather than rejected.
Muzak rejects unknown members by default, which turns a client's typo into an immediate 422 instead of a silently dropped value. Opt out only where forward compatibility with clients that send extra members matters more. Duplicate object members and invalid UTF-8 remain rejected regardless.
func Deprecated ¶
func Deprecated() SharedOption
Deprecated marks a route, or every route beneath a router, as deprecated in the OpenAPI document. It changes nothing at runtime.
func Hidden ¶
func Hidden() SharedOption
Hidden omits a route, or every route beneath a router, from the OpenAPI document and the documentation UI while leaving it fully routable. Use it for health checks and internal endpoints.
func MaxBodySize ¶
func MaxBodySize(bytes int64) SharedOption
MaxBodySize overrides the maximum accepted request body size, in bytes, for a route or for every route beneath a router. A request whose body exceeds the limit is rejected with 413 before the handler runs. The application-wide default comes from AppOptions.MaxBodySize.
func MaxFileSize ¶
func MaxFileSize(bytes int64) SharedOption
MaxFileSize limits the size, in bytes, of any single uploaded file for a route or for every route beneath a router.
A request carrying a file larger than the limit is rejected with 413 before the handler runs. The limit is checked once the body has been read, so it bounds what a handler is handed rather than what the server accepts; MaxUploadSize is what bounds the latter and should be set alongside it. The application-wide default comes from AppOptions.MaxFileSize, and zero leaves each file bounded only by the upload limit.
func MaxUploadSize ¶
func MaxUploadSize(bytes int64) SharedOption
MaxUploadSize overrides the maximum accepted size, in bytes, of a form body for a route or for every route beneath a router.
It is what bounds a route that binds `form` or `file` fields, in place of MaxBodySize, because an upload is expected to be larger than a JSON document and the two limits should not have to be traded off against each other. A body that exceeds it is rejected with 413 while it is being read, so the server never buffers more than the limit. The application-wide default comes from AppOptions.MaxUploadSize; a negative value removes an inherited limit, which is only appropriate behind a proxy that imposes its own.
func Needs ¶
func Needs[T any](provide func(ctx *Context) (T, error)) SharedOption
Needs declares a request-scoped value dependency produced by the given provider function.
The provider runs once per request, after every guard, and its result is retrieved inside the handler with From:
r.Get("/items/{id}", func(ctx *muzak.Context, in Params) (ItemOut, error) {
user := muzak.From[CurrentUser](ctx)
return ItemOut{ID: in.ID, Owner: user.Username}, nil
}, muzak.Needs(GetCurrentUser))
The type parameter is inferred from the provider, so callers never write it out. A provider that returns an error aborts the request, and that error is mapped to a response the same way a handler error is.
Resolved values are stored on the request's Context and discarded when it returns to the pool, so two concurrent requests never observe each other's values. Declaring the same type more than once along the chain is allowed; the most recent declaration wins, which lets a route override a dependency its router declared. Use Singleton for a value that should be computed once for the whole application instead.
func RateLimit ¶
func RateLimit(quotas ...Quota) SharedOption
RateLimit replaces the quotas a route, or every route beneath a router, is limited by, keeping the storage and tracker it inherits.
It is how one route is held to a stricter policy than the rest, which a login route needs whether or not the rest of the application is limited at all:
r.Post("/login", login,
muzak.RateLimit(muzak.Quota{Name: "login", Window: time.Minute, Limit: 5}))
The quotas given replace those inherited rather than adding to them, so a route that wants both restates the ones it is keeping.
func Singleton ¶
func Singleton[T any](provide func(ctx *Context) (T, error)) SharedOption
Singleton declares a value dependency that is resolved once for the lifetime of the application and shared by every request thereafter.
It is the deliberate exception to Muzak's request scoping, meant for expensive, immutable values such as a parsed configuration or a compiled template set. The provider runs on the first request that needs the value, receiving that request's Context; it must not retain that Context, read request-specific state from it, or return a value that is unsafe for concurrent use, because every later request shares the same value. An error from the provider is cached too, so a failing singleton fails every request rather than being retried.
func SkipRateLimit ¶
func SkipRateLimit() SharedOption
SkipRateLimit exempts a route, or every route beneath a router, from rate limiting, including the message limits of a WebSocket route.
It is what a health check wants, since a monitor polling every second is the one client that should never be told to slow down:
r.Get("/health", health, muzak.SkipRateLimit())
An exemption cannot be undone by a narrower scope: once a router is exempt, every route beneath it is.
func WithDependencies ¶
func WithDependencies(guards ...Guard) SharedOption
WithDependencies attaches guard dependencies to an application, a router or a single route.
Guards run in declaration order, outermost first: those declared on the application run before those declared when including a router, which in turn run before those declared on the route itself. The first guard to return an error stops the chain and produces the response, so a guard that authorizes a whole subtree can be declared once at the point of inclusion:
app.Include(admin.NewRouter(), muzak.WithDependencies(GetTokenHeader))
Use Needs instead when the dependency must hand a value to the handler.
Example ¶
ExampleWithDependencies attaches a guard to the whole application, which every route beneath it inherits.
getQueryToken := func(ctx *muzak.Context) error {
if ctx.Query("token") == "" {
return muzak.NewHTTPError(http.StatusBadRequest, "token is required")
}
return nil
}
app := muzak.New(muzak.AppOptions{
LoggerOptions: muzak.LoggerOptions{Format: muzak.LogFormatNone},
}, muzak.WithDependencies(getQueryToken))
app.Get("/users/me", func(ctx *muzak.Context, _ muzak.Empty) (UserOut, error) {
return UserOut{Username: "fakecurrentuser"}, nil
})
for _, target := range []string{"/users/me?token=jessica", "/users/me"} {
rec := httptest.NewRecorder()
app.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, target, nil))
fmt.Println(rec.Code)
}
Output: 200 400
func WithLifecycle ¶
func WithLifecycle(components ...Lifecycle) SharedOption
WithLifecycle registers lifecycle components that are not tied to a published value, such as a background worker or a metrics exporter.
Components registered this way are started and stopped exactly like those discovered through WithSingleton.
func WithRateLimit ¶
func WithRateLimit(opts RateLimitOptions) SharedOption
WithRateLimit configures rate limiting for an application, a router or a single route.
app := muzak.New(muzak.AppOptions{Title: "Shop"},
muzak.WithRateLimit(muzak.RateLimitOptions{
Storage: NewRedisRateLimitStorage(settings.RedisAddr),
Tracker: UserOrIPTracker,
Quotas: []muzak.Quota{
{Name: "short", Window: time.Second, Limit: 3},
{Name: "medium", Window: 10 * time.Second, Limit: 20},
{Name: "long", Window: time.Minute, Limit: 100},
},
}),
)
Options layer field by field on top of AppOptions.RateLimit and on top of whatever an enclosing router declared, so a route can change the quotas without restating the storage. Use RateLimit for the common case of changing only the quotas, and SkipRateLimit to exempt a route.
func WithResponseDoc ¶
func WithResponseDoc(code int, description string) SharedOption
WithResponseDoc documents an additional response the route may produce, which is recorded in the OpenAPI document but has no effect at runtime. Use it for outcomes a handler produces through an error rather than through its return type, as in WithResponseDoc(418, "I'm a teapot"). Applying it to a router documents the response for every route beneath it.
The body is described as the standard error envelope, ErrorResponse, since that is what an error returned from a handler produces. A route that answers some status with a body of its own describes it with WithResponseModel instead. An empty description falls back to the status code's standard reason phrase, so WithResponseDoc(404, "") reads as "Not Found".
func WithResponseModel ¶ added in v0.1.1
func WithResponseModel[T any](code int, description string) SharedOption
WithResponseModel documents an additional response and the model its body carries, so that one operation can describe a different schema per status code. Like WithResponseDoc it is recorded in the OpenAPI document and has no effect at runtime.
The handler's return type describes the success response and nothing else, which leaves every other outcome undescribed unless it is declared here. The type argument is the response model, written exactly as the handler's own Out type would be:
r.Get("/items/{item_id}", handlers.ReadItem,
muzak.WithResponseModel[schemas.ItemError](http.StatusNotFound, "The item does not exist"),
muzak.WithResponseModel[schemas.ItemError](http.StatusGone, "The item was deleted"))
The rules that apply to a handler's Out type apply here too: a named struct is referenced from the components section and described once however many operations mention it, Empty describes a response with no body at all, and HTML describes one carrying text/html.
An empty description falls back to the status code's standard reason phrase. Applying it to a router documents the response for every route beneath it, and the last declaration of a status code wins, so a route may replace what it inherited, including the response derived from its own return type when it names the status the route succeeds with.
func WithSSE ¶
func WithSSE(opts SSEOptions) SharedOption
WithSSE configures the event streams of a route, or of every route beneath a router.
live := muzak.NewRouter()
live.SSE("/items/stream", streamItems)
app.Include(live, muzak.WithSSE(muzak.SSEOptions{
KeepAlive: 5 * time.Second,
Retry: 2 * time.Second,
}))
Options layer field by field on top of AppOptions.SSE and on top of whatever an enclosing router declared, so a route can change one bound without restating the rest.
func WithSingleton ¶
func WithSingleton[T any](value T, opts ...SingletonOption) SharedOption
WithSingleton publishes an already constructed value to every handler.
It is the eager counterpart to Singleton: the value exists before the application starts, so there is nothing to resolve and every request simply receives it.
settings := muzak.MustLoadConfig[Settings](muzak.EnvFile(".env"))
app := muzak.New(muzak.AppOptions{Title: "Awesome API"},
muzak.WithSingleton(settings),
)
Handlers retrieve it by type, with no cast:
s := muzak.From[Settings](ctx)
If the value implements Lifecycle, or a LifecycleFunc option is supplied, the value is also registered as a lifecycle component: it is started before the server accepts traffic and stopped after the server has drained. Lifecycle registration happens only when the option is applied to an application or a router, since a component's life is not tied to a single route.
The value is shared by every request, so it must be safe for concurrent use.
Example ¶
ExampleWithSingleton publishes a value to every handler, retrieved by type.
type Settings struct{ AppName string }
type InfoOut struct {
AppName string `json:"app_name"`
}
app := muzak.New(muzak.AppOptions{
LoggerOptions: muzak.LoggerOptions{Format: muzak.LogFormatNone},
}, muzak.WithSingleton(Settings{AppName: "Awesome API"}))
app.Get("/info", func(ctx *muzak.Context, _ muzak.Empty) (InfoOut, error) {
s := muzak.From[Settings](ctx)
return InfoOut{AppName: s.AppName}, nil
})
rec := httptest.NewRecorder()
app.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/info", nil))
fmt.Println(rec.Body.String())
Output: {"app_name":"Awesome API"}
func WithTags ¶
func WithTags(tags ...string) SharedOption
WithTags adds OpenAPI tags to a router or a single route, which is what groups operations in the generated documentation.
A router's tags are inherited by every route beneath it, and a route's own tags add to what it inherited rather than replacing it, so an operation appears under every group it names:
admin := muzak.NewRouter(muzak.WithTags("admin"))
admin.Post("/actions", act, muzak.WithTags("audit")) // admin and audit
A route under a router that declares no tags at all is grouped by its own tags alone. Duplicates are removed while first-seen order is preserved, and the groups themselves are described and ordered by OpenAPIOptions.Tags.
func WithVersion ¶
func WithVersion(versions ...Version) SharedOption
WithVersion declares the version(s) a router or route answers, for an application with versioning enabled through AppOptions.Versioning.
Declaring it on a route overrides whatever an enclosing router declared, exactly as Status overrides a router's declared default; declaring it on a router applies to every route beneath it that does not declare its own. A route or router that never declares one falls back to VersioningOptions.DefaultVersion, and if that is unset too, answers no request at all while versioning is enabled: an application has to opt a route into being unversioned deliberately, with VersionNeutral, rather than by omission.
admin := muzak.NewRouter(muzak.WithVersion("1"))
admin.Get("/cats", findAllV1)
admin.Get("/cats", findAllV2, muzak.WithVersion("2"))
admin.Get("/health", health, muzak.WithVersion(muzak.VersionNeutral))
Calling it with more than one version answers every one of them; calling it with no versions at all is a build error, since there would be nothing left for the declaration to mean. VersionNeutral cannot be combined with another version in the same call, because it already answers every request the narrower version would.
func WithWebSocket ¶
func WithWebSocket(opts WSOptions) SharedOption
WithWebSocket configures the WebSocket connections of a route, or of every route beneath a router.
chat := muzak.NewRouter()
chat.WS("/rooms/{room}/ws", joinRoom)
app.Include(chat, muzak.WithWebSocket(muzak.WSOptions{
ReadLimit: 64 << 10,
PingInterval: 30 * time.Second,
AllowedOrigins: []string{"https://app.example.com"},
}))
Options layer field by field on top of AppOptions.WebSocket and on top of whatever an enclosing router declared, so a route can raise one limit without restating the rest.
type SingletonOption ¶
type SingletonOption interface {
// contains filtered or unexported methods
}
SingletonOption adjusts how a value published with WithSingleton is managed.
func LifecycleFunc ¶
func LifecycleFunc(name string, start, stop func(ctx context.Context) error) SingletonOption
LifecycleFunc attaches start and stop functions to a value published with WithSingleton, so that a resource can take part in the application lifecycle without implementing Lifecycle itself.
It is the closure form of a lifecycle component, and is what makes a plain map or slice usable as managed state:
models := map[string]func(float64) float64{}
app.Options(muzak.WithSingleton(models, muzak.LifecycleFunc("ml-model",
func(ctx context.Context) error {
models["answer_to_everything"] = func(x float64) float64 { return x * 42 }
return nil
},
func(ctx context.Context) error {
clear(models)
return nil
},
)))
Because the value itself is published unchanged, the handler still retrieves it by type with From. Note that the closures must capture a value whose contents can be mutated in place, such as a map, a slice header held behind a pointer, or a struct pointer; reassigning a captured variable inside Start will not change what handlers see.
type StaticOptions ¶
type StaticOptions struct {
// Dir is the directory holding the files, or the subdirectory within FS
// when both are set.
Dir string
// FS serves the files from a filesystem rather than from disk, which is
// what an [embed.FS] of assets belonging to a library looks like.
FS fs.FS
// Index serves a directory with the index.html inside it, as a web server
// does for a site of pages. It is off by default, because a mount of
// scripts and stylesheets has no index and asking for a directory is a
// mistake worth reporting.
Index bool
// SkipCheck stops the directory being verified when the application is
// built, for one that something else fills in later.
SkipCheck bool
}
StaticOptions describes a directory of files to serve.
It is the plain form: files are served as they are found and nothing stands in for a path with no file behind it. A frontend wants more than that, so a single page application belongs in Router.Frontend rather than here.
type StatusCoder ¶
type StatusCoder interface {
// HTTPStatus returns the status code that should be written for this
// error. Values outside the 100 to 599 range are clamped to 500.
HTTPStatus() int
}
StatusCoder is implemented by errors that carry their own HTTP status code.
Muzak consults it when turning a handler or dependency error into a response: an error that implements StatusCoder is considered deliberate and its message is sent to the client, while any other error is treated as an unexpected fault and reported as a bare 500 with the real cause logged but never transmitted. Implement it on your own error types to make them first-class citizens of the error pipeline without depending on *HTTPError.
type StringField ¶
StringField matches a string field or an optional pointer to one.
The union is what lets one entry point serve both `Name string` and `Nickname *string`. Rules bind to the field's address either way, so the field is still identified by position, and a nil pointer skips its rules rather than failing them.
type Tag ¶
type Tag struct {
// Name is the tag as it appears on operations.
Name string `json:"name"`
// Description explains what the group covers.
Description string `json:"description,omitzero"`
}
Tag groups operations in the generated documentation.
type Validatable ¶
type Validatable interface {
// Validate declares this model's rules. It is called once per request, on
// the bound value, and should declare rules rather than do work of its own.
Validate(v *Validation)
}
Validatable is implemented by an input model that declares validation rules.
Muzak runs Validate after binding and after every guard, so a model never gets to tell an unauthenticated caller what is wrong with its request. The method belongs on the pointer type, which is what lets rules name fields by address:
func (in *CreateUser) Validate(v *muzak.Validation) {
v.String(&in.Email).Trim().Lower().Required().Email()
v.Number(&in.Age).Between(18, 120)
}
Implementing it is the only thing needed: there is no option to remember and no pipe to install, so a model cannot be left unvalidated by omission. Use SkipValidation on a route that must not validate.
type Validation ¶
type Validation struct {
// contains filtered or unexported fields
}
Validation collects the rules a model declares and turns what they find into error details.
A model receives one during its Validate method and uses it to bind rule sets to its own fields. It is not safe for concurrent use and must not be retained after Validate returns.
func (*Validation) Nested ¶
func (v *Validation) Nested(model Validatable)
Nested validates a model held inside another, reporting its failures under a dotted path:
v.Nested(&in.Address)
A failure on the nested model's City field is reported as "address.city". A nil pointer is skipped, so an optional nested model needs no guard of its own.
func (*Validation) Number ¶
func (v *Validation) Number[T NumberField](ptr *T) *validate.NumberRules
Number binds rules to a numeric field.
v.Number(&in.Age).Between(18, 120)
Bounds are written as ordinary constants whatever the field's own numeric type. A pointer field is optional.
func (*Validation) Reject ¶
func (v *Validation) Reject(target any, issue string)
Reject records a failure against a field without declaring a rule for it.
It is the direct form of a cross-field check, for a condition that reads better as an ordinary if than as a rule:
if in.Start.After(in.End) {
v.Reject(&in.End, "must not be before the start")
}
func (*Validation) Slice ¶
func (v *Validation) Slice[E any](ptr *[]E) *validate.SliceRules[E]
Slice binds rules to a collection, both about the collection itself and, through validate.SliceRules.Each, about each element:
v.Slice(&in.Tags).MaxItems(10).Each(validate.String().MaxLen(20))
func (*Validation) String ¶
func (v *Validation) String[T StringField](ptr *T) *validate.StringRules
String binds rules to a string field.
v.String(&in.Email).Trim().Lower().Required().Email()
The field may be a string or a pointer to one; a nil pointer skips its rules. A field of any other type does not compile.
func (*Validation) Time ¶
func (v *Validation) Time[T TimeField](ptr *T) *validate.TimeRules
Time binds rules to a time field.
func (*Validation) Value ¶
func (v *Validation) Value[T any](ptr *T) *validate.ValueRules[T]
Value binds rules to a field of any type, for what the typed rule sets do not cover. Must and OneOf take the field's own type, so the values written at the call site are checked by the compiler.
func (*Validation) When ¶
func (v *Validation) When(condition bool) *Condition
When begins a check that applies only when a condition holds:
v.When(in.Role == "admin" && in.Age < 21). Reject(&in.Role, "an admin must be at least 21")
The condition is an ordinary Go expression over the model's own fields, which is all a cross-field rule needs to be.
type ValidationError ¶
type ValidationError struct {
// Details lists every problem found, in the order the fields are declared
// on the input type.
Details []ErrorDetail
}
ValidationError reports one or more fields that could not be bound from the request. Muzak renders it as a 422 classified "validation_error", with one entry in "details" per offending field, so a client learns about every mistake at once instead of one per round trip.
func (*ValidationError) Error ¶
func (e *ValidationError) Error() string
Error implements the error interface, summarizing how many fields failed and naming the first of them.
func (*ValidationError) HTTPStatus ¶
func (e *ValidationError) HTTPStatus() int
HTTPStatus implements StatusCoder, reporting 422 Unprocessable Content.
type Version ¶
type Version string
Version identifies one version a route or router answers.
It is an ordinary string with one reserved value, VersionNeutral; every other value is whatever an application chooses to call its versions, such as "1", "2" or "2023-01-01".
const VersionNeutral Version = "\x00neutral"
VersionNeutral marks a route or router as answering every request regardless of the version it names, including a request that names none at all. For VersioningURI specifically, a version-neutral route is reached at its plain path, with no version segment inserted.
It cannot be combined with another version in the same WithVersion call or the same VersioningOptions.DefaultVersion, because it already answers every request the narrower version would.
type VersionExtractor ¶
VersionExtractor pulls the version(s) a request declares out of it, for VersioningCustom.
Return them in order from most to least preferred: a request that says it accepts several versions is matched against the first of them that some route answers, exactly as VersioningOptions.DefaultVersion and a route's own WithVersion list are. Returning nil or an empty slice reports a request that names no version at all, which only a route registered VersionNeutral answers.
type VersioningOptions ¶
type VersioningOptions struct {
// Type selects how a request declares its version. It defaults to
// [VersioningNone], which is versioning turned off.
Type VersioningType
// Prefix is prepended to the version in the request path, for
// [VersioningURI], defaulting to "v" so that version "1" is reached at
// "/v1". Point it at an empty string, through a pointer to one, to use
// the version verbatim with no prefix at all.
Prefix *string
// Header names the request header carrying the version, for
// [VersioningHeader]. It is required for that type.
Header string
// Key is the Accept header parameter naming the version, for
// [VersioningMediaType], such as "v=" for "application/json;v=2". It is
// required for that type.
Key string
// Extractor pulls the version(s) a request carries, for
// [VersioningCustom]. It is required for that type.
Extractor VersionExtractor
// DefaultVersion is used for a route or router that declares none of its
// own with [WithVersion]. Left unset, such a route answers no request at
// all while versioning is enabled, rather than being served
// unversioned: an application has to opt a route into being
// version-independent deliberately, with [VersionNeutral], not by
// omission.
DefaultVersion []Version
}
VersioningOptions configures how requests declare which version of the API they want.
The zero value leaves versioning off: Type is VersioningNone, WithVersion may not be used anywhere in the application, and every route answers exactly as it would without this package existing at all. Set Type to turn versioning on, through AppOptions.Versioning:
app := muzak.New(muzak.AppOptions{
Versioning: muzak.VersioningOptions{Type: muzak.VersioningURI},
})
type VersioningType ¶
type VersioningType uint8
VersioningType selects how a request declares which version of the API it wants.
const ( // VersioningNone is the zero value: versioning is off. [WithVersion] may // not be used anywhere in the application, and every route answers // exactly as it would if this package did not exist. VersioningNone VersioningType = iota // VersioningURI reads the version from the request's own path, such as // "/v1/cats", inserting it automatically for every route that declares // one. It is the default a new application should reach for first. VersioningURI // VersioningHeader reads the version from a request header named by // [VersioningOptions.Header]. VersioningHeader // VersioningMediaType reads the version from a parameter of the request's // Accept header, named by [VersioningOptions.Key], as in // "application/json;v=2". VersioningMediaType // VersioningCustom reads the version using [VersioningOptions.Extractor], // for anything the three built-in types do not cover. VersioningCustom )
type WSCloseError ¶
type WSCloseError struct {
// Status is the close status code. It is [WSStatusNoStatusReceived] for a
// close frame that carried no code, and [WSStatusAbnormalClosure] for a
// connection lost without one.
Status WSStatus
// Reason is the human-readable explanation that accompanied the status,
// which is often empty.
Reason string
// contains filtered or unexported fields
}
WSCloseError reports a WebSocket connection that has ended.
Every read and every write returns one once the connection is finished, so a handler's loop ends on the first error it sees whether the peer closed politely, broke the protocol or vanished:
for {
msg, err := conn.ReadText(ctx.Context())
if err != nil {
return nil
}
// ...
}
Use WSCloseStatus to find out which of those happened when it matters.
func (*WSCloseError) Error ¶
func (e *WSCloseError) Error() string
Error implements the error interface.
type WSConn ¶
type WSConn struct {
// contains filtered or unexported fields
}
WSConn is one WebSocket connection.
A handler receives a connection that is already open: the handshake succeeded before the handler was called, and the connection is closed for it when the handler returns. Reading and writing are message oriented, so a message that arrived in several frames is delivered once, whole.
Concurrency ¶
Writes are serialized, so any number of goroutines may write to the same connection and each message goes out intact. Reads are serialized too, but a second reader is rarely what is wanted: the messages of one connection arrive in order and one loop should consume them.
Failure ¶
A connection is single use. The first failure, whether a protocol violation, a lost transport or a cancelled operation, ends it, and every later call returns that same error rather than trying to carry on with a stream whose position is no longer known.
func WSDial ¶
func WSDial(ctx context.Context, rawURL string, opts WSDialOptions) (*WSConn, *http.Response, error)
WSDial opens a WebSocket connection to a server.
It is the client side of the same engine that serves connections, which is what makes a WebSocket route testable end to end without a second implementation to disagree with the first. The URL may be written with either the ws and wss schemes or the http and https ones.
conn, _, err := muzak.WSDial(ctx, "ws://"+app.Addr()+"/items/plumbus/ws", muzak.WSDialOptions{})
if err != nil {
return err
}
defer conn.Close(muzak.WSStatusNormalClosure, "")
The response is returned alongside the connection so that a caller can read the headers of the handshake, and on failure so that it can read the status and body the server refused with; its body has already been read into memory and may be read again. The connection is nil unless the handshake succeeded.
Cancelling ctx aborts the handshake. Afterwards ctx has no further bearing on the connection, which is driven by the contexts passed to its own reads and writes.
func (*WSConn) Close ¶
Close closes the connection with a status and a reason.
Muzak closes the connection when the handler returns, so calling Close is only necessary to choose a status other than WSStatusNormalClosure or to end the connection from another goroutine. It is safe to call more than once and from more than one goroutine; only the first call sends anything, and a connection that has already ended reports success.
The reason is truncated to the 123 bytes a close frame can carry, on a rune boundary. A status that describes a local observation rather than something worth telling the peer, such as WSStatusAbnormalClosure, closes without a status code.
func (*WSConn) Ping ¶
Ping sends a ping and returns once it is on the wire.
It does not wait for the answer, because the answer arrives through WSConn.Read like everything else the peer sends. Set WSOptions.PingInterval for a connection that should be checked periodically rather than by hand.
func (*WSConn) Read ¶
Read reads the next complete message from the connection.
Fragmentation is invisible: a message split across several frames is reassembled and returned once, and control frames that arrive in between are handled without interrupting the message. A ping is answered automatically, and a close is answered and then reported as a *WSCloseError.
The returned slice belongs to the caller and is not reused, so it may be kept for as long as it is useful. A message larger than the connection's read limit is refused with WSStatusMessageTooBig before any of it is buffered, which is what keeps a peer from choosing how much memory the server spends. A peer sending faster than WSOptions.MessageLimits allows is closed rather than read from again.
func (*WSConn) ReadBinary ¶
ReadBinary reads the next message and returns its bytes.
A text message is refused with WSStatusUnsupportedData, for the same reason WSConn.ReadText refuses a binary one.
func (*WSConn) ReadJSON ¶
ReadJSON reads the next message and decodes it into target.
The message must be text, and it is decoded with the same rules a request body is: unknown members, duplicate members and invalid UTF-8 are all rejected. A message that does not decode closes the connection with WSStatusInvalidFramePayload, because a peer sending malformed JSON on a JSON connection is not going to be understood by carrying on.
func (*WSConn) ReadText ¶
ReadText reads the next message and returns it as text.
A binary message is refused with WSStatusUnsupportedData, because a handler that asked for text has no way to make sense of one.
func (*WSConn) Subprotocol ¶
Subprotocol returns the subprotocol negotiated during the handshake, or the empty string when none was.
func (*WSConn) Write ¶
Write sends one message.
The whole message goes out as a single frame, and the payload is not retained, so the caller may reuse the slice as soon as Write returns. A text message must be valid UTF-8; one that is not is refused before anything reaches the wire, since sending it would oblige the peer to close the connection.
func (*WSConn) WriteBinary ¶
WriteBinary sends a binary message.
type WSDialOptions ¶
type WSDialOptions struct {
// HTTPClient issues the handshake request, which is how a dialer reaches a
// server on a network of its own, such as the in-process one a test client
// serves over. It defaults to a fresh [net/http.Client].
//
// A client with a Timeout is used with that timeout removed, because it
// would otherwise apply to the whole life of the connection rather than to
// the handshake and cut the conversation short.
HTTPClient *http.Client
// Header carries extra request headers, which is where an Authorization
// header or a cookie belongs. The headers the handshake defines are set
// afterwards and cannot be overridden.
Header http.Header
// Subprotocols lists the subprotocols to offer, in order of preference.
// The server picks one of them or none at all, and a server that picks
// something else is refused.
Subprotocols []string
// ReadLimit, ReadTimeout, WriteTimeout and CloseGracePeriod configure the
// connection exactly as the matching fields of [WSOptions] do for a served
// one.
ReadLimit int64
ReadTimeout time.Duration
WriteTimeout time.Duration
CloseGracePeriod time.Duration
}
WSDialOptions configures WSDial.
The zero value dials with the default HTTP client, offers no subprotocol and applies the same connection defaults a served connection gets.
type WSHandler ¶
WSHandler is the shape every Muzak WebSocket handler takes.
In is bound from the request exactly as it is for any other route, from the path, query string, headers and cookies of the handshake. There is no output type: what a WebSocket route produces is a conversation, written to conn.
Returning nil closes the connection normally. Returning a *WSCloseError closes it with the status and reason it carries, which is how a handler rejects a peer on its own terms. Any other error closes the connection with WSStatusInternalError and is logged, with nothing about it disclosed to the peer.
type WSMessageType ¶
type WSMessageType uint8
WSMessageType says what a WebSocket message carries.
const ( // WSText is a message whose payload is UTF-8 text. The protocol requires // the encoding, so a text message is validated on the way in and on the // way out. WSText WSMessageType = WSMessageType(wsframe.Text) // WSBinary is a message whose payload is arbitrary bytes. WSBinary WSMessageType = WSMessageType(wsframe.Binary) )
func (WSMessageType) String ¶
func (t WSMessageType) String() string
String names the message type, for logs and error messages.
type WSOptions ¶
type WSOptions struct {
// ReadLimit is the largest message accepted, in bytes, defaulting to
// [DefaultWSReadLimit]. A message that would exceed it is refused with
// [WSStatusMessageTooBig] before any of it is buffered, so a peer can
// never choose how much memory the server spends. There is deliberately no
// way to remove the limit.
ReadLimit int64
// WriteTimeout bounds how long one message may take to reach the peer,
// defaulting to [DefaultWSWriteTimeout]. It is what stops a peer that has
// stopped reading from pinning a goroutine and a send buffer forever. A
// negative value removes the bound, which is only appropriate when
// something else imposes one.
WriteTimeout time.Duration
// ReadTimeout bounds how long one message may take to arrive once its
// first frame has, defaulting to [DefaultWSReadTimeout]. It is what stops
// a peer dribbling a message out a byte at a time and holding a goroutine
// for as long as it cares to.
//
// It does not bound how long a connection may sit idle between messages,
// because waiting is what most connections are for. Use PingInterval to
// notice a peer that has stopped answering at all. A negative value
// removes the bound.
ReadTimeout time.Duration
// MaxConnections is how many WebSocket connections the application will
// hold open at once, defaulting to [DefaultWSMaxConnections]. A handshake
// arriving once the limit is reached is refused with 503 and a Retry-After
// header rather than accepted into a process that has no room for it.
//
// Unlike every other field here it may only be set on the application: the
// resource it protects is the process, not a route, so a router or a route
// that sets it is refused when the application is built. A negative value
// removes the limit, which is only appropriate where something else is
// counting.
MaxConnections int
// MaxConnectionsPerIP is how many WebSocket connections a single client
// address may hold open at once, defaulting to
// [DefaultWSMaxConnectionsPerIP]. A handshake that would exceed it is
// refused with 503 and a Retry-After header, the same as MaxConnections,
// but for one client rather than for the process.
//
// MaxConnections alone bounds the process; it does not bound one client
// within it. A WebSocket handshake needs no Origin header at all to
// succeed - only a browser sends one, and the origin check exists to stop
// browser-based hijacking, not to authenticate a client - so a single
// unauthenticated, non-browser client can open connections in a tight
// loop and hold every one of MaxConnections' slots itself, leaving 503
// for everyone else until it disconnects. This is what stops that: no
// matter how many connections the process has room for, one address can
// never hold more than this many of them.
//
// The address used is the one [Context.ClientIP] resolves, the same
// spoof-resistant resolution the rate limiter's default [IPTracker] uses;
// see [ClientIPOptions] to configure it behind a proxy. Like
// MaxConnections, this may only be set on the application, because the
// dimension it bounds is a client's share of the process, not of one
// route: a router or a route that sets it is refused when the
// application is built. A negative value removes the limit, which is
// only appropriate where something else is counting per client, such as
// a reverse proxy already capping connections per source address.
MaxConnectionsPerIP int
// CloseGracePeriod is how long a closing connection waits for the peer's
// close frame before the transport is torn down, defaulting to
// [DefaultWSCloseGracePeriod]. Waiting briefly is what lets the peer read
// the close frame instead of finding the connection reset. A negative
// value closes immediately.
CloseGracePeriod time.Duration
// PingInterval turns on keepalive: the server pings this often and closes
// the connection when the peer stops answering, which is what notices a
// connection dropped by a network that told nobody. It is off by default.
//
// Keepalive only works while the handler is reading, because a pong is
// consumed by a read like any other frame. A handler that only ever writes
// should ping by hand instead, with [WSConn.Ping].
PingInterval time.Duration
// PongTimeout is how long a keepalive ping waits for its answer, defaulting
// to [DefaultWSPongTimeout]. It is meaningful only alongside PingInterval.
PongTimeout time.Duration
// MessageLimits bounds how fast a peer may send messages, using the same
// quotas, storage and tracker as [RateLimitOptions] does for requests. It
// is unset by default, which leaves a connected peer free to send as fast
// as it likes within ReadLimit and ReadTimeout.
//
// ReadLimit bounds what one message costs and MaxConnections bounds how
// many peers there are, but neither bounds a peer that stays inside both
// and simply never pauses. This is what does:
//
// muzak.WithWebSocket(muzak.WSOptions{
// MessageLimits: []muzak.Quota{{Name: "ws-messages", Window: time.Second, Limit: 20}},
// })
//
// Messages are what is counted, one per message the handler reads, which
// bounds the scheduling a chatty peer costs; ReadLimit is what bounds the
// bytes. A peer that goes over is closed with
// [WSStatusPolicyViolation] rather than left connected and ignored,
// because a message silently dropped is a protocol nobody can debug. The
// count happens after the message has been read, so the limit bounds a
// sustained rate rather than refusing the message that crossed it.
//
// Counting a message costs whatever a storage round trip costs, so a
// shared storage on a chatty connection is a real expense; prefer a window
// long enough that the count is not the conversation's bottleneck. The
// quotas share a namespace with those of [RateLimitOptions], so give them
// names of their own unless a shared budget is what is wanted. A route
// marked [SkipRateLimit] counts no messages either.
MessageLimits []Quota
// Subprotocols lists the subprotocols the route can speak, such as
// "graphql-transport-ws". The client's own list is in preference order, so
// the first of its choices that appears here is the one negotiated, and a
// client asking for something else is answered without the header, which
// tells it to give up.
Subprotocols []string
// AllowedOrigins lists the browser origins permitted to open a connection,
// such as "https://app.example.com", in addition to the server's own
// origin, which is always allowed. The single entry "*" allows any origin.
//
// The check exists because a WebSocket handshake is not subject to the
// same-origin policy and is not preflighted: without it, any page on the
// internet could open an authenticated connection to this server from a
// visitor's browser, cookies and all. Note that [AppOptions.CORS] has no
// bearing on it, for exactly that reason.
AllowedOrigins []string
// AllowOriginFunc decides dynamically whether an origin may connect. It is
// consulted only for an origin that AllowedOrigins and the same-origin rule
// did not already allow, and it runs on every handshake, so it must be
// cheap and free of side effects.
AllowOriginFunc func(r *http.Request, origin string) bool
// InsecureSkipOriginCheck accepts a handshake from any origin, including a
// browser page hosted anywhere on the internet.
//
// It is safe only for a connection that carries no ambient authority: one
// authenticated by a token the client has to present explicitly, never by
// a cookie, since a browser attaches cookies to a cross-origin handshake
// without being asked.
InsecureSkipOriginCheck bool
}
WSOptions configures WebSocket connections.
It can be set application-wide through AppOptions.WebSocket and narrowed for a router or a single route with WithWebSocket. Layering works field by field: whatever a narrower scope leaves at its zero value it inherits, so a route that raises only the read limit keeps the application's origin policy.
The zero value is usable and safe: messages are bounded, writes are bounded, and a cross-origin handshake is refused.
type WSStatus ¶
type WSStatus uint16
WSStatus is a WebSocket close status code, the two byte number that says why a connection ended.
The codes below 3000 are the ones RFC 6455 defines. Everything from 3000 to 3999 is registered by libraries and everything from 4000 to 4999 is free for an application to define, which is where a status meaningful to one protocol rather than to WebSocket itself belongs.
const ( // WSStatusNormalClosure reports a connection closed because whatever it // was opened for is finished. It is what Muzak sends when a handler // returns without an error. WSStatusNormalClosure WSStatus = 1000 // WSStatusGoingAway reports an endpoint that is disappearing, such as a // server shutting down or a browser navigating away. WSStatusGoingAway WSStatus = 1001 // WSStatusProtocolError reports a frame that breaks the protocol. WSStatusProtocolError WSStatus = 1002 // WSStatusUnsupportedData reports a message of a type the endpoint cannot // accept, such as binary where only text is understood. WSStatusUnsupportedData WSStatus = 1003 // WSStatusNoStatusReceived reports a close frame that carried no code. WSStatusNoStatusReceived WSStatus = 1005 // WSStatusAbnormalClosure reports a connection lost without a close frame, // which is what a dropped network or a killed process looks like. WSStatusAbnormalClosure WSStatus = 1006 // WSStatusInvalidFramePayload reports a payload that is not what its type // promised, such as a text message that is not valid UTF-8. WSStatusInvalidFramePayload WSStatus = 1007 // WSStatusPolicyViolation reports a message refused on policy grounds, // which is the general code for a rejection with no more specific one. WSStatusPolicyViolation WSStatus = 1008 // WSStatusMessageTooBig reports a message larger than the receiver accepts. WSStatusMessageTooBig WSStatus = 1009 // WSStatusMandatoryExtension reports a client giving up because the server // declined an extension it required. WSStatusMandatoryExtension WSStatus = 1010 // WSStatusInternalError reports a condition that stopped the endpoint from // fulfilling the request. It is what Muzak sends when a handler returns an // error or panics. WSStatusInternalError WSStatus = 1011 // WSStatusServiceRestart reports a server restarting. WSStatusServiceRestart WSStatus = 1012 // WSStatusTryAgainLater reports a server refusing because it is overloaded. WSStatusTryAgainLater WSStatus = 1013 // WSStatusBadGateway reports a gateway that received an invalid response // from the server it forwards to. WSStatusBadGateway WSStatus = 1014 // WSStatusTLSHandshake reports a TLS handshake that failed. WSStatusTLSHandshake WSStatus = 1015 )
The close status codes defined by RFC 6455.
Three of them describe a local observation rather than something a peer said: WSStatusNoStatusReceived, WSStatusAbnormalClosure and WSStatusTLSHandshake are reported by Muzak but are never written to the wire, and passing one to WSConn.Close closes without a status code.
func WSCloseStatus ¶
WSCloseStatus returns the status a WebSocket connection was closed with, and reports whether err describes a closure at all.
It is how a handler tells a polite goodbye from a protocol violation:
if status, ok := muzak.WSCloseStatus(err); ok && status == muzak.WSStatusNormalClosure {
return nil
}
Source Files
¶
- auth.go
- binding.go
- clientip.go
- compress.go
- config.go
- context.go
- di.go
- doc.go
- docs.go
- errors.go
- frontend.go
- html.go
- lifecycle.go
- logging.go
- middleware.go
- muzak.go
- openapi.go
- ratelimit.go
- ratelimit_memory.go
- registry.go
- router.go
- server.go
- sse.go
- sse_client.go
- sse_route.go
- statuserrors.go
- upload.go
- validation.go
- versioning.go
- websocket.go
- websocket_client.go
- websocket_route.go
Directories
¶
| Path | Synopsis |
|---|---|
|
internal
|
|
|
radix
Package radix implements the path-matching tree that backs Muzak's router.
|
Package radix implements the path-matching tree that backs Muzak's router. |
|
wsframe
Package wsframe implements the WebSocket wire format of RFC 6455.
|
Package wsframe implements the WebSocket wire format of RFC 6455. |
|
Package testclient exercises a Muzak application over a real HTTP connection from inside a Go test.
|
Package testclient exercises a Muzak application over a real HTTP connection from inside a Go test. |
|
Package validate holds the rules Muzak applies to a bound request.
|
Package validate holds the rules Muzak applies to a bound request. |
