Receitas
Um app na frente de uma API existente
Encaminhar /api/ para um serviço que já existe, com um login próprio.
ctx --pack · est. 15094 tokens est. · llms.txt da receita
A forma da maioria das migrações: os usuários são seus — e-mail, hash de senha, papel — e a API continua onde está. Duas peças do Trilha se encontram aqui, e se encontram porque o proxy precisa levar o que o login guardou.
O examples/local-login é esta receita, inteira e rodando.
1. A sessão, sem provedor#
auth.Sessions é o mesmo *Auth do fluxo OIDC, sem provedor. Require(), RequireRole(), a Store, a rotação do identificador e a janela idle são o código que já estava lá; o que muda é quem diz que a pessoa é quem diz ser.
var Flow = auth.Sessions(auth.Options{
Store: auth.NewMemoryStore(),
Idle: 30 * time.Minute,
LoginPath: "/entrar",
AfterLogin: "/painel",
})User.Extra é o que esta sessão carrega e para o que o OIDC não tem claim — o token que a API espera, o tenant, o plano. Ele viaja por onde o resto da sessão viaja, então deixe-o pequeno e nunca ponha uma senha ali:
func Entrar(c *trilha.Ctx, u usuarios.Usuario) error {
return Flow.Login(c, &auth.User{
Subject: u.ID, Email: u.Email, Name: u.Nome, Roles: []string{u.Papel},
// The token of the API the upstream forwards to. It travels in the
// session, never in the page.
Extra: map[string]string{"api_token": u.Token},
})
}2. A senha, conferida na sua própria tabela#
auth.CheckPBKDF2 lê o formato que o Django e o hashlib gravam — pbkdf2_sha256$iteracoes$sal$hash — então a tabela de usuários que já existe continua valendo, hashes e tudo. auth.HashPBKDF2 grava um novo com 600 mil iterações.
Responda a mesma coisa para e-mail errado e para senha errada, e demore o mesmo tanto: dizer qual dos dois estava errado conta a um atacante quem tem conta.
func (s *Store) Verify(email, senha string) (Usuario, error) {
s.mu.RLock()
u, ok := s.rows[strings.ToLower(strings.TrimSpace(email))]
s.mu.RUnlock()
if !ok {
auth.CheckPBKDF2(semUsuario, senha)
return Usuario{}, ErrCredencial
}
if !auth.CheckPBKDF2(u.Hash, senha) {
return Usuario{}, ErrCredencial
}
return u, nil
}O handler é um formulário como outro qualquer:
func POST(c *trilha.Ctx) error {
email, senha := c.Form("email"), c.Form("senha")
u, err := trilha.Use[*usuarios.Store](c).Verify(email, senha)
if err != nil {
return c.Render(422, formulario(c, email, "E-mail ou senha inválidos."))
}
return sessao.Entrar(c, u)
}3. A API, na mesma origem#
func Config(cfg *trilha.Config) {
// Where the trail of who did what goes. Without this it would go to the
// log, which is enough to grep and enough to ship with.
cfg.Audit = sessao.Auditoria
// O app é escrito para quem lê em português, e é isto que decide o idioma
// dos componentes do kit que trazem texto próprio — a tela de auditoria
// entre eles — e o fuso em que as datas dela aparecem.
cfg.Locale = "pt-BR"
cfg.TimeZone = "America/Sao_Paulo"
if api := os.Getenv("API_URL"); api != "" {
cfg.Upstreams = map[string]trilha.Upstream{
"/api/": {
Target: api,
Headers: func(c *trilha.Ctx, hdr http.Header) {
if tok := sessao.Token(c); tok != "" {
hdr.Set("Authorization", "Bearer "+tok)
}
},
},
}
}
}O proxy responde fora da cadeia de middleware, então ninguém pôs o usuário no contexto da requisição: leia a sessão do cookie.
func Token(c *trilha.Ctx) string {
u, err := Flow.Session(c)
if err != nil {
return ""
}
return u.Extra["api_token"]
}Os tipos que a API já publica#
O proxy acima é para o browser. A página não é o browser: ela roda no servidor, e o que ela precisa não é de uma URL, é da forma da resposta — que a API já publica, no documento OpenAPI dela.
trilha client http://api:8801/openapi.json --out internal/apiIsso grava um arquivo só, commitado como o trilha_gen.go, com uma struct por esquema e um método por operação. O trilha client ... --check na CI falha no dia em que o documento mudar e ninguém tiver regerado. O cliente gerado importa a biblioteca padrão e mais nada — nem a Trilha — então serve também num job e num teste.
// Acervo is the client of the API the front used to call from the browser. It
// is generated by `trilha client` from the document the API publishes, so the
// compiler is what catches the field that changed name.
var Acervo = api.New("http://api:8801", api.WithHeader(sessionToken), api.WithResponse(collectCookies))A credencial viaja no contexto, não no cliente:
// tokenKey is how the credential travels from the request to the client. The
// client takes a context, not a *trilha.Ctx: it also runs in a job and in a
// test, where there is no request to borrow a token from.
type tokenKey struct{}
// Calling is the context to hand the client: the session's token, and nothing
// else about the request.
func Calling(c *trilha.Ctx) context.Context {
tok, _ := c.Signed("api_token")
return context.WithValue(c.Context(), tokenKey{}, tok)
}
// sessionToken is the only place the credential appears. It runs for every
// request the client makes, so a call from a context that carries no token
// goes out unauthenticated instead of borrowing somebody else's session.
func sessionToken(ctx context.Context) http.Header {
tok, _ := ctx.Value(tokenKey{}).(string)
if tok == "" {
return nil
}
return http.Header{"Authorization": {"Bearer " + tok}}
}Quando a API é autenticada por cookie, a credencial vai no sentido contrário: ela volta num Set-Cookie, e a operação que a produz é o login. O WithResponse roda para cada resposta que o cliente recebe e entrega o status e os cabeçalhos dela, então o coletor é a mesma ideia do token — mora no context da chamada, e é desta requisição:
// cookieKey is the other direction of tokenKey. On an API authenticated by
// cookie the credential comes back in a Set-Cookie, and the operation that
// produces it is the login — so the page has to read the response and not only
// the body. The collector travels in the context of the call, which is what makes
// it right on a server: a http.CookieJar on the client would belong to the
// process, and would send one person's credential on the next person's call.
type cookieKey struct{}
// Collecting is Calling for an operation whose answer is also in the header: the
// context to hand the client, and where the cookies it set will be once the call
// returns.
func Collecting(c *trilha.Ctx) (context.Context, *[]*http.Cookie) {
var set []*http.Cookie
return context.WithValue(Calling(c), cookieKey{}, &set), &set
}
// collectCookies runs for every response the client receives, and fills the
// collector of that call — nothing at all when the call did not bring one.
func collectCookies(ctx context.Context, resp *http.Response) {
if set, ok := ctx.Value(cookieKey{}).(*[]*http.Cookie); ok {
*set = resp.Cookies()
}
}
// SignIn hands the browser the credential the API set. The operation is whichever
// one the API calls a login — here, the one this document happens to declare —
// and what matters is that its Set-Cookie reaches the response of this request
// and of no other.
func SignIn(c *trilha.Ctx, in api.User) error {
ctx, cookies := Collecting(c)
if _, err := Acervo.Users().UpsertUser(ctx, in); err != nil {
return err
}
for _, ck := range *cookies {
ck.Domain = "" // the cookie is this app's host now, not the API's
c.SetCookie(ck)
}
return c.Redirect("/documents")
}O mesmo gancho lê o ETag ou o Location de um POST que cria e o Link de uma resposta paginada. O que ele não oferece é o corpo: o corpo é da operação, que o devolve decodificado.
E aí a página é uma página. Não há estado de carregamento, porque o dado já está na mão na hora de escrever o HTML:
// Documents is the page. It runs on the server, so there is no loading state
// and no second endpoint: the data it renders is already in hand.
func Documents(c *trilha.Ctx) (h.Node, error) {
var q DocsQuery
if err := c.Bind(&q); err != nil {
return nil, err
}
page, err := Acervo.Documents().ListDocuments(Calling(c), api.DocumentsListDocumentsParams{
Page: int64(q.Page),
PageSize: int64(q.PerPage),
Q: q.Q,
Status: api.Status(q.Status),
})
if err != nil {
return nil, err
}
q.ListParams.Restrict("filename", "status")
return ui.DataTable(c, DocsColumns, page.Items, ui.ListState{
Params: q.ListParams,
Total: int(page.Total),
ID: "docs",
Search: "search",
}), nil
}As colunas são tipadas pela struct da própria API, então um campo que deixa de existir deixa de compilar — que é o motivo de gerar o cliente em vez de ler JSON para dentro de um mapa:
// DocsColumns names the columns of the table. The types are the API's, so a
// field that stops existing stops compiling.
var DocsColumns = ui.Columns[api.Document]{
{Key: "filename", Label: "File", Sort: true},
{Key: "status", Label: "Status", Cell: func(d api.Document) h.Node { return ui.Badge(h.Text(string(d.Status))) }},
{Key: "pages", Label: "Pages", Num: true, Cell: func(d api.Document) h.Node { return h.Text(strconv.Itoa(int(d.Pages))) }},
}Resposta binária chega como o *http.Response, então o corpo passa direto:
// DownloadDocument hands the API's answer straight to the browser: the body is
// never read into memory, and the Content-Type the API chose is the one that
// arrives.
func DownloadDocument(c *trilha.Ctx) error {
resp, err := Acervo.Documents().Download(Calling(c), c.Param("id"))
if err != nil {
return err
}
defer resp.Body.Close()
return c.Inline(c.Param("id")+".pdf", resp.Body, resp.Header.Get("Content-Type"))
}As duas portas são a mesma API e o mesmo token: a página usa o cliente, as ilhas e qualquer fetch continuam chamando /api/* nesta origem.
// ProxyAPI is the other half. The page uses the client above; the islands and
// any fetch still call /api/* on the same origin, and the upstream puts the
// same credential on the request. One API, one token, two doors.
func ProxyAPI(cfg *trilha.Config) {
cfg.Upstreams = map[string]trilha.Upstream{
"/api/": {
Target: "http://api:8801",
Headers: func(c *trilha.Ctx, hdr http.Header) {
if tok, _ := c.Signed("api_token"); tok != "" {
hdr.Set("Authorization", "Bearer "+tok)
}
},
Timeout: 60 * time.Second,
},
}
}Uma escrita recusada volta como um *Error, e quando a API é uma FastAPI o 422 que ela escreve já é uma mensagem por campo. Fields é essa lista, com a chave que o formulário usa, então a página que falhou se redesenha com cada mensagem ao lado do seu campo:
// CreateDocument is the write. A 422 is not an error page: it is the same form
// again, with the API's own messages next to the fields that earned them.
func CreateDocument(c *trilha.Ctx) (h.Node, error) {
var in api.DocumentIn
if err := c.Bind(&in); err != nil {
return nil, err
}
doc, err := Acervo.Documents().CreateDocument(Calling(c), in)
if e, ok := api.AsError(err); ok && e.Status == http.StatusUnprocessableEntity {
return documentForm(in, trilha.FieldErrors(e.Fields)), nil
}
if err != nil {
return nil, err
}
return nil, c.Redirect("/documents/" + doc.ID)
}trilha.FieldErrors é um map[string]string e Fields também, então a conversão é o adaptador inteiro: nenhuma segunda validação das mesmas regras deste lado, e nenhuma versão delas para divergir da API.
O que o gerador não vai fingir que entende — um oneOf, um esquema sem tipo — chega como json.RawMessage e sai como uma linha do relatório na hora de gerar; uma API sem response_model em lugar nenhum ganha a conta de quanto dela ficou sem tipo. Veja trilha client.
O que vem de graça#
POST /api/*exige o token do CSRF, como toda escrita do app.- O
Authorizationdo próprio navegador nunca chega à API — só o da sessão. - Um upload de 200 MB e um PDF voltando passam em stream, por cima do
MaxBodyBytese do write deadline. - Um
route.goque você escreva em/api/documentosresponde antes do proxy, então a API vai para Go endpoint a endpoint e o front não percebe.
O trilha audit avisa se o alvo é http:// puro para outro host, se o upstream não injeta nada num app com login, ou se o login está sem limite de taxa.