Ir para o conteúdo
Trilha
Capítulos

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/api

Isso 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 Authorization do 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 MaxBodyBytes e do write deadline.
  • Um route.go que você escreva em /api/documentos responde 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.

Veja Upstreams e Auth para os detalhes.