Ir para o conteúdo
Trilha
Capítulos

Aprender

Formulários

POST no mesmo page.go, proteção CSRF automática e o padrão redirecionar-depois-de-gravar.

Uma página pode receber formulários exportando POST (ou PUT, PATCH, DELETE) ao lado de Page. O Trilha verifica o token CSRF antes de chamar a sua função.

A página com o formulário#

app/eventos/novo/page.go:

package novo

import (
	"strings"

	"github.com/emersonjoe/trilha"
	"github.com/emersonjoe/trilha/h"
	"agenda/internal/eventos"
)

func Page(c *trilha.Ctx) (h.Node, error) {
	c.SetTitle("Novo evento")
	erro := c.Query("erro")
	return h.Fragment(
		h.H1(h.Text("Novo evento")),
		h.If(erro != "", h.P(h.Class("erro"), h.Text(erro))),
		h.Form(h.Method("post"), h.Action("/eventos/novo"),
			trilha.CSRFInput(c),
			h.Label(h.For("nome"), h.Text("Nome")),
			h.Input(h.ID("nome"), h.Name("nome"), h.Required(), h.Autofocus()),
			h.Label(h.For("cidade"), h.Text("Cidade")),
			h.Input(h.ID("cidade"), h.Name("cidade")),
			h.Button(h.Type("submit"), h.Text("Publicar")),
		),
	), nil
}

func POST(c *trilha.Ctx) error {
	if err := c.FormErr(); err != nil {
		return err // 400 em formulário inválido, 413 se passou do limite
	}
	nome := strings.TrimSpace(c.Form("nome"))
	if nome == "" {
		return c.Redirect("/eventos/novo?erro=Informe+o+nome")
	}
	ev := eventos.Criar(nome, c.Form("cidade"))
	return c.Redirect("/eventos/" + ev.Slug)
}
app/…/page.go
func Page(c *trilha.Ctx) (h.Node, error) {
	return h.Form(h.Method("post"), h.Action("/eventos/novo"),
		trilha.CSRFInput(c),
		h.Label(h.For("nome"), h.Text("Nome do evento")),
		h.Input(h.ID("nome"), h.Name("nome"), h.Required()),
		h.Button(h.Type("submit"), h.Text("Publicar")),
	), nil
}

func POST(c *trilha.Ctx) error {
	ev := agenda.Criar(c.Form("nome"))
	return c.Redirect("/eventos/" + ev.Slug)
}
resultado
Envie para ver o que o servidor faria (envio simulado no navegador; o POST real está no código ao lado).

O que acontece no envio#

  1. O navegador manda POST /eventos/novo com os campos e o _csrf.
  2. O Trilha compara o _csrf com o cookie trilha_csrf (tempo constante). Diferente ou ausente: 403, e POST nem roda.
  3. POST roda e devolve c.Redirect(...): resposta 303 See Other. O navegador faz um GET na URL nova. Recarregar a página não reenvia o formulário.

trilha.CSRFInput(c) cria o cookie na primeira renderização e o campo oculto. Clientes JavaScript podem mandar o mesmo valor no cabeçalho X-CSRF-Token.

Validação e mensagens#

O exemplo acima confere o nome na mão e devolve o erro pela query string, o que mantém o padrão POST → redirect → GET e funciona sem JavaScript. Assim que o formulário passa de dois ou três campos, ponha as regras na struct: a tag validate fica do lado do campo de que ela fala, e o Bind aplica todas antes de voltar.

type entrada struct {
	Nome  string `form:"nome" validate:"required,min=3,max=80"`
	Email string `form:"email" validate:"required,email"`
	Vagas int    `form:"vagas" validate:"min=1,max=10"`
}

func POST(c *trilha.Ctx) error {
	var in entrada
	if err := c.Bind(&in); err != nil {
		if errs, ok := err.(trilha.FieldErrors); ok {
			// Mesma página, 422, valores preservados, uma mensagem por campo.
			return c.Render(http.StatusUnprocessableEntity, formulario(c, in, errs))
		}
		return err
	}
	ev := eventos.Criar(in.Nome, in.Email, in.Vagas)
	return c.Redirect("/eventos/" + ev.Slug)
}

FieldErrors é um map[string]string (campo → mensagem), então o formulário lê direto: ui.Errors(errs, "email") mostra a mensagem e ui.InvalidIf(errs, "email") marca o campo com aria-invalid. Nada para no primeiro erro — a pessoa vê tudo de uma vez, não um erro a cada envio.

As regras são required, min, max, len, email, url, oneof e eqfield; a referência de validação tem o que cada uma quer dizer em cada tipo. Duas merecem ser ditas aqui:

  • Toda regra além de required ignora valor vazio. Um campo opcional com min=3 só responde pelo que alguém digitou.
  • required quer dizer "não é o valor zero". Onde 0 ou false é resposta de verdade, declare o campo como ponteiro (*int): ausente continua ausente, e zero chega como zero.

As mensagens vêm em inglês. App que fala outra língua chama trilha.UseValidationPTBR() no Setup, ou escreve as próprias em trilha.ValidationMessages.

Quando a tag não basta#

Regra sobre o formato de um valor pertence ao tipo — e aí todo formulário que usa o tipo está coberto:

type Dinheiro string

func (d Dinheiro) Validate() error {
	if v, err := ParseMoney(string(d)); err != nil || v <= 0 {
		return errors.New("valor deve ser maior que zero")
	}
	return nil
}

Regra que lê dois campos pertence à struct: dê a ela um Validate() error e ela roda no fim, só quando nenhum campo falhou. Regra que se repete de projeto em projeto vira uma tag sua:

trilha.AddRule("cep", func(f trilha.Field) bool { return cepValido(f.Text) })
trilha.ValidationMessages["cep"] = "CEP inválido"

