- Go 100%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
The package doc and the README now cover trusted proxies, the CSRF check, request ids and caching, in the same order and the same voice as the sections beside them. One decision is stated where it is easy to get wrong: the readers are Context methods — c.ClientAddr, c.CSRFToken, c.RequestID — and not package functions over a bare *http.Request. Each is a value a middleware stored in the request's own context under a key that is this package's business, so a reader outside the chain could only ever return the zero one. |
||
| cmd/test | ||
| testdata | ||
| tplx | ||
| urlenc | ||
| .gitignore | ||
| bench_test.go | ||
| btest.sh | ||
| chain.go | ||
| chain_test.go | ||
| content.go | ||
| context-contenttype_test.go | ||
| context-decode.go | ||
| context-decode_test.go | ||
| context-json_test.go | ||
| context-pathprefix_test.go | ||
| context-problem_test.go | ||
| context-redirect_test.go | ||
| context-status.go | ||
| context-status_test.go | ||
| context.go | ||
| csrf.go | ||
| csrf_test.go | ||
| debug.go | ||
| doc.go | ||
| errorhandler.go | ||
| errorhandler_test.go | ||
| errors.go | ||
| etag.go | ||
| etag_test.go | ||
| exact_method_test.go | ||
| go.mod | ||
| handler.go | ||
| handler_test.go | ||
| header_path_test.go | ||
| health.go | ||
| health_test.go | ||
| http.go | ||
| license.txt | ||
| maxbody.go | ||
| maxbody_test.go | ||
| method.go | ||
| method_head_test.go | ||
| method_test.go | ||
| middleware_test.go | ||
| panic.go | ||
| panic_test.go | ||
| path.go | ||
| path_test.go | ||
| rand.go | ||
| readme.md | ||
| redirect.go | ||
| redirect_flush_test.go | ||
| render.go | ||
| render_test.go | ||
| requestid.go | ||
| requestid_test.go | ||
| response_test.go | ||
| root.go | ||
| root_chain_test.go | ||
| root_test.go | ||
| routing_test.go | ||
| static.go | ||
| static_test.go | ||
| staticerror.go | ||
| swapper.go | ||
| swapper_test.go | ||
| tracker.go | ||
| trustproxy.go | ||
| trustproxy_test.go | ||
goss
Go Simple Server — a small HTTP library for writing backends and connecting
them to a frontend. Built on net/http, so standard handlers, servers and
cookies keep working.
Routing is a tree of path segments and methods. Everything that can handle a
request is a Handler, including routers, so they compose without special
cases.
Install
go get codeberg.org/mojosa/goss
Requires Go 1.23 or newer.
Quick start
package main
import (
"log"
"net/http"
"codeberg.org/mojosa/goss"
)
// App is the process-wide state handed to every request.
type App struct {
Greeting string
}
var api = goss.Paths().
Route("hello", goss.Methods().
RouteFunc(http.MethodGet, hello).
RouteFunc(http.MethodPost, echo)).
Route("static/", goss.StaticDir("./static")).
FallbackFunc(notFound)
func main() {
root := goss.New(&App{Greeting: "hello"}, api)
log.Fatal(http.ListenAndServe(":8080", root))
}
func hello(c *goss.Context) {
app := c.State.(*App)
c.JSON(map[string]any{"msg": app.Greeting})
}
func echo(c *goss.Context) {
var body map[string]any
if err := c.DecodeJSON(&body); err != nil {
c.BadRequest(err)
return
}
c.JSON(body)
}
func notFound(c *goss.Context) { c.NotFound() }
*RootRouter implements http.Handler, so it goes into http.ListenAndServe
or http.Server unchanged.
Routing
Register routes on a tree. Route takes a handler, RouteFunc takes a
function; the same pair exists for Fallback, Exact, OnlyMethod and
Chain.
tree := goss.Paths().
Route("about", showAbout).
Route("notes", showNotes).
Route("notes/", showNote).
FallbackFunc(notFound)
A route matches by prefix, and the handler gets the rest of the path in
c.Path():
// /notes/7 → c.Path() is "7"
func showNote(c *goss.Context) {
id, ok, err := c.PathAsInt64()
...
}
c.URLPath() is always the whole path; c.PathPrefix() is what routing
consumed. Leading and trailing slashes are ignored, so "notes/" and "notes"
name the same route.
By method. A route that answers several methods:
tree.Route("notes", goss.Methods().
RouteFunc(http.MethodGet, listNotes).
RouteFunc(http.MethodPost, createNote))
Other methods get 405 with an Allow header. A route that answers exactly one
uses OnlyMethod:
tree.Route("notes", goss.OnlyMethod(http.MethodPost, createNote))
Registering GET also serves HEAD.
Whole paths. A route is a subtree by default, so "list" also answers
/list/anything with the same handler. When a path names one thing rather than
a section, register it with Exact, and anything below it is a 404:
tree.ExactFunc("list", listHyphae)
An Exact and a Route may share a path: the first answers the path itself,
the second answers what is below it. That is an index page beside a subtree,
and the order of the two calls does not matter.
tree.ExactFunc("notes", listNotes). // /notes
RouteFunc("notes", showNote) // /notes/7 → c.Path() is "7"
Bare handlers. A few handlers need no function of your own:
tree.Route("old-blog", goss.Redirect("/blog", http.StatusMovedPermanently))
tree.Route("intro", goss.RedirectSeeOther("/about"))
tree.Route("debug/", goss.Debug()) // dumps the path parts and query
Removing a route. A nil handler means the route is not there, which is how
a method can be withdrawn from a router. A nil anywhere at construction — New,
NewSwapper, StaticFSys — panics while the server is being built.
Middleware
A middleware wraps a handler. Chain a list of them, first element outermost:
mws := goss.MiddlewareChain{logRequests, loadSession}
root := goss.New(app, mws.Chain(tree))
Chained around the whole tree, as above, the middleware sees every request including those no route matches — the place for logging and session loading. Chained around one registration, it wraps only that handler:
var locked = goss.MiddlewareChain{requireLogin}
tree.Route("admin", locked.ChainFunc(adminHandler))
Chain takes a handler, ChainFunc a function. A chain is itself a
middleware, so chains nest. The list is composed once, when Chain runs, not
per request.
Context
c carries the request, the response writer and the per-request state.
Read the request
c.Method() c.URLPath() c.Path()
c.Query() c.Header(name) c.Referer()
c.Cookie(name) c.Cookies()
Decode a body. DecodeAuto picks by content type: JSON or a form.
var req CreateNote
if err := c.DecodeAuto(&req); err != nil {
c.BadRequest(err)
return
}
DecodeJSON, DecodeForm and DecodeQuery choose explicitly — the last reads
the query string, which has no content type of its own.
All of them check the Content-Type before reading, with one rule: a header that
is present and names a different format is an error, and an absent header is
not. So a JSON body handed to DecodeForm is refused rather than decoded into
an empty struct, while a client that leaves the header off still works.
DecodeJSON application/json, any type ending in +json, or absent
DecodeForm application/x-www-form-urlencoded, or absent
DecodeQuery no check: a query string is not a body
DecodeAuto must be present, since the header is the whole choice
The refusal is ErrUnknownContentType, which BadRequest maps to 415
Unsupported Media Type — the format is wrong, not the request. A multipart body
is refused too: it needs ParseMultipartForm, and a form with a file input
sends exactly that type.
The two form-encoded decoders also differ in what a key means. DecodeForm reads
a[b] as nesting, because a form body is where that convention lives;
DecodeQuery takes every key literally, because a query string usually means
?q=harvest&tags=a&tags=b and a stray bracket should not be able to fail it.
var q struct {
Q string
Tags []string
}
c.DecodeQuery(&q) // ?q=harvest&tags=a&tags=b
For bracket nesting from a query string, call urlenc.Unmarshal on
c.R.URL.RawQuery yourself. urlenc (see Other packages)
binds form fields to struct fields either way, converting ints, bools, floats
and time.Duration, and collecting repeated keys into slices.
Write a response
c.JSON(v) c.Textf("hello %s", name)
c.SetStatus(code) c.SetHeader(k, v) c.SetContentType(typ)
c.SetCookie(cookie) c.ServeFile(name)
c.Redirect(url, status) c.RedirectSeeOther(url)
The first status or body write fixes the response; net/http's rules apply
from there. c.W is the server's writer, so streaming works — flush through
http.NewResponseController(c.W).
c.State is the value passed to goss.New, the same for every request.
c.Env is scratch space for middleware to pass data down.
Fail
c.NotFound() c.Unauthorized() c.Forbidden()
c.BadRequest(err) c.InternalServerError(err)
c.PayloadTooLarge() c.UnsupportedMediaType()
c.NoContent()
These answer like net/http does: NotFound writes 404 page not found, the
rest write their status text, and NoContent writes nothing because 204 must be
bodyless. An error passed to BadRequest or InternalServerError is logged,
never sent — error strings carry SQL, paths and internal identifiers. Use
c.Error(msg, status) for a message the client should read, or
c.SetStatus(code) to set a status and render your own page.
BadRequest reads the errors it is given and answers more precisely than 400
when it can: an *http.MaxBytesError becomes 413, and an error matching
ErrUnknownContentType becomes 415. That is what a decoder failure turns into
when a handler reports it the usual way:
if err := c.DecodeForm(&form); err != nil {
c.BadRequest(err) // 415 for a wrong Content-Type, 400 otherwise
return
}
Error pages
By default a refused request gets net/http's answer. Give the root a handler
to use your own:
root := goss.New(app, tree).WithError(func(c *goss.Context, status int) {
switch status {
case http.StatusNotFound:
c.SetStatus(status)
c.MustRender("not-found", nil)
default:
c.DefaultError(status)
}
})
The handler owns the response, including its status, and is reached from any
depth of nesting. c.DefaultError(status) writes what goss would have written,
so a handler owns only the statuses it means to.
Every refusal goes through it — 404, 405, 403, 401, 413, 415, 400, 500 — and so do static file misses and a panicking handler. If the response had already started, a panic is re-raised instead and the connection is aborted, because a truncated body must not look complete.
Static files
goss.StaticDir("./static") // a directory
goss.StaticFSys(assets) // any fs.FS, such as an embed.FS
goss.StaticFile("./favicon.ico") // one file
//go:embed static
var assets embed.FS
tree.Route("static/", goss.StaticFSys(assets))
Use fs.Sub to serve a subtree of an fs.FS. Dotfiles are not served unless
you call ShowHidden(true). A miss goes through your error handler like any
other refusal.
To serve a standard http.Handler, wrap it: goss.StdAdapter{StdHandler: h}.
Set UseRelativeURL: true to let it see c.Path() rather than the whole path.
Templates
tplx parses a directory of layouts and views into a Renderer:
tmpls := tplx.MustParseDir("./templates", tplx.StdFuncMap)
root := goss.New(app, mws.Chain(tree)).WithRenderer(tmpls)
c.Render("default;home", data) // layout "default", view "home"
c.Render("home", data) // same, default layout
Layouts live in templates/, views in views/, components in components/.
tplx.Templates satisfies Renderer directly.
A template that fails part-way has already written its output, so wrap the renderer to keep the failure out of the response:
.WithRenderer(goss.NewBufferedRenderer(tmpls))
Replacing the tree at runtime
A Swapper holds a handler you can replace while requests are running:
sw := goss.NewSwapper(tree)
root := goss.New(app, sw)
http.ListenAndServe(":8080", root)
// later, from another goroutine:
sw.Set(newTree)
The root is never rebuilt, so State, the middleware around it and the context
pool survive. A request in flight finishes on the tree it started with; the next
one sees the new one.
Limiting the body
MaxBody is opt-in, like every middleware:
root := goss.New(app, goss.MiddlewareChain{goss.MaxBody(1 << 20)}.Chain(tree))
A request whose declared length is over the limit is answered 413 and never
reaches a handler. A chunked upload has no declared length, so the handler runs
with a capped body and a read past the limit returns *http.MaxBytesError —
report it, and BadRequest turns it into 413:
if err := c.DecodeForm(&form); err != nil {
c.BadRequest(err)
return
}
Without MaxBody there is no limit, and DecodeForm reads the whole body into
memory.
Health checks
Health answers a probe. With no checks it is a liveness probe — it answers 200
when the process can run a handler, and claims nothing about anything else:
tree.Exact("/healthz", goss.Health())
With checks it is a readiness probe: 200 when all of them pass, 503 when any fails, and the failures are named so a red probe says what to fix:
tree.Exact("/healthz", goss.Health(
goss.Check{Name: "db", Check: db.Ping},
goss.Check{Name: "cache", Check: cache.Healthy},
))
The checks run concurrently under one deadline ending in 503, so N
dependencies cost one round trip, and a dependency that has wedged cannot hold
the answer past the point where the prober gave up. A check is handed a context
with that deadline on it and must honour it; one that does not is reported as
timed out, and its goroutine outlives the response. DefaultHealthTimeout is
the default budget.
503 rather than 500 is deliberate: the service is running and its dependency is not, which is a different thing to fix. The body is JSON:
{"status":"unavailable","failures":[{"name":"db","error":"connection refused"}]}
A panicking check is reported as a failure; it does not take the server down. A
Check with no function is a mistake in the program and panics while the tree
is being built, like every other goss constructor.
The answer carries Cache-Control: no-store. A cached health response reports
the health of the past, which is worse than no response. Failure text describes
how the service is put together, so mount the route where only the operator and
the prober can reach it.
Register it outside whatever middleware chain the app builds. A probe should not be behind a session check, a body limit or a request log that it will fill one entry per ten seconds.
Other packages
urlenc decodes application/x-www-form-urlencoded into structs, which the
standard library does not do. It converts values to the field type, so int,
bool, float64, time.Duration and encoding.TextUnmarshaler decode
directly. A repeated key keeps the last value for a scalar field and collects
into a slice.
There are two entry points, differing only in what a key means:
urlenc.Unmarshalreads PHP-style nesting:user[name]=ann,tags[]=x&tags[]=y,items[][id]=1. An unbalanced key such asa[bisErrMalformedKey.urlenc.UnmarshalFlattakes every key literally, souser[name]is the keyuser[name]and no bracket can make the decode fail. This is whatContext.DecodeQueryuses.
Client address and trusted proxies
Behind a reverse proxy, RemoteAddr is the proxy, so anything that counts
requests per client — a sign-in limiter, an audit log — sees one address for
the whole internet. TrustProxies resolves the visitor's:
proxies, err := goss.NewTrustProxies("10.0.0.0/8", "192.168.1.5")
if err != nil {
log.Fatal(err) // a bad entry in the operator's configuration
}
root := goss.New(app, goss.MiddlewareChain{
proxies,
}.Chain(tree))
proxies.Resolve(r) is the pure form: it takes a request and computes, with no
context involved, for a caller that would rather not mount anything.
c.ClientAddr() reads what the middleware stored. The header read is
X-Forwarded-For, and only when the peer is on the list: a client that can reach
the service directly must not be believed, or it could choose the address it is
counted under. A nil *TrustProxies trusts nothing.
The chain is read from the right, skipping hops that are themselves trusted
proxies, so a forged X-Forwarded-For — written by the client and appended to by
the proxy — is never reached. A malformed entry discards the header entirely
rather than walking past it into a value the client chose.
The CSRF check
NewCSRF refuses a state-changing request that does not carry a token matching
the one derived from its cookie:
csrf := goss.NewCSRF(goss.CSRFConfig{
CookieName: "app_csrf",
Key: key, // 32 bytes, never sent to the client
})
root := goss.New(app, goss.MiddlewareChain{
goss.MaxBody(1 << 20), // first: the check reads a form body
csrf,
}.Chain(tree))
The token is an HMAC of the cookie, not the cookie itself. Plain double-submit — where the field must equal the cookie — is defeated by any sibling subdomain that can set a cookie on the parent domain; the key closes that.
Handlers read the token with c.CSRFToken() and put it in every form. The
check parses the body and puts a urlencoded one back, so DecodeForm in the
handler still works. GET, HEAD, OPTIONS and TRACE are not checked;
everything else is.
Secure is a function rather than a flag, so a service whose scheme can change
after startup keeps telling the truth. The key is a program-level requirement:
NewCSRF panics on a missing cookie name or a short key.
Request IDs
root := goss.New(app, goss.MiddlewareChain{goss.WithRequestID()}.Chain(tree))
// later, anywhere:
id := c.RequestID()
Each request gets a random identifier, sent back in X-Request-Id and published
through c.RequestID(), so a failure report can be joined to every line the
service logged about that request. An incoming X-Request-Id is ignored: a client that
could choose it could also collide it with another request's.
Caching
ETag is a strong tag over a response body; ETagMatches reads
If-None-Match. For content that is generated rather than read from a file,
ETagCache buffers the response up to a limit and answers 304 when the client
already has those bytes:
root := goss.New(app, goss.MiddlewareChain{
goss.ETagCache(goss.ETagConfig{
Limit: 1 << 20,
MaxAge: time.Minute,
Only: func(c *goss.Context) bool { return strings.HasPrefix(c.URLPath(), "/static/") },
}),
}.Chain(tree))
Only a 200 is tagged; an error page and a redirect are replayed unchanged.
Above Limit the response streams through untagged rather than being truncated.
Limits
- A large upload without
MaxBodyis read into memory in full. - A panic after the first write cannot be answered; the connection is aborted.
- A router mounted under its own path recurses until the stack dies. Not detected: checking every request would cost more than the bug it catches.
- Replacing the tree costs one atomic load per request; services that never swap pay nothing.
The demo in cmd/test shows the wiring, including a form with a CSRF token and
a redirect after POST. It is not a production template: no rate limiting on
login, and the cookie is not marked Secure.
License
MIT, see license.txt.