A simple way of creating efficient HTTP APIs in golang using conventions over configuration.
To install govalin run:
go get -u github.com/pkkummermo/govalinfunc main() {
govalin.New().
Get("/test", func(call *govalin.Call) {
call.Text("Hello world")
}).
Start(7070)
}A route registered with Get also answers HEAD, as HTTP defines it: the handler runs and its body
is dropped. Register Head explicitly on a route that can answer without producing one, and it wins.
ValidatedBody unmarshals the request body and checks it, naming each field with an accessor:
app.Post("/users", func(call *govalin.Call) {
var user User
err := call.ValidatedBody(&user).
StringField("name", func(u User) string { return u.Name }).Required().MinLength(2).
StringField("email", func(u User) string { return u.Email }).Required().Email().
IntField("age", func(u User) int { return u.Age }).Min(18).Max(100).
Get()
if err != nil {
call.Error(err)
return
}
call.JSON(user)
})- The accessor is what makes the chain safe: a misspelled field, a
MinLengthon an int, or a rule meant for another body are all compile errors rather than surprises at request time. - The name is yours to choose, and a failure is reported under it — pass the name the client sent, not the Go field name.
Customreceives the whole body, so a check can depend on other fields while reporting under the field it is chained onto — aconfirmthat has to matchpassword, say. Chained before the first field it reports under"body"instead.- Query, path and form parameters validate the same way, one value at a time:
call.ValidatedQueryParam("name").Required().MinLength(3).Get()returns the value and an error. - Lengths count characters, not bytes, so
caféis four of them and not five.
Text, HTML and JSON are for bodies that fit in memory. For anything bigger, Call streams:
govalin.New().
// Any reader, of any length: nothing is buffered.
Get("/logs", func(call *govalin.Call) {
if err := call.Stream("text/plain", logReader()); err != nil {
call.Error(err)
}
}).
// Seekable content, with Range support: resumable downloads and seeking.
Get("/files/{name}", func(call *govalin.Call) {
file, err := os.Open(call.PathParam("name"))
if err != nil {
call.Error(err)
return
}
defer file.Close()
info, _ := file.Stat()
call.ServeContent(info.Name(), info.ModTime(), file)
}).
Start(7070)Stream(contentType, reader)copies a reader onto the response. Unknown length, no ranges.ServeContent(name, modTime, readSeeker)handlesRange,If-Range,206,416,304,HEADandContent-Lengthfor you, and picks the content type from the name.ServeContentAt(name, modTime, readerAt, size)is the same for content you can only read at an offset — a remote object exposing ranged GETs — so a multi-gigabyte object is served with working Range requests and nothing buffered.Download(filename, modTime, readSeeker)isServeContentoffered as an attachment to save.
Stream returns a failure to read your content. A failure to write it out — a client that hangs up
mid-body — is not returned: the header is already sent, so there is nothing left to answer with. It
is logged at debug level.
NotModified answers a client that already has the resource, so a revalidation costs headers
instead of a body:
govalin.New().
Get("/users/{id}", func(call *govalin.Call) {
user := store.Load(call.PathParam("id"))
if call.NotModified(user.Version) {
return
}
call.JSON(user)
}).
Start(7070)The tag is yours, out of something you already know — a row version, an updated_at, a digest.
Govalin never hashes a response body to make one up: by the time it could, your handler has already
done all the work the 304 exists to avoid.
- The tag is sent as the
ETageither way, quoted if you did not quote it. - A
truereturn means the client'sIf-None-Matchnamed this version, compared the weak way RFC 9110 requires. It is permission to skip the work, not an obligation — a body written anyway is refused, and the client still gets a correct 304. - Only
GETandHEADare answered this way. On a write,If-None-Matchasks a different question that govalin does not answer for you. - To apply one validator to a group of routes, return it from a
Beforehandler:app.Before("/api/*", func(call *govalin.Call) bool { return !call.NotModified(version()) }).
Static mounts do this for themselves, with no configuration. A file served from disk is validated by when it changed and how big it is; an embedded file, which has no modification time to offer, is validated by a hash of its content, computed once per file. Both are strong tags, so resumable downloads stay resumable.
A validator makes a revalidation cheap. What makes it unnecessary is a lifetime: until it runs out, the client serves the response from its own cache and never asks.
call.CacheFor(10 * time.Minute) // Cache-Control: max-age=600
call.CachePrivateFor(1 * time.Minute) // ...and only in the browser that asked for it
call.NoCache() // keep it, but check with me before every reuse
call.NoStore() // don't keep it at allCacheForsays how long, and nothing about who — a shared cache decides that for itself.CachePrivateForis the shape a response behind a login needs, andNoStorethe one for a body that must not outlive the request.- Whichever you call last is the one that answers, so a lifetime declared for a group can be overridden by the single route that must not be stored.
- A response that sets a cookie is the exception: it is narrowed to the client that asked for it,
whatever lifetime you declare. A session-minting response is
private, andCacheFor(time.Hour)on top of it comes outprivate, max-age=3600— the browser keeps its copy for the hour, and no cache in between gets to hand your visitor's session to the next one. - Declare it where you know what you are answering. One declared up front — in a
Beforehandler, say — lands on whatever that route ends up sending, and an explicit lifetime is exactly what makes a 404 or a 500 storable and reusable for its duration. - A 304 carries the lifetime too: revalidating renews the stored copy instead of leaving the client asking on every reuse.
- No
Expiresis sent. Wherever both appear,max-ageis the one every cache since HTTP/1.1 reads.
Static mounts declare no lifetime unless you ask for one. A validator is advisory, but a lifetime is a promise — an asset served an hour out of date is not something to inherit from a framework — and nothing govalin knows about a directory says how long its files stay current. One line says it:
app.Static("/assets", func(_ *govalin.Call, config *govalin.StaticConfig) {
config.WithFS(assets).CacheFor(time.Hour)
})That is what turns an embedded bundle from one round trip per asset per page load into none.
On a mount in SPA mode the shell is the exception, and govalin makes it for you: index.html is
served no-cache whatever the mount declares, at the mount root, at its own URL and at every
client-side route it answers. The shell names the hashed bundles, so a client holding an old copy
asks for assets the deploy has already replaced — and being served at every URL the app routes, a
stale one is the whole app. Checking is cheap: the derived validator answers it with a 304.
That is the split an asset pipeline is built around. Give the fingerprinted files a long lifetime, and let the one file that names them stay honest:
app.Static("/", func(_ *govalin.Call, config *govalin.StaticConfig) {
config.WithFS(bundle).EnableSPAMode(true).CacheFor(24 * time.Hour)
})A validator and a lifetime both say whether a stored response may be reused. Neither says which one. If your response depends on something the client asked for, say so:
call.VaryOn("Accept-Language") // Vary: Accept-LanguageWithout it the response is stored under its URL alone, and a cache in between hands your Norwegian copy to the next reader who asked for English — for the whole lifetime you declared, since that is what keeps them from asking.
- It adds rather than replaces, so a plugin varying on
Originand the handler below it varying onAccept-Languageboth end up on the response. Declaring the same header twice declares it once. - Declare it before you answer a revalidation. A 304 carries it, but
NotModifiedreturning true is where the handler stops — anything declared after that never reaches the response. - The CORS plugin does this for itself: every response from an app that installs it varies on
Origin, because that is the header the plugin read. - For a response no cache may reuse,
NoStoresays it.Vary: *reads as "varies on everything" and means the same thing, less clearly. - Behind
CachePrivateForit matters less: a private cache holds one client's copies, so a session cookie there selects between responses that client would get anyway. In a shared cache it is the difference between one client's response and another's.
Static mounts declare nothing, and need nothing: a mount serves one representation per URL.
Govalin ships a test harness, govalintest, for testing your application over real HTTP. Hand it your own app — built by your own constructor, with your config, plugins and routes — and it starts it on an OS-assigned port, waits for it to be ready, and shuts it down when the test finishes:
import (
"testing"
"github.com/pkkummermo/govalin/govalintest"
)
func TestMyAPI(t *testing.T) {
govalintest.Test(t, myapp.New(), func(client *govalintest.Client) {
if body := client.Get("/health"); body != "ok" {
t.Errorf("expected ok, got %q", body)
}
})
}A few things to know:
- Failures fail the calling test (
t.Fatalf), never the test process. - The body-returning verbs (
Get,Post, ...) return the body regardless of status code, so you can assert on error responses. Use the*Responsevariants (GetResponse, ...) to assert on status codes and headers. Post,PutandPatchsendstring,[]byteandio.Readerbodies as-is; any other value is JSON-encoded withContent-Type: application/json.- For anything custom (headers, auth, exotic verbs), build an
*http.Requestwith a relative path and pass it toclient.Do(...), or grab the underlying client withclient.HTTP(). client.Websocket(path)connects a websocket to your app.client.GetRange(path, from, to)asks for a byte range, for asserting on206responses.- Harness knobs live in
govalintest.TestWithOptions(t, app, govalintest.Options{...}, fn)— the zero value always means sane defaults.
I love how fast and efficient go is. What I don't like, is how it doesn't create an easy way of creating HTTP APIs. Govalin focuses on pleasing those who want to create APIs without too much hassle, with a lean simple API.
Inspired by simple libraries and frameworks such as Javalin, I wanted to see if we could port the simplicity to golang.
The govalin gopher is a modified version of the Go gopher, which was designed by Renée French and is licensed under CC BY 3.0. The mark it is hugging is the Javalin logo, used with Javalin's permission.