A fronteira é esta: a tag diz o que um valor aceita, não o que o sistema aceita. "Essa conta existe" e "essa sala está livre nessa noite" são perguntas para os seus dados, e ficam no seu pacote. Rode depois do Bind e junte o resultado no mesmo FieldErrors, para os dois tipos de mensagem chegarem na mesma resposta.

Quando o formulário cresce#

Três dependentes, cinco linhas de pedido, uma permissão por módulo: a quantidade de campos não é conhecida na hora de escrever a página. O nome do input carrega a posição, e o Bind preenche uma fatia de struct a partir dele — itens[0].nome, itens[1].nome, na ordem:

type Linha struct {
	Nome string `form:"nome" validate:"required,max=40"`
	Qtd  int    `form:"qtd" validate:"min=1"`
}

var in struct {
	Itens []Linha `form:"itens" validate:"minitems=1,maxitems=50"`
}

Desenhe as linhas que existem mais uma em branco no fim, e a pessoa ganha a próxima linha sem JavaScript nenhum; linha em branco não é item, então descarte antes de salvar. Chave no lugar de índice dá um mapa — perm[docs]=2 preenche um map[string]int. O nome do input é também a chave da mensagem: ui.Errors(errs, "itens[1].qtd") põe a mensagem ao lado do campo errado, e o BindJSON produz exatamente a mesma chave.

Às vezes o formulário inteiro é desconhecido — o passo de um fluxo, a configuração de um cliente, um formulário público que alguém montou numa tela de administração. Aí o formulário é dado:

values, err := trilha.BindSchema(c, esquema)
errs, ok := err.(trilha.FieldErrors)
if err != nil && !ok {
	return err
}
if ok {
	return c.Render(http.StatusUnprocessableEntity, pagina(c, values, errs))
}

esquema é um trilha.Schema, que é uma lista de SchemaField — ele decodifica direto de JSON, então pode morar numa tabela. Os valores voltam como texto, as mensagens voltam no mesmo FieldErrors de qualquer formulário, e o ui.SchemaForm(esquema, values, errs) desenha os campos dentro de um <form> que continua sendo seu. O examples/cadastro tem os dois: uma lista de dependentes e uma tela cujo esquema é JSON.

Métodos que o navegador não manda#

Formulários HTML só enviam GET e POST. Para "apagar", exporte DELETE para clientes de API e faça o POST da página chamar a mesma lógica:

func DELETE(c *trilha.Ctx) error {
	if !eventos.Apagar(c.Param("slug")) {
		return trilha.ErrNotFound
	}
	return c.Redirect("/eventos")
}

func POST(c *trilha.Ctx) error { return DELETE(c) }

Limites#

O corpo da requisição tem limite de 1 MiB por padrão (Config.MaxBodyBytes). Acima disso a resposta é 413 antes de o seu código rodar.

Vários idiomas#

Um formulário que atende migrantes é lido por quem não lê o idioma em que o posto escreve. O Config.Locales faz do idioma uma propriedade do pedido, e o c.T diz nele as frases do próprio formulário.

// app/setup.go
//go:embed i18n
var mensagens embed.FS

func Setup(a *trilha.App) error {
	cfg := a.Config()
	cfg.Locales = []string{"pt-BR", "ht", "fr"} // o primeiro é o padrão
	cfg.LocaleOf = sessao.Flow.LocaleOf         // o que a pessoa escolheu, vindo da sessão
	cat, err := trilha.LoadCatalog(mensagens, "i18n")
	if err != nil {
		return err
	}
	cat.Fallback = map[string]string{"ht": "fr"} // o crioulo lê o francês antes do português
	cfg.Catalog = cat
	return nil
}

O c.Locale() negocia uma vez por requisição: a preferência da sessão, depois o ?lang=ht (o link da página, que deixa um cookie trilha_lang para o clique seguinte continuar no idioma), depois esse cookie, depois o Accept-Language — num guichê o idioma do navegador é o do guichê, não o da pessoa, e é por isso que a sessão e o link vêm antes — e por fim o padrão.

// i18n/ht.json: {"inscricao.titulo": "Enskripsyon", "inscricao.nome": "Non ou"}
func Page(c *trilha.Ctx) (h.Node, error) {
	return ui.Form(h.Method("post"), trilha.CSRFInput(c),
		ui.Legend(h.Text(c.T("inscricao.titulo"))),
		ui.Field(ui.Label("nome", c.T("inscricao.nome")), ui.Input(h.Name("nome"))),
		ui.Field(h.Text(c.T("inscricao.vagas", n))), // "1 vaga" / "4 vagas"
		ui.Submit(h.Text(c.T("inscricao.enviar"))),
	), nil
}

O layout escreve h.Lang(c.Locale()), então a tag <html> diz o idioma em que a página realmente está — que é o que o leitor de tela e a tradução do navegador leem. Chave que o idioma não traduziu cai pela cadeia (ht → fr → pt-BR) e nunca chega crua na tela; o trilha i18n missing ht lista o que falta traduzir, e o trilha check reprova a chave que falta no idioma padrão.

Desafio#

Adicione ao formulário um campo vagas numérico, aceite só de 1 a 10, e mostre a mensagem ao lado do campo em vez de na página seguinte.

Mostrar solução
type entrada struct {
	Nome   string `form:"nome" validate:"required,min=3"`
	Cidade string `form:"cidade"`
	Vagas  int    `form:"vagas" validate:"required,min=1,max=10"`
}

// No POST, c.Bind(&in) devolve trilha.FieldErrors, e a página é renderizada de
// novo com c.Render(http.StatusUnprocessableEntity, ...) e ui.Errors(errs, "vagas").