指南 02
Routing
本页尚未翻译,因此显示的是英文原文。 在 GitHub 上翻译
本页内容
An app is a tug.App: its settings, its routes, and the middleware around
them. Routing is net/http's ServeMux, middleware has net/http's own shape,
and a handler takes a *tug.Ctx and returns an error:
func main() {
app := tug.New() // ADDR or PORT, APP_DEBUG and APP_URL, from the environment
app.Use(middleware.RequestID(), middleware.Logger(), middleware.Recover(), middleware.CSRF())
app.Get("/posts/{id}", showPost).Name("posts.show")
if err := app.Run(); err != nil { // until SIGINT or SIGTERM
log.Fatal(err)
}
}An app made by tug new has the same shape, with pages: see
getting-started.md and pages.md.
examples/api is a whole JSON API.
tug.New() returns an App, with its Config read from the environment
by tug.ConfigFromEnv. A Config passed in is used as it is, so start from
ConfigFromEnv to keep what the environment says:
cfg := tug.ConfigFromEnv()
cfg.ShutdownTimeout = 30 * time.Second
app := tug.New(cfg)Every field has a default, so the zero Config works:
| Field | Default | What it is |
|---|---|---|
Addr |
":8080", or with TLS, ":443" |
Where Run listens. |
TLS |
none: plain HTTP | Has Run serve HTTPS itself, with certificates from files or Let's Encrypt, and plain HTTP beside it, which sends each request to HTTPS. See HTTPS on its own. |
Debug |
false |
Shows a server error with its details: a page of the error, a panic's stack with the app's source around it, the route that answered and the request. It's for development: in production they belong in the log only. See While debugging. |
Editor |
none | The editor Debug's page opens a frame's file in: vscode, cursor, zed, goland or sublime, or a link with {file} and {line} in it. |
ErrorHandler |
DefaultErrorHandler |
Answers the errors handlers return, and the 404s and 405s of requests no route takes. See Errors. |
BodyLimit |
32 MiB | The largest request body Bind reads; a larger one is a 413. |
ShutdownTimeout |
10 seconds | How long Run waits for the requests in flight after SIGINT or SIGTERM. |
Inertia |
none | Renders the app's pages. See pages.md. |
ErrorPage |
none | The Inertia page that errors are shown with. See pages.md. |
Session |
none | Keeps each visitor's session, which carries flash data and validation errors to the next page. See forms.md. |
URL |
none | The app's own address, as https://example.com, which the links that leave the app begin with. See Whole links, and signed ones. |
Keys |
none | The app's keys, which sign the links SignedURL makes. |
Lang |
none: English | The app's languages, which what tug says, and c.T, are said in. See Languages. |
Locale |
none | The language the app has chosen for a request, as a user's, before the browser's Accept-Language. See Languages. |
DevTools |
false |
Keeps an entry of each request for Inertia's DevTools, a panel of the browser's. It's for development, as tug dev runs the app. See pages.md. |
ConfigFromEnv reads ADDR, the address to listen on, such as
127.0.0.1:8080; or else PORT, as platforms such as Cloud Run and Fly.io
set it, so PORT=3000 is :3000; APP_DEBUG, where true or 1 turns
on Debug; APP_URL, the app's address; TLS_CERT and TLS_KEY, a
certificate's files, and HTTP_ADDR, for TLS; APP_EDITOR, the
Editor; and TUG_DEV, which tug dev sets, and which turns on
DevTools. The default address,
:8080, listens on every interface. tug.New panics on a URL that isn't
a scheme and a host, as a mistyped APP_URL should stop the app as it
starts. deployment.md has more on the environment in
production.
app.Run() listens on Addr, returning the error when it can't, and serves
until the process gets SIGINT or SIGTERM. Then it shuts down gracefully: it
stops taking connections and waits up to ShutdownTimeout for the requests
in flight. When they finish in time it returns nil; otherwise it closes
their connections and returns an error that says so. app.Serve(ctx, ln)
does the same on a listener of your own, until ctx is done, and closes
the listener. The server either one starts has a ReadHeaderTimeout of 10
seconds, so a client that sends its headers a byte at a time can't hold a
connection for as long as it likes.
With Config.TLS, Run serves HTTPS on Addr, and HTTP/2 with it, and
plain HTTP on TLS.HTTPAddr, :80, which sends each request to HTTPS,
and shuts both down together: deployment.md
has how, with certificates from Let's Encrypt by package letsencrypt.
Serve serves the listener it's given as it is: a TLS listener serves
HTTPS, and HTTP/2 when its tls.Config offers h2 in NextProtos, which
http.Server adds for ServeTLS alone.
Work that isn't a request, such as a job queue's workers, runs beside the
server with app.Go:
app.Go(q.Run) // a job queue's: func(ctx context.Context) errorRun and Serve start it as they start serving, with a context that's
done as the app shuts down, and wait for it to return before they do. An
error it returns before then shuts the app down, and Run returns it, as
an app whose jobs have stopped shouldn't carry on as though they hadn't.
ServeHTTP starts nothing, nor does tug gen, and Go panics once the
app is serving.
jobs.md has the job queue.
Run is also how tug gen learns the app: it runs the app with TUG_GEN
set, and Run writes the app's TypeScript instead of serving. So add every
route before calling Run; whatever main does before it, it does for
tug gen too. See typescript.md.
An App is an http.Handler, so an http.Server set up by hand can serve
it, without Run's signals and shutdown. A test needs no server at all: it
calls app.ServeHTTP with an httptest.NewRecorder(), as the tests of
examples/api do.
app.Command("users:admin", "make a user an admin: users:admin <email>", func(ctx context.Context, args []string) error {
if len(args) != 1 {
return errors.New("users:admin takes the user's email")
}
return users.MakeAdmin(ctx, args[0])
})An app's binary does more than serve: a fix to its data, an import, a
user made an admin, and the auth starter's jobs, which lists the jobs
that failed. app.Command(name, summary, run) adds one, and Run, given
its name as the binary's first argument, runs it in place of serving,
with the arguments after the name and a context canceled on SIGINT or
SIGTERM, and returns its error, which main stops with:
$ ./blog users:admin ann@example.com
$ ./blog help
Without a command, ./blog serves. Its commands:
./blog users:admin make a user an admin: users:admin <email>
./blog help list these commandsA command runs in the app main made, with its routes, for the links it
makes, its queue, for the jobs it pushes, and its database, and needs the
environment the server does. Neither the server nor what Go runs
starts: a job a command pushes runs on the instances that serve. Its
arguments are its own, to read with package flag or by hand; tug parses
none. A name that isn't a command is an error that says to ask help.
The tug CLI can't run an app's commands, as a deployed app is its binary,
where tug isn't: in a container, docker exec <container> /server users:admin ann@example.com. An app without commands serves whatever its
arguments, and under tug gen, Run writes the types, whatever they
are. A command's name is a word of letters, digits and :-_, and
Command panics on one that's taken, on help, and once the app is
serving.
app.Get("/posts", index)
app.Post("/posts", store)
app.Get("/posts/{id}", show)
app.Put("/posts/{id}", update)
app.Delete("/posts/{id}", destroy)Get, Post, Put, Patch, Delete and Options add a route for their
method, and Any one for every method. Handle(method, path, h) takes the
method as a string, where "" is every method. A Get route answers HEAD
requests too.
Paths are ServeMux patterns. {id} is one segment: /posts/{id} takes
/posts/42, and c.Param("id") is "42". {path...}, at the end, is the
rest of the path: /files/{path...} takes /files/docs/a.txt, and
c.Param("path") is "docs/a.txt". When two routes take a request, the
more specific one wins: /posts/latest over /posts/{id}.
Paths match exactly. /posts is /posts and nothing under it, and / is
only the home page. On its own, a pattern that ends in a slash would make
ServeMux take every path under it, so tug adds {$} to one: /tags/
becomes /tags/{$}, which is /tags/ alone. To take everything under a
path, end it with a {name...} wildcard. app.Any("/{path...}", h) takes
every request no other route does, and app.Get("/{path...}", h) every GET,
which leaves the other methods a 405.
A request no route takes is answered in one of three ways:
- When its path ends in a slash, and a route would take the request without
it, it's redirected there with a 307, query and all, as
/posts/?page=2is most likely a link to/posts?page=2. For a route whose path ends in a slash,ServeMuxdoes the reverse:/tagsgets a 307 to/tags/. - When routes take its path under other methods, it's a 405, with an
Allowheader that lists them, such asGET, HEAD, POST. - Otherwise, it's a 404.
The 405 and the 404 go to the ErrorHandler as *tug.HTTPErrors, so they
look like the app's other errors: JSON for an API client, and the error page
for a browser, when the app has one.
Mistakes panic at the call that makes them, so they stop the app as it
starts rather than showing up later: a route path or group prefix that
doesn't start with /, a pattern ServeMux can't parse, such as
/posts/{id, and a route that clashes with one added before it. Routes
clash when they take the same requests, as /posts/{slug} and
/posts/{id} do, or when both take some request and neither is more
specific: Any("/posts/latest", h) clashes with Get("/posts/{id}", h),
since one has the more specific path and the other the more specific
method.
The first request fixes the routes and middleware, as does Serve when it
starts: adding a route, a group, middleware or a route name after that
panics. It's when tug puts each route's middleware together, which is why a
group's Use after its routes still wraps them.
tug routes lists the app's routes, by path: each one's method, name, the
struct it takes, the line of the app's that added it, and its handler as
that line has it, a.guestsOnly(a.login) (The CLI).
app.Get("/posts/{id}", show).Name("posts.show")
app.Get("/files/{path...}", file).Name("files")
path, err := app.URL("posts.show", 42) // "/posts/42"
path, err = app.URL("files", "docs/read me.txt") // "/files/docs/read%20me.txt"Name names a route, so its path can be built rather than written out. A
name belongs to one route: naming a second route the same panics.
App.URL(name, params...) fills the route's wildcards with params, in
order, each written with fmt.Sprint and escaped; a {name...} wildcard
keeps the slashes in its value. It returns an error for a name no route has,
for too few or too many values, and for an empty value for a {name}
wildcard. In a handler, c.URL builds a path the same way, as examples/api
does for a Location header, and c.RedirectRoute redirects to one: a
handler for a form usually ends with
return c.RedirectRoute("posts.show", post.ID).
tug gen writes the named routes to TypeScript too, with a route() that
builds the same paths in the frontend: route('posts.show', { id: 42 }).
See typescript.md.
Takes says what a named route's handler binds, by a value of it, for
tug gen to type the route's forms by, which it can't see itself behind
a wrapper of the handler:
app.Post("/posts", createPost).Name("posts.store").Takes(PostInput{})A route needs its name before it takes an input, and the input is a
struct: Takes panics otherwise. Bind fails a handler that binds a
struct of body fields other than the one its route takes, as a 500, so
Takes can't drift from its handler unseen. See
typescript.md.
cfg := tug.ConfigFromEnv() // APP_URL, the app's address, as https://example.com
cfg.Keys = keys // session.KeysFromEnv's, from APP_KEY
app := tug.New(cfg)
app.Get("/invitations/{id}", tug.Signed(acceptInvitation)).Name("invitations.accept")
link, err := app.AbsoluteURL("posts.show", 42) // "https://example.com/posts/42"
link, err = app.SignedURL("invitations.accept", time.Now().Add(72*time.Hour), invite.ID)
// "https://example.com/invitations/7?expires=1790000000&signature=..."A path is enough for a link within the app, and it's what redirects and
pages use: the scheme and host a proxy answers on aren't the app's to
know. A link that leaves the app, in mail, a QR code, or another service,
is whole: AbsoluteURL(name, params...) is the named route's path after
Config.URL, the app's own address, which ConfigFromEnv reads from
APP_URL. It's never taken from the request's Host, which can name any
site, so a link made from it could send its reader anywhere. Without
Config.URL, it's an error that says to set APP_URL. tug dev sets it
to the address it shows the app at, unless .env names another.
SignedURL(name, expires, params...) is a whole link that only the app
could have made, until it expires: its query has expires, a Unix time,
and signature, over the link's path and its expiry, with a key derived
from the first of Config.Keys for signed links alone. tug.Signed(h)
wraps a handler that only such a link reaches: any other request is a
403 for the ErrorHandler, "this link has expired" for one the app made
that's past its time, and "this link isn't valid" for the rest, a link
changed anywhere in its path, its expiry or its signature, or with
anything else in its query, which Bind would give the handler. It's a
wrapper, as tug.Limit is, so an Inertia visit gets the error page.
A link is checked with each of the keys, so one made with a key moved to
APP_PREVIOUS_KEYS works until the key is dropped. Every link expires,
and one that has to work for years, as a link to unsubscribe, is given
years. It works until then, as often as it's followed: what should happen
once, as an invitation accepted, is the app's to record. c.AbsoluteURL
and c.SignedURL make the same links in a handler; a job, with no
request, makes them with the *tug.App, as the auth starter's mail does.
app.Use(middleware.RequestID(), middleware.Logger(), middleware.Recover(), middleware.CSRF())
admin := app.Group("/admin", requireAdmin)
admin.Get("/", dashboard).Name("admin") // /admin
admin.Get("/users", users).Name("admin.users") // /admin/users
admin.Post("/users/{id}/ban", ban, audit) // requireAdmin, then audit
account := app.Group("", requireLogin) // no prefix: the middleware alone
account.Get("/settings", settings)Group(prefix, mw...) returns a *tug.Router for routes under the prefix,
which starts with /. A group's own page, its /, is the prefix itself:
/admin, not /admin/. Groups nest, and their prefixes and middleware add
up.
Middleware is net/http's own shape, func(http.Handler) http.Handler, which
tug names tug.Middleware, so middleware written for net/http works as it
is. On the App, with app.Use, it wraps every request, including those no
route takes, so it sees the redirects, 405s and 404s too, which suits
logging and recovery. On a group, as arguments to Group or with the
group's Use, it wraps the group's routes, including those added before
the Use, but not a request under the prefix that no route takes. On a
route, as the last arguments to Get, Post and the rest, it wraps that
route alone.
A request goes through them from the outside in, each in the order it was added:
- The App's middleware.
- The session middleware, when
Config.Sessionis set. - The Inertia middleware, when
Config.Inertiais set. - The middleware of the route's groups, the outermost group's first.
- The route's own middleware, then the handler.
The App's middleware is outside the rest, so it sees every request, and
every response as it finally goes out: Logger logs the status the client
gets. It also runs before there's a session: session.From finds nothing
there. The session middleware is outside Inertia's so that flash data lasts
through the reload Inertia's middleware asks for when a browser has an old
build of the frontend. Group and route middleware run inside both, so they
can read the session, as a check that someone is logged in does, and their
redirects are fixed for Inertia's client as a handler's are: a 302 after a
PUT becomes a 303. So middleware that needs the session goes on a group,
even one with an empty prefix, rather than on the App.
tug.WrapHandler puts an http.Handler on a route, such as a file server.
It answers for itself: its 404s are its own, not the ErrorHandler's.
app.Get("/static/{path...}", tug.WrapHandler(http.StripPrefix("/static", http.FileServerFS(static))))app.Use(func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
next.ServeHTTP(w, r)
route := tug.RouteOf(r) // "GET /posts/{id}", once the handler has run
// ...
})
})tug.RouteOf(r) is the route that answered r, as its String says it:
its method and path as they were added, GET /posts/{id}, or
ANY /files/{path...} for a route of any method. The App's middleware,
outside the router, reads it once the handler has run, as the label of a
request's metrics, which has as many values as the app has routes, where
its path has as many as the app has posts (Metrics);
Logger logs it. A route answers as it's matched, so a redirect its group's
middleware sends, to log in, say, is the route's. A request no route
answered, a 404, a 405, or a redirect to its path without its trailing
slash, has "".
An App with middleware of its own puts a place for the route in each
request's context as the request comes in, which the router fills in,
whatever copies of the request the middleware in between makes. Inside the
router, a handler's RouteOf(c.Request()) reads it, or, in an App with no
middleware, which makes no place, the pattern ServeMux keeps on the
request.
Package middleware has eight, each a plain
func(http.Handler) http.Handler that works with any router. The order in
app.Use above is the usual one: RequestID first, so every log line
carries the ID, and Logger before Recover, so a panic is logged as the
500 that Recover makes of it. Behind a proxy, TrustProxies goes before
them all. Headers and CSP go after Recover, and CORS, for an API
another site's pages call, before CSRF, as the starters have them:
app.Use(
middleware.TrustProxies(strings.Split(os.Getenv("TRUSTED_PROXIES"), ",")...),
middleware.RequestID(), middleware.Logger(), middleware.Recover(),
middleware.Headers(middleware.HeadersConfig{HSTS: strings.HasPrefix(cfg.URL, "https://")}),
middleware.CSP(middleware.CSPConfig{ReportPath: "/csp-reports", DevServer: assets.DevServer, StyleNonce: true}),
middleware.CSRF(),
)TrustProxies(proxies...)gives a request that came through the app's proxies, such as a load balancer, the address of the client it came from, inRemoteAddr, where it had the proxy's. See The client's address.RequestID()gives each request an ID: theX-Request-IDit came with, as a proxy in front may set one, when that's short and plain (up to 64 letters, digits and-_.:+/=), or else a new random one. The ID goes back in the response'sX-Request-ID, and into the request's context, wheremiddleware.RequestIDFrom(ctx)finds it, and a job the request pushes takes it, through a queue that carriesmiddleware.CarryRequestID(Background jobs).Logger()logs a line for each request throughslog.Default(): its method, path, status, size and duration, itsroute, when a route answered it (tug.RouteOf, above), and itsrequest_idwhenRequestIDran before. A 5xx logs at Error, a 4xx at Warn, and the rest at Info. The path is logged without its query string, which can carry tokens.Recover()answers a panic with a 500 and logs it with its stack. tug already recovers a handler's panics and hands them to theErrorHandler;Recoveris for the rest, such as a panic in middleware, which runs outside the handler. A response that has started is left as it is.CSRF(trustedOrigins...)rejects a request a browser sent from another origin, with a 403 in plain text, unless it's a GET, HEAD or OPTIONS, which must never change anything. It's net/http'sCrossOriginProtection, which reads theSec-Fetch-Siteheader browsers send, or else comparesOriginwithHost, so there are no tokens to put in forms. A request with neither header, such as one from curl, passes, and so does one from a trusted origin, such as"https://admin.example.com".CSRFpanics on a trusted origin that isn't one, such asadmin.example.comwithout its scheme, so that a mistyped setting stops the app as it starts. An entry that's a path, such as"/api/", is a pattern ofServeMux's whose requests pass unchecked: routes that take a token, never the session's cookie, which another site's page can't borrow, as the auth starter's API's do. See forms.md.CORS(origins...)lets the pages of the sites named, as"https://app.example.com", or"*"for any, call the app from the browser, as another site's pages call an API. A request from one of them getsAccess-Control-Allow-Origin, and the browser's preflight, anOPTIONSthat asks first, is answered with a 204 and the methods and headers it asked for, before any route, as an API's routes have noOPTIONSof their own. A request from another site passes as it came, and its browser keeps the response from its page. Credentials, the app's cookies, are never allowed: an API another site's pages call takes tokens, which a page sends itself.Retry-Afteris exposed, for a limit's 429. A blank origin is skipped, and one that isn't an origin panics, asCSRF's do.Headers(cfg)says what a browser may do with each response:X-Content-Type-Options: nosniff,Referrer-Policy: strict-origin-when-cross-origin,Cross-Origin-Opener-Policy: same-originandX-Frame-Options: SAMEORIGIN, aPermissions-Policythat turns off the powerful features, as the camera, but thosePermissionsallows, and withHSTS,Strict-Transport-Securityfor a year. A handler that needs another sets its own. It panics on a feature or an allowlist it can't put in the header.CSP(cfg)sends a Content-Security-Policy, which runs the scripts that carry a nonce made for the response, and what they load, and lets a page load the app's own alone.middleware.NonceFrom(ctx)reads the nonce, which packageinertiahands the root template as.Nonce.ReportOnlysends it asContent-Security-Policy-Report-Only,ReportPathis where the browser reports what it blocks, whichCSPanswers itself and logs,Sourcesadds to its directives,DevServerlets Vite's dev server in, andStyleNoncehas a<style>carry the nonce too. It panics on a source or a path it can't put in the header. See deployment.md.
app.Use(middleware.TrustProxies("10.0.0.0/8"), middleware.RequestID(), middleware.Logger())
func search(c *tug.Ctx) error {
slog.Info("a search", "from", c.IP()) // "203.0.113.9", the client's, not the load balancer's
...
}c.IP() is the address a request came from, without its port. Behind a
proxy, a load balancer, a CDN or a platform's router, that's the proxy's,
and every client comes from it: a limit by address counts them all as
one. middleware.TrustProxies(proxies...) names the proxies the app
believes, each an address or a range, as 10.0.0.5 or 10.0.0.0/8, and
gives a request from one of them the client's address, in RemoteAddr,
where c.IP(), and anything else written for net/http, finds it. The
starters read the proxies from TRUSTED_PROXIES, comma separated, and
believe none without it.
Each proxy adds the address it saw to the end of X-Forwarded-For, so the
header is read from its end, back past the proxies named, to the first
address that isn't one: the client, as far as the proxies can tell. What
comes before it, the client wrote itself, and could be anything, which is
why reading the header from its start, as the easy way does, lets anyone
pick the address a limit counts. A request from an address that isn't a
proxy keeps its own, whatever its headers say.
* believes whatever connects, and it alone: the client is the address
that proxy added. It's for a platform whose proxy has no address the app
can name, where nothing but the proxy can reach the app. An app that
anything else can reach names its proxies: with *, whatever connects
picks its own address. TrustProxies panics on a proxy that isn't an
address or a range, such as a host's name, so that a mistyped setting
stops the app as it starts, rather than leave it believing no one.
It reads the address alone, not X-Forwarded-Proto or
X-Forwarded-Host: the app's links are APP_URL's, its cookie is
Secure by session.Config.Secure, and the proxy passes Host on (see
Deployment).
searches := &auth.Throttle{Name: "searches", Max: 30, Window: time.Minute}
app.Get("/search", tug.Limit(searches, (*tug.Ctx).IP, search)).Name("search")tug.Limit(limiter, key, handler) counts a try for each request, by the
key the function makes of it, and one over the limit doesn't reach the
handler: it's a 429 for the ErrorHandler, as a 404 is, with
Retry-After, the wait in seconds, rounded up, and "too many requests:
wait 30 seconds, and try again". So an Inertia visit gets the error page,
and an API's client JSON. A limiter's error, as when the database that
counts is down, is as though the handler had returned it.
It's a wrapper around a handler, as the auth starter's usersOnly is,
rather than middleware: middleware answers before the handler, with no
Ctx, and could only write a response of its own, which an Inertia visit
shows in a modal. The limiter is anything with Try, a tug.Limiter,
as an auth.Throttle is (Accounts): in memory, this
process's own counts, or with a Store, counts that every instance of
the app shares.
The key is the app's to make from the request: the address it came from,
c.IP(), which behind a proxy takes TrustProxies (see
The client's address), or a user's ID, for a
limit per account. A throttle used on more than one route counts them
together, unless the key says which.
func show(c *tug.Ctx) error {
id := c.Param("id") // "42" for /posts/42, on "/posts/{id}"
tab := c.Query("tab") // "comments" for ?tab=comments
return c.String(http.StatusOK, "post "+id+", tab "+tab)
}A handler is a tug.HandlerFunc, func(c *tug.Ctx) error. It writes its
response through c, or returns an error for the ErrorHandler to answer:
a handler says what went wrong, and never writes an error page itself. A
Ctx belongs to its request, so don't keep it after the handler returns,
as a goroutine that outlives the handler would.
| Method | Returns |
|---|---|
Request() |
The *http.Request. |
Response() |
The http.ResponseWriter, for headers, or to write a response by hand. |
Context() |
The request's context, which is canceled when the client goes away. |
Param(name) |
The value of the path wildcard {name}. |
Query(name) |
The first value of the query parameter name. |
IP() |
The address the request came from, without its port: the client's, behind the proxies TrustProxies names. |
Locale() |
The request's language, as "vi": the app's choice for it, or else the browser's, or else the app's default. c.T and c.Choice say texts in it (Languages). |
Written() |
Whether the response has started. After that, its status and headers can't change. |
The responses set the status and the Content-Type, and write the body.
Each returns an error, so a handler can end with return c.JSON(...).
| Method | Writes |
|---|---|
JSON(code, v) |
v as JSON. A value encoding/json can't encode is an error, returned before anything is written. |
String(code, s) |
s as plain text. |
HTML(code, html) |
html as a page, exactly as given: anything from a user in it must already be escaped. |
Blob(code, contentType, b) |
b, with the given content type. |
NoContent(code) |
A status and no body, such as 204. |
c.Redirect(to) sends the client to another URL: with 302 Found after a GET
or HEAD, and 303 See Other after anything else. A 303 makes browsers, and
Inertia, follow a PUT, PATCH or DELETE with a GET, where a 302 may repeat
the method. c.RedirectRoute(name, params...) redirects to a named route,
and c.RedirectBack() to the page the request came from, by its Referer,
when that's a page of this app, and to / otherwise, since a Referer can
name any site. It's for a form that more than one page has, which goes back
to whichever it was sent from, as the auth starter's "send the link again"
does.
A Ctx has more for pages, such as Inertia and Location, in
pages.md, and for forms, such as BindValid, Session and
Flash, in forms.md.
app.Get("/terms", func(c *tug.Ctx) error {
return c.File("legal/terms.pdf") // shown in the browser, as a PDF is
})
app.Get("/invoices/{id}", func(c *tug.Ctx) error {
pdf, err := a.invoicePDF(c.Context(), c.Param("id"))
if err != nil {
return err
}
return c.Download("invoice-"+c.Param("id")+".pdf", bytes.NewReader(pdf))
})c.File(path)sends one of the app's files, shown as its type: a PDF as a PDF, HTML as a page.http.ServeContentsends it, with ranges, so a download that broke off goes on where it stopped, and withIf-Modified-Since, for the browser's cache. A file that isn't there, or a directory, is a 404 for theErrorHandler.pathis the app's, never a request's.c.File("reports/" + c.Param("name"))would sendapp.dbto a request for/reports/..%2Fapp.db, as one part of a path can carry a slash, as%2F.c.FileFS(fsys, name)sends a file of anfs.FS, such asos.DirFS("reports"), or anos.Root'sFS, which keeps symbolic links inside as well, where a name that would leave it isn't there, a 404. It's how an embedded file is sent too.c.Download(name, content)sendscontent, anio.Reader, as a file to save asname: the browser saves it rather than shows it. The name goes inContent-Dispositionhowever it's written, in Vietnamese, with quotes or with a line break, which can't end the header, and what's before its last/or\is dropped, as the browser saves a name, not a path. Its type is its name's extension's, or else what its first bytes say. Content that seeks, a file or abytes.Reader, has ranges, and a file its time forIf-Modified-Since; other content, as a response from another service, goes as it's read. Closing it is the handler's.- A file someone uploaded goes by its disk's route
(Files), which sends it as what its bytes are, or by
Download, under the name it was uploaded with:Filewould show HTML as a page of the app's.
app.Get("/posts.csv", func(c *tug.Ctx) error {
return c.StreamDownload("posts.csv", "text/csv; charset=utf-8", func(w io.Writer) error {
out := csv.NewWriter(w)
err := a.posts.each(c.Context(), func(p Post) error {
return out.Write([]string{strconv.FormatInt(p.ID, 10), p.Title})
})
out.Flush()
return cmp.Or(err, out.Error())
})
})c.Stream(contentType, write) sends a body as write makes it, for one
too long to hold, made as it's read: every post as CSV, a row at a time,
as the query returns them. c.StreamDownload(name, contentType, write)
sends it as a file to save, named as Download names one. Each write
goes to the client as it's made, flushed, so a client that reads as it
comes, a fetch of lines, gets each one; many small writes are buffered
by whoever makes them, as csv.Writer does.
write's error before its first write is the handler's, for the
ErrorHandler, as any is. Once the body has started, there's no error
page to show: the error goes to the log, and the connection is cut, so
the browser says the download failed, where it would have saved half a
file as the whole. A stream takes as long as it takes, as the server has
no timeout for a response, and the app's shutdown waits for it, up to
ShutdownTimeout, as it does for any request.
app.Get("/imports/{id}/progress", func(c *tug.Ctx) error {
return c.Events(func(ctx context.Context, send func(tug.Event) error) error {
for p := range a.imports.progress(ctx, c.Param("id")) {
if err := send(tug.Event{Name: "progress", Data: p}); err != nil {
return err
}
}
return send(tug.Event{Name: "done"})
})
})c.Events(fn) sends server-sent events, which a page reads with the
browser's EventSource (Pages): an
import's progress, or a change a page should show. fn sends each, a
tug.Event: its Name, which the page listens for, its ID, and its
Data, as JSON, which the page reads with JSON.parse. A name or ID with
a line break, or data JSON can't hold, is send's error, and isn't sent.
- The stream lasts as long as
fn.fn's context is done when the client goes away, and when the app shuts down, asServewaits for the requests in flight, which a stream would hold up;sendfails once it is. - An
EventSourceconnects again when its stream ends, the app's shutdown's among them, to an instance that's up, and sends the last event's ID inLast-Event-ID, whichc.Request().Headerhas, for the app to go on from. A page that has what it waited for closes itsEventSource. - A quiet stream sends a comment every 20 seconds, which the browser
passes over, so the proxies in front of the app, which close a
connection that says nothing for a minute, don't. The stream has
Cache-Control: no-cache, andX-Accel-Buffering: no, so nginx doesn't hold the events back. fn's error is logged, as the response has started; a client that went away, or the app's shutdown, isn't an error.- The events are the instance's own: a page on one instance doesn't hear
an event another instance has. Package
broadcastsends one to every page that follows a channel, whichever instance it's on (Broadcasting).
type UpdatePost struct {
ID int64 `path:"id"` // the {id} in "/posts/{id}"
Draft bool `query:"draft"` // ?draft=1
Title string `json:"title"` // the body, JSON or a form
Tags []string `json:"tags"`
}
func update(c *tug.Ctx) error {
var in UpdatePost
if err := c.Bind(&in); err != nil {
return err
}
return c.JSON(http.StatusOK, in)
}c.Bind(&in) fills a struct from the request. Each field's tags say where
its value comes from:
| Tag | From |
|---|---|
path:"id" |
The path wildcard {id}. |
query:"page" |
The query string. |
json:"title" |
A JSON body, through encoding/json. In a form, it's the field's key when there's no form tag. |
form:"title" |
A form body, urlencoded or multipart. |
The body is read by its Content-Type. JSON, application/json or any
+json type, goes through encoding/json. A form,
application/x-www-form-urlencoded or multipart/form-data, fills a field
by its form tag, or else its json tag's name, or else its name.
form:"-" leaves a field out of a form, as json:"-" does for a field
with no form tag. A body of any other type is a 415.
The body is bound first, then the query, then the path, and each overwrites
the one before where it has a value. So the URL wins, and a body can't
change the {id} in it. A body can still fill a query or path field
that the URL has no value for: examples/api keeps a body away from its ID
with ID int64 `path:"id" json:"-"`.
In a query or a form, a key that repeats, or ends in [], fills a slice:
tags=a&tags=b, or tags[]=a&tags[]=b. An empty value leaves its field
alone, since an empty input means no value rather than zero. A multipart
form's files go to fields of type *multipart.FileHeader, or
[]*multipart.FileHeader for several, and a file input left empty, sent
as a file with no name, leaves its field nil. Up to 32 MiB of files is
held in memory, and the rest in temporary files, which net/http removes
after the request. Inertia's client sends a form with a file in it as a
multipart form, with a list's items under tags[], true and false as 1
and 0, which bind, and a nested object's values under user[name],
which don't: a form with a file keeps its fields flat. See
Files.
Values from the path, the query and forms fill strings, bools, ints, uints,
floats, []byte, time.Time, any type with an UnmarshalText method, such
as netip.Addr, and pointers and slices of these. A bool takes 1, true,
on or yes, and 0, false, off or no, so a checkbox's on binds.
A time takes RFC 3339, or what HTML's date and datetime-local inputs
send, as UTC. The fields of an embedded struct with no tag bind as the outer
struct's own, as encoding/json promotes them. A field of a type Bind
can't fill, such as a map, is the program's mistake rather than the
client's: when a value comes for it, Bind returns a plain error, a 500.
Inertia's <Form> sends a form as JSON, with every value a string, as the
browser's FormData has it: "on" from a ticked checkbox, "42" from a
number input, "2026-09-25" from a date input, and "" from one left
empty. encoding/json takes none of those for a bool, a number or a
time.Time, so a JSON body that doesn't decode is read again, with each of
those strings read as the form's value would be: "on" is true, "42"
is 42, a date is one in UTC, and "" leaves its field alone, or, in a
list, is left out. That goes for fields at any depth, in nested structs,
lists and maps, found by encoding/json's own rules for which field a key
fills. A string that still doesn't parse is the error it was: "age must be
a whole number". A body that decodes as it is, as an API client's true
and 42 do, binds just as encoding/json has it, and a field that reads
its own JSON or text, such as netip.Addr, or one tagged json:",string",
is given the string as it came.
A value that doesn't parse is a 400 whose message names the field: "age
must be a whole number", or "author.age must be a whole number" from a JSON
body. The error is an *HTTPError that wraps a *BindError. The fields
after it are still bound, and the first such error is the one returned. A
path value that doesn't parse is a 404, whatever else is wrong, since
/posts/abc is a page that isn't there. JSON that doesn't parse at all is a
400, "invalid JSON", and a body over Config.BodyLimit is a 413.
A handler can bind more than once, as one that reads the {id} to find a
post and then binds the form does: a JSON body is kept after the first read.
c.BindValid(&in) binds as Bind does, then checks the struct's validate
tags, and a value that doesn't parse becomes one of the form's errors rather
than a 400. See forms.md.
func show(c *tug.Ctx) error {
post, err := db.FindPost(c.Context(), c.Param("id"))
if errors.Is(err, db.ErrNotFound) {
return tug.NewHTTPError(http.StatusNotFound, "post not found")
}
if err != nil {
return err // a 500, with the details in the log
}
return c.JSON(http.StatusOK, post)
}A handler's error goes to the app's ErrorHandler, which turns it into the
response. tug.NewHTTPError(code, message...) returns an *HTTPError,
which chooses the status; without a message, it says the status's text, so
NewHTTPError(http.StatusNotFound) is a 404 that says "Not Found". An
HTTPError has three fields. Message is shown to the client, so it says
what went wrong in their terms. Err is the cause, which is for the log:
it's logged with a server error, and shown only by Debug.
return &tug.HTTPError{Code: http.StatusBadGateway, Message: "the payment service didn't answer", Err: err}An *HTTPError anywhere in an error's chain counts, so
fmt.Errorf("loading post: %w", tug.NewHTTPError(http.StatusNotFound)) is
still a 404. Two more types reach the ErrorHandler: a *BindError, which
Bind returns inside an *HTTPError, with the Field, the Reason
("must be a whole number") and the parse error, Err; and a *PanicError,
a handler's panic recovered by the app, with its Value and Stack. A
panic with http.ErrAbortHandler, net/http's way of dropping a response on
purpose, isn't recovered.
DefaultErrorHandler answers them like this:
- An
*HTTPErrorchooses the status and the message. So does an error that says its status, with aStatusCode() intmethod, as packageauth's no does (Authorization), with its own words as the message, under 500, and the status's text from 500 up, as its words may not be the client's. Anything else is a 500 that says "Internal Server Error". - Errors of 500 and up are logged through
slog.Default()as "request failed", with the method, path and error, and a panic's stack. WithDebugon, the response shows them too: see While debugging. - The body is JSON,
{"message":"post not found"}, when the request'sAcceptheader asks for JSON first, as API clients do, and plain text otherwise. - An error after the response has started can't change it: it's logged, and the response goes out as it began.
- The
validate.ErrorsofBindValidgo back to the form, or to an API client as a 422. See forms.md. - With
Config.ErrorPageset, browsers and Inertia's client see errors as an Inertia page, unlessDebugis showing a server error's details. See pages.md.
An ErrorHandler of your own gets all of these, and the 404s and 405s of
requests no route takes, as *HTTPErrors. Handle the errors that are
yours, and hand the rest to DefaultErrorHandler:
cfg := tug.ConfigFromEnv()
cfg.ErrorHandler = func(c *tug.Ctx, err error) {
if errors.Is(err, sql.ErrNoRows) {
err = tug.NewHTTPError(http.StatusNotFound)
}
tug.DefaultErrorHandler(c, err)
}
app := tug.New(cfg)One that writes a response itself checks c.Written() first: once a
response has started, only the log is left.
With Debug on, as APP_DEBUG=true turns it on, and tug new's .env
has it, DefaultErrorHandler answers a server error with what it knows of
it, in place of "Internal Server Error". A browser gets a page of it, and
so does Inertia's client, which shows it in its modal, over the page the
visit left:
- The error, and each error it wraps, with its Go type:
fmt.Errorf("loading post: %w", err)is a*fmt.wrapError, thenerr, and the errorserrors.Joinjoins are each a level further in. - A panic's stack, from the frame that panicked: the app's frames open, each with the lines of its source around it, its own marked, and those of tug, the standard library and the app's dependencies folded between them.
- The route that answered, its name, its handler, and the lines of the
app's that added it,
app.Get("/posts/{id}", a.show). An error a handler returns has no stack, so that's where it came from. - The request: its method, URL, the route's values, its ID, and its
headers, with
Cookie,Authorization, and the tokens and passwords in its query[REDACTED], as Inertia's DevTools keep them.
A client that asks for JSON first gets the same as JSON, {"message", "errors": [{"type", "message"}], "stack": [{"function", "file", "line"}], "route"}, and one that takes no HTML, as curl, the error and a panic's
stack as text.
APP_EDITOR makes each frame's file a link that opens it in the editor,
at its line: vscode, cursor, zed, goland or sublime, or a link
of the editor's with {file} and {line} in it, as
myeditor://open?file={file}&line={line}.
The page runs no script: Inertia's modal shows it under the
Content-Security-Policy of the page the visit left, whose nonce isn't the
error response's. It reads the app's source where the app was built, as
under tug dev; a frame whose file isn't there, as in a build with
-trimpath, is shown without its lines. A deployed app leaves Debug
off, as the page shows the app's source and its requests' headers.
tug logs through slog.Default() and never sets it: the handler, the
format and the level are the app's call. For JSON lines, say, set it in
main, before Run:
slog.SetDefault(slog.New(slog.NewJSONHandler(os.Stdout, nil)))Run and Serve log "listening", with the address, and "shutting down",
and send the http.Server's own errors there at Warn, but a TLS
handshake that failed, at Debug, as most are a client's doing, a
scanner's or one that gave up; a certificate the app couldn't get for a
handshake for one of its own domains is a warning, "no certificate for a
handshake". Package letsencrypt logs each domain's certificate as the
app starts, "a certificate is ready", or "couldn't get a certificate", at
Error. DefaultErrorHandler
logs "request failed" for server errors. In package middleware, Logger
logs a "request" line for each request, and Recover logs "panic". A line
about a request is logged with the request's context, so a slog.Handler
of the app's own can add middleware.RequestIDFrom(ctx) to it. Package
queue logs a job that failed, with its kind and ID, and what it carried
from the request that pushed it, its ID among it
(Background jobs).