Simple server Golang module
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
surdeus 9961720e43 docs: describe the four additions
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.
2026-10-28 18:28:59 +05:00
cmd/test Rename Decode to DecodeAuto 2026-10-26 05:17:18 +05:00
testdata style(demo): give the demo an intentional look 2026-10-25 04:27:24 +05:00
tplx fix: make the tplx components directory optional 2026-10-23 15:56:30 +05:00
urlenc DecodeQuery reads keys literally 2026-10-26 05:27:50 +05:00
.gitignore refactor!: rename package ss to goss and tidy the public API 2026-10-23 14:34:49 +05:00
bench_test.go Rename Decode to DecodeAuto 2026-10-26 05:17:18 +05:00
btest.sh feat: better testing material, some more methods to the *Context. 2026-05-10 20:41:10 +05:00
chain.go refactor!: one spelling for each thing, with Paths and Methods 2026-10-26 03:05:20 +05:00
chain_test.go refactor!: one spelling for each thing, with Paths and Methods 2026-10-26 03:05:20 +05:00
content.go refactor!: rename package ss to goss and tidy the public API 2026-10-23 14:34:49 +05:00
context-contenttype_test.go Rename Decode to DecodeAuto 2026-10-26 05:17:18 +05:00
context-decode.go Guard the decoders on Content-Type 2026-10-26 05:39:14 +05:00
context-decode_test.go Answer 415 for a Content-Type the decoder cannot read 2026-10-26 05:43:02 +05:00
context-json_test.go feat!: answer refused requests the way net/http does, and let the root own them 2026-10-26 00:51:18 +05:00
context-pathprefix_test.go fix: stop PathPrefix panicking on an inconsistent routing cursor 2026-10-25 03:22:36 +05:00
context-problem_test.go Remove response buffering, simplify middleware, and fix three bugs 2026-10-25 03:01:33 +05:00
context-redirect_test.go feat: add RedirectSeeOther for the post/redirect/get status 2026-10-25 04:21:43 +05:00
context-status.go Answer 415 for a Content-Type the decoder cannot read 2026-10-26 05:43:02 +05:00
context-status_test.go refactor!: Textf returns nothing, and the shorthand bodies are documented right 2026-10-26 03:43:22 +05:00
context.go refactor!: Textf returns nothing, and the shorthand bodies are documented right 2026-10-26 03:43:22 +05:00
csrf.go feat: check state-changing requests against an HMAC token 2026-10-28 18:28:45 +05:00
csrf_test.go feat: check state-changing requests against an HMAC token 2026-10-28 18:28:45 +05:00
debug.go refactor!: rename package ss to goss and tidy the public API 2026-10-23 14:34:49 +05:00
doc.go docs: describe the four additions 2026-10-28 18:28:59 +05:00
errorhandler.go feat!: answer refused requests the way net/http does, and let the root own them 2026-10-26 00:51:18 +05:00
errorhandler_test.go refactor!: one spelling for each thing, with Paths and Methods 2026-10-26 03:05:20 +05:00
errors.go Guard the decoders on Content-Type 2026-10-26 05:39:14 +05:00
etag.go feat: give responses a validator, generated content included 2026-10-28 18:28:55 +05:00
etag_test.go feat: give responses a validator, generated content included 2026-10-28 18:28:55 +05:00
exact_method_test.go Make exactness a property of the route, not a wrapper 2026-10-26 04:09:13 +05:00
go.mod feat: added Cookie interaction shortcuts to the *Context and implemented secure random values generation. 2026-05-11 17:45:45 +05:00
handler.go fix: a directory request with its trailing slash reaches the file server 2026-10-26 03:30:24 +05:00
handler_test.go fix: StdAdapter no longer repoints the request URL 2026-10-23 15:10:43 +05:00
header_path_test.go fix: match media types case-insensitively and exactly 2026-10-25 03:56:57 +05:00
health.go feat: answer health probes 2026-10-27 06:47:43 +05:00
health_test.go feat: answer health probes 2026-10-27 06:47:43 +05:00
http.go refactor!: rename package ss to goss and tidy the public API 2026-10-23 14:34:49 +05:00
license.txt feat: license added. 2024-01-14 23:47:09 +03:00
maxbody.go refactor!: one spelling for each thing, with Paths and Methods 2026-10-26 03:05:20 +05:00
maxbody_test.go refactor!: one spelling for each thing, with Paths and Methods 2026-10-26 03:05:20 +05:00
method.go Make exactness a property of the route, not a wrapper 2026-10-26 04:09:13 +05:00
method_head_test.go refactor!: one spelling for each thing, with Paths and Methods 2026-10-26 03:05:20 +05:00
method_test.go refactor!: one spelling for each thing, with Paths and Methods 2026-10-26 03:05:20 +05:00
middleware_test.go refactor!: one spelling for each thing, with Paths and Methods 2026-10-26 03:05:20 +05:00
panic.go feat!: answer a panicking handler with 500 instead of dropping the connection 2026-10-26 00:57:00 +05:00
panic_test.go refactor!: one spelling for each thing, with Paths and Methods 2026-10-26 03:05:20 +05:00
path.go Make exactness a property of the route, not a wrapper 2026-10-26 04:09:13 +05:00
path_test.go docs: state the nil rules and the function-form convention, and test both routers 2026-10-26 03:39:36 +05:00
rand.go refactor!: rename package ss to goss and tidy the public API 2026-10-23 14:34:49 +05:00
readme.md docs: describe the four additions 2026-10-28 18:28:59 +05:00
redirect.go feat: add RedirectSeeOther for the post/redirect/get status 2026-10-25 04:21:43 +05:00
redirect_flush_test.go feat!: answer a panicking handler with 500 instead of dropping the connection 2026-10-26 00:57:00 +05:00
render.go Remove response buffering, simplify middleware, and fix three bugs 2026-10-25 03:01:33 +05:00
render_test.go feat!: answer refused requests the way net/http does, and let the root own them 2026-10-26 00:51:18 +05:00
requestid.go feat: give every request an identifier 2026-10-28 18:28:49 +05:00
requestid_test.go feat: give every request an identifier 2026-10-28 18:28:49 +05:00
response_test.go feat!: answer a panicking handler with 500 instead of dropping the connection 2026-10-26 00:57:00 +05:00
root.go docs: state the nil rules and the function-form convention, and test both routers 2026-10-26 03:39:36 +05:00
root_chain_test.go refactor!: one spelling for each thing, with Paths and Methods 2026-10-26 03:05:20 +05:00
root_test.go feat!: answer a panicking handler with 500 instead of dropping the connection 2026-10-26 00:57:00 +05:00
routing_test.go Make exactness a property of the route, not a wrapper 2026-10-26 04:09:13 +05:00
static.go feat: static file misses reach the root's ErrorHandler 2026-10-26 03:25:36 +05:00
static_test.go fix: a directory request with its trailing slash reaches the file server 2026-10-26 03:30:24 +05:00
staticerror.go feat: static file misses reach the root's ErrorHandler 2026-10-26 03:25:36 +05:00
swapper.go Remove response buffering, simplify middleware, and fix three bugs 2026-10-25 03:01:33 +05:00
swapper_test.go refactor!: one spelling for each thing, with Paths and Methods 2026-10-26 03:05:20 +05:00
tracker.go feat!: answer a panicking handler with 500 instead of dropping the connection 2026-10-26 00:57:00 +05:00
trustproxy.go feat: resolve the client address behind a trusted proxy 2026-10-28 18:28:38 +05:00
trustproxy_test.go feat: resolve the client address behind a trusted proxy 2026-10-28 18:28:38 +05:00

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.Unmarshal reads PHP-style nesting: user[name]=ann, tags[]=x&tags[]=y, items[][id]=1. An unbalanced key such as a[b is ErrMalformedKey.
  • urlenc.UnmarshalFlat takes every key literally, so user[name] is the key user[name] and no bracket can make the decode fail. This is what Context.DecodeQuery uses.

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 MaxBody is 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.