ガイド 05
Forms and sessions
このページはまだ翻訳されていないため、英語で表示しています。 GitHub で翻訳する
このページの内容
A form is Inertia's <Form> on a page, sent to a handler that binds it into
a struct and checks it. A form that doesn't validate goes back to its page
with the errors; one that does leaves a flash message for the page it
redirects to. The session carries both, in an encrypted cookie, and CSRF
protection needs no tokens.
type PostInput struct {
Title string `json:"title" validate:"required,max=80"`
Body string `json:"body" validate:"required,min=10"`
}
func createPost(c *tug.Ctx) error {
var in PostInput
if err := c.BindValid(&in); err != nil {
return err // back to the form, with what to fix
}
post := posts.Add(in) // the app's own storage
c.Flash("success", "Post created")
return c.RedirectRoute("posts.show", post.ID)
}c.BindValid binds the request into in, as c.Bind does (see
Routing), then checks it against its validate tags. When
something's wrong it returns validate.Errors, a message for each field,
which the handler returns as it is: the app's ErrorHandler sends the form
back with them. Otherwise the handler does its work, leaves a message for
the next page, and redirects (see Pages).
With the route
app.Post("/posts", createPost).Name("posts.store").Takes(PostInput{}),
whose Takes says the struct its handler binds, the form, in
resources/js/pages/Posts/Create.tsx, is:
import { Form } from '@inertiajs/react'
import { form, type Inputs } from '../../tug/routes'
export default function Create() {
return (
<Form<Inputs['posts.store']> action={form('posts.store')}>
{({ errors, processing }) => (
<>
<label>
Title
<input name="title" />
</label>
{errors.title && <p className="error">{errors.title}</p>}
<label>
Body
<textarea name="body" rows={5} />
</label>
{errors.body && <p className="error">{errors.body}</p>}
<button type="submit" disabled={processing}>
Create post
</button>
</>
)}
</Form>
)
}<Form> sends its inputs by their names, the names in the json tags, as
JSON with every value a string, or with a file in it, as a multipart form
(Files). Bind reads a string where the struct has
a bool, a number or a time.Time as it reads a form's value, so a
checkbox's "on" fills a bool, a number input's "42" an int, and an
input left empty leaves its field alone (Routing has
the rest). When the form comes back, errors has a message for each field
that's wrong, such as "body must be at least 10 characters", and the inputs
keep what was typed. form(), the route's path and method, and Inputs,
what each route takes, are written by tug gen from the named routes
(TypeScript): errors.titel, a key PostInput
hasn't, is a type error. Sending the form back, and the flash message
outliving the redirect, take Config.Session, which tug new sets up: see
Sessions.
tug.DefaultErrorHandler answers the validate.Errors a handler returns in
one of two ways.
A browser's request, from Inertia's client or a plain HTML form, goes back
where it came from. The errors are flashed to the session, and the response
redirects to the Referer when that's a page of this app, or else to /,
since a Referer can name any site. The page shown next has the errors in
its errors prop, {"body": "body is required"}, and the one after has
{}, as every page without errors does. A Referrer-Policy that leaves
the path out, such as no-referrer or origin, sends every form that
doesn't validate to /.
A page with two forms can keep their errors apart with an error bag:
<Form action={route('comments.store', { id: post.id })} method="post" errorBag="comment">Inertia's client names the bag in X-Inertia-Error-Bag, and the errors
come back under it: {"comment": {"body": "body is required"}}. The
<Form> that names the bag reads its errors from there, so errors.body
works in it as before.
A request whose Accept header asks for JSON first, as an API client's
does, gets a 422 instead, and so does any request to an app without
Config.Session:
{"errors":{"body":"body is required","title":"title must be at most 80 characters"},"message":"body is required"}message is the error of the field whose name sorts first. Inertia's
client would show that 422 in its error dialog rather than on the form,
which is why an app with forms has sessions. An app with an ErrorHandler of
its own hands validate.Errors on to tug.DefaultErrorHandler: nothing
else sends them back.
type OrderInput struct {
Email string `json:"email" validate:"required,email"`
Note string `json:"note" validate:"omitempty,min=3"`
Lines []LineInput `json:"lines" validate:"min=1,dive"`
}
type LineInput struct {
Product string `json:"product" validate:"required"`
Quantity int `json:"quantity" validate:"min=1"`
}The rules are the tags of
go-playground/validator,
separated by commas. omitempty skips the rest for an empty value. A field
that's a struct is checked by its own fields' tags, and a list of structs
needs dive for its items to be checked. tug writes a sentence for these
tags, and for a rule of the app's own, and
"field is invalid" for any other. The limits count characters for text,
items for a list or map, and the value itself for a number.
file_max and file_type are tug's own, for an upload: its size, and
what it is by its first bytes (Files):
| Tags | Message |
|---|---|
required, required_if, required_unless, required_with, required_with_all, required_without, required_without_all |
title is required |
email |
email must be a valid email address |
url, http_url, uri |
website must be a valid URL |
uuid, uuid4, uuid7 |
id must be a valid UUID |
min, gte; max, lte |
note must be at least 3 characters; lines must have at most 20 items |
gt; lt; len |
price must be more than 0; price must be less than 100; code must be exactly 6 characters |
eq; ne |
answer must be 42; username must not be admin |
oneof |
size must be one of: small, medium, large |
alpha; alphanum |
code must contain only letters; code must contain only letters and numbers |
numeric, number |
zip must be a number |
boolean |
terms must be true or false |
datetime |
starts at must be a valid date |
eqfield; nefield |
password confirmation must match password; new password must be different from password |
contains; excludes |
password must contain "!"; username must not contain "@" |
startswith; endswith |
handle must start with "@"; domain must end with ".com" |
unique |
tags must not repeat a value |
file_max; file_type |
photo must be at most 2 MB; photo must be a PNG or JPEG image |
An error is under the field's name as the client named it: by the json
tag, or by the Go name when there's none. A nested field is under its
dotted path, a list's index included: the second line's quantity is
lines.1.quantity, which is errors['lines.1.quantity'] in TSX. An input
type can be anonymous, as var in struct{...} is, and its fields are
named the same way.
A message names the field as a person reads it: by its label tag, or
else by its name made into words, starts_at or startsAt as "starts
at", and a nested field by its own part, as the second line's quantity is
"quantity must be at least 1":
type SignupInput struct {
Email string `json:"email" label:"email address" validate:"required,email"` // "email address is required"
Confirm string `json:"email_confirmation" validate:"eqfield=Email"` // "email confirmation must match email address"
}The messages, and the fields' names, are in the request's language: see Languages.
A value that doesn't parse, from the body or the query, is a field error
too: "five" for an int is "stars": "stars must be a whole number". The
fields after it are still bound and checked, so the form hears about
everything at once, and the parse error is the message its field keeps. A
path value that doesn't parse is still a 404, and a body Bind can't read,
such as JSON that isn't, still fails as Routing describes.
validate.Rule("slug", func(s string, _ string) bool {
return slugPattern.MatchString(s)
}, ":field must be letters, digits and dashes")
type PostInput struct {
Slug string `json:"slug" validate:"required,slug"` // "slug must be letters, digits and dashes"
}validate.Rule(name, check, message) adds a tag: check takes the
field's value, as its own type, and the tag's parameter, "VN" for
phone=VN, and says whether the value keeps the rule; message says
what's wrong when it doesn't, in English, with :field for the field's
name and :param for the parameter, as a language's file
translates it. The value's type is the rule's T, or a type of the same
kind, as a type Slug string is a string, or one with the interface T
is; a field of another type panics as it's checked, which a test finds.
Rules are added as the app starts, as its routes are, in main or in the
function that makes the app, which the tests call too: a rule added again
replaces the one before. A name the validator keeps for itself, as
omitempty or dive, panics.
err := c.BindValid(&in, func(errs validate.Errors) {
if posts.TitleTaken(in.Title) {
errs.Add("title", c.T("another post has that title"))
}
})Checks run after the tags, with the errors found so far. errs.Add(field, message) adds one unless the field has one already: the first thing wrong
with a field is the one worth saying. A check can read errs too, to skip
a query when its field is wrong already, as the auth starter's registration
does before it looks the email up. A check made by a function, to share
between handlers, takes a pointer to the input, as titleFree(&in, post.ID)
does below: it's made before BindValid fills the input in.
validate.Errors is a map[string]string. Besides Add, it has First,
the message of the field that sorts first, and Only(fields...), the
errors of the fields named and of those nested in them.
A handler can also return validate.Errors of its own after BindValid, for
what can only be checked when the form is sent, and they're answered as
BindValid's are. The auth starter's login returns
validate.Errors{"email": "the email and password don't match an account"}
for a wrong password (see Accounts).
c.Validate(v, checks...) checks a value that doesn't come from the
request, with the same answers, such as a draft about to be published:
c.Validate(PostInput{Title: draft.Title, Body: draft.Body}).
validate.Struct(v) is the check alone, for code with no request: it
returns validate.Errors, or nil, in English, or in a language it's
given, as validate.Struct(v, catalog.In("vi")).
<input name="title" aria-invalid={invalid('title')} onBlur={() => validate('title')} />validate and invalid come from <Form>'s render function, beside
errors. validate('title') asks the server about the form as it stands,
without sending it: a Precognition request, to the form's action with its
method, carrying the form's data as JSON and the headers
Precognition: true and Precognition-Validate-Only, which lists the
fields checked so far. invalid('title') is true while the title has an
error. validationTimeout on <Form> is how long, in milliseconds, it
waits before checking again: 1500 by default, and 300 in
examples/inertia.
The handler needs nothing new: BindValid answers the request itself. It
binds and checks the whole input, tags and checks both, then keeps the
errors of the fields named in Precognition-Validate-Only, and of fields
nested in them. The rest haven't been filled in yet: an empty body isn't
wrong until it's been left. With no errors it answers 204, with
Precognition-Success: true; with some, a 422 with message and errors.
Then it returns an error, which the handler returns as it does any other.
The rest of the handler doesn't run, and the app knows the request has been
answered, so the ErrorHandler never sees it.
func updatePost(c *tug.Ctx) error {
post, err := findPost(c) // runs for every Precognition request too
if err != nil {
return err
}
var in PostInput
if err := c.BindValid(&in, titleFree(&in, post.ID)); err != nil {
return err // a Precognition request is answered, and stops, here
}
posts.Update(post.ID, in) // only when the form is sent
c.Flash("success", "Post updated")
return c.RedirectRoute("posts.show", post.ID)
}So what comes before BindValid has to be safe to repeat. Finding the post an edit form is for is: a post that's gone is a 404 for the Precognition request too. Saving, sending mail, or counting a login attempt isn't. The checks passed to BindValid run for every Precognition request as well, whichever fields it names, which is how a taken title shows as the field is left. What comes after BindValid, such as the login's password check, runs only when the form is sent.
A handler without BindValid or c.Validate knows nothing of Precognition,
and runs in full for one: call validate only in a form whose handler has
one of them.
import { usePage } from '@inertiajs/react'
export default function Flash() {
const { flash } = usePage()
return flash.success ? <p role="status">{flash.success}</p> : null
}c.Flash(key, value) leaves data for the next page shown, the one this
request renders or else the one it redirects to, and for that page alone.
A page that only redirects shows nothing, so flash data goes on through a
chain of redirects, and a browser that reloads for a new build of the
frontend gets it on the reloaded page. The client reads it as
usePage().flash, apart from the props, and doesn't keep it in history, so
going back doesn't show it again. A value can be anything encoding/json
writes, and flashing a key twice keeps the later value.
A partial reload of a page, as one that refreshes a count, or a poll, neither shows nor takes the flash data, or a form's errors: it can come while the redirect that leads to the page they were left for is loading, in the same tab or another, with the one cookie, and the page they were left for shows them, once, whichever comes first. The reload leaves the session's cookie as it came, unless it changes the session itself.
A key declared with tug.Flash, with the type of its value, is typed in
the frontend too. The starters declare theirs in flash.go:
var Success = tug.Flash[string]("success")
// in a handler, as c.Flash("success", "Post created") does
Success.Set(c, "Post created")tug gen writes FlashData, the keys declared, each optional, in
pages.ts, as the type of usePage().flash and of the flash event, so
flash.success is a string | undefined, and a key no handler declares,
as flash.sucess, doesn't type-check. Set takes a value of its key's
type alone, and c.Flash of a declared key with a value of another type
panics, as the frontend's type would say otherwise. A key flashed with
c.Flash, and never declared, still flashes, untyped.
An app that declares no key may type its flash itself, with Inertia's
flashDataType, as one made before v0.33.0 does in
resources/js/types.ts. Once it declares one, tug gen writes
flashDataType, and the app's own goes, as the two would clash.
c.ClearHistory() and c.PreserveFragment() reach the next page the same
way, through redirects. ClearHistory has the page tell the client to
clear the history it has encrypted, as logging out should (see
history encryption); PreserveFragment has
it keep the #fragment of the visit that led to it. Past a redirect, all
three need Config.Session: without it, a handler that flashes and
redirects fails with a 500 rather than lose the message. A handler that
empties the session with c.Session().Clear(), as logging out does, calls
it before any of them, since Clear takes back what the request has
flashed too.
keys, err := session.KeysFromEnv() // APP_KEY, then APP_PREVIOUS_KEYS
if err != nil {
log.Fatal(err)
}
sessions, err := session.New(session.Config{Keys: keys})
if err != nil {
log.Fatal(err)
}
cfg := tug.ConfigFromEnv()
cfg.Session = sessions
app := tug.New(cfg)The app tug new makes does this in its main.go. Every request then goes
through the session's middleware, inside the app's own, and c.Session()
returns its session; without Config.Session, it's nil.
The whole session travels in one cookie, encrypted and authenticated with
AES-256-GCM under a key derived from APP_KEY, so the server keeps nothing.
A cookie that was tampered with, or made with a key since dropped, starts
an empty session. session.Config has four more fields:
Cookienames the cookie,tug_sessionby default. It'sHttpOnlyandSameSite=Lax, and isn't set while the session is empty.Lifetimeis how long a session lasts without a request, two hours by default. Each response sets the cookie again with a new expiry, so a session in use stays alive; one left longer starts afresh, even if the browser kept the cookie, as the expiry is inside it too. One session can have a lifetime of its own, set withSetLifetime, below.Securekeeps the cookie to HTTPS. A request over TLS gets that anyway, but behind a proxy that ends TLS, requests arrive over plain HTTP and it takesSecure: true. See Deployment.Keeperkeeps the sessions themselves where every instance of the app finds them, and leaves the cookie carrying the session's ID alone, for an app whose sessions hold more than a cookie takes: Kept on the server.
APP_KEY is base64: and 32 random bytes in base64, as Laravel writes it,
and tug new puts a fresh one in .env. tug key prints another:
tug keyWithout one, KeysFromEnv returns session.ErrNoKey, whose message says
the same. session.ParseKey reads a key in that form, or the base64 alone,
for one kept outside the environment.
To rotate the key, the new one goes in APP_KEY and the old one in
APP_PREVIOUS_KEYS, comma separated when there are several. The first key
encrypts and each of them decrypts, so sessions made with the old key still
read, and each response encrypts its session again with the new one. Once
a Lifetime has passed, every session still alive has been through a
response, and the old key can go: the longest lifetime any session has,
which is a month for the auth starter's "Remember me".
s := c.Session()
s.Set("theme", "dark")
theme, _ := s.Get("theme").(string)
s.Delete("theme")SetLifetime(d) has the session last d without a request, in place of
the Store's Lifetime, as a login that ticked "Remember me" does in the
auth starter: a month, where everyone else's lasts two hours. The lifetime
travels in the cookie with the rest, so each response starts it again as
it does the Store's, until Clear, or SetLifetime(0), goes back to the
Store's. Lifetime() says how long the session lasts: its own, or the
Store's, as the auth starter keeps it with a login's row, for when the
login ends.
Clear empties the session, flash data included. Values go through
encoding/json on their way into the cookie, so a later request gets JSON
types back: set 42, and the next request gets float64(42); a struct
comes back as a map[string]any.
The session's own Flash(key, value) stores a value for the next request,
which reads it with Flashed(key); Reflash keeps what the request before
flashed for one more, and Unflash takes back what this one flashed.
They're for values a handler reads rather than a page: c.Flash is built
on them, under keys of its own.
A browser keeps about 4 KB in a cookie. A session larger than that isn't
saved: the response goes out without it, what the request changed is lost,
and the log says session: too large for a cookie, so it wasn't saved; keep less in it. Keep IDs in the session, and the rest in a database, or keep
the sessions on the server, below.
Middleware added with app.Use runs outside the session and can't see it;
a group's or a route's can, with session.From(r.Context()).
sessions, err := session.New(session.Config{Keys: keys, Keeper: &sessionTable{db: db}})A Keeper keeps the sessions themselves where every instance of the app
finds them, a table in its database say, and the cookie carries the
session's ID alone, 26 random characters sealed as the whole session was.
Nothing else changes: c.Session(), the flash, and the lifetime work as
they do, and a session holds what it likes.
type Keeper interface {
Get(ctx context.Context, key []byte, now time.Time) (value []byte, ok bool, err error)
Set(ctx context.Context, key, value []byte, expires time.Time) error
Delete(ctx context.Context, key []byte) error
}The key is the SHA-256 of the session's ID, so a keeper read out gives up
no ID to send as a session's own, and the value is the session sealed with
APP_KEY, bound to that ID, so it gives up nothing the session holds
either, and a row moved to another ID doesn't open. A session the keeper
no longer has, because it expired or was deleted, is a new one, as a
cookie that doesn't open is. A keeper that fails is neither: the request
has no session, the log says session: can't be read, so this request has none, and the response leaves the session the keeper holds alone.
The signatures are a cache.Store's,
so an app can hand its cache over in a line, knowing that clearing the
cache, or a cache that throws values out to make room, logs out every
user. A table of its own doesn't. Package sessiontest has
TestStore(t, open), the promises a keeper makes, as tests, for one the
app writes.
The auth starter has a sessions table in each database's layer,
sessions_db.go, and SESSION_STORE picks it: cookie by default, or
database. Moving to the table logs nobody out, since a session still in
a cookie is read from it one last time and written to the keeper under a
new ID; moving back logs everybody out. prune-sessions, a job on the
hour, deletes the rows that have expired.
A request that changes nothing, flashes nothing, and was flashed nothing writes no row while the row it read was written less than a minute ago, as the auth starter writes a login's last sight at most that often. The cookie goes out all the same, so the browser's own expiry is exact, and the row outlasts it by a minute, so a session the browser still has is one the keeper still holds. A request that was flashed something writes, since the row holds that flash until a write leaves it out, and the page after it would be shown the message again.
Rotate gives the session a new ID, keeping its values, and the row under
the old one goes as the response does. auth.Login calls it, so a session
whose ID someone else set in the browser isn't the one that ends up logged
in, and Clear does too, so the ID a browser came with is no session at
all once it has logged out. Without a Keeper there's no ID, and Rotate
does nothing.
app.Use(middleware.RequestID(), middleware.Logger(), middleware.Recover(), middleware.CSRF())middleware.CSRF() rejects a POST, PUT, PATCH or DELETE that a browser
sent from another site, with a 403. It's Go's http.CrossOriginProtection.
Every current browser says where a request comes from in Sec-Fetch-Site,
and one that's cross-site, or same-site from another subdomain, is
rejected. For a browser that doesn't send that header, Origin has to
match Host. A request with neither, from curl or another server, isn't a
browser acting for a visitor, and passes.
So there are no tokens: nothing to put in forms, nothing to expire, and no
need for Inertia's XSRF-TOKEN cookie. A real user never meets the 403;
only a forged request does, which is why it's plain text rather than the
error page.
GET, HEAD and OPTIONS always pass, so they must never change anything.
That's why logging out is a POST, from a link such as
<Link href={route('logout')} method="post" as="button">.
An origin the app trusts, such as its admin site's, is let through by name:
middleware.CSRF("https://admin.example.com"). A value that isn't an
origin, such as one without a scheme, panics when the app starts rather
than turn into 403s later.
CSRF goes in app.Use, whose middleware wraps every request: every route
is covered, in whatever group, and a rejected request stops before the
session and the handler.