Carteira digital de investimentos construída inteiramente em Rust — backend e frontend. Projeto do curso RESTful Stack (DIO), com o instrutor Breno Lemos.
Status: Final — Carteira completa ✅ Além da API do admin e da sessão stateless via JWT, o usuário tem uma tela de ativos (
/assets) com saldo em caixa, posições (quantidade, preço, custo médio e lucro/prejuízo por ativo), resumo do patrimônio e o histórico de transações. As operações são depositar, comprar, vender e atualizar cotações (preços de mercado reais via API da Coinbase) — tudo com valores monetários exatos (rust_decimal) — e logout.
Crate: wallet (nome curto, sem traço). A UI segue a estética da demo do
curso: textos em inglês minúsculo e fonte monoespaçada.
src/
main.rs # enxuta: tokio::main -> App::start()
app.rs # App (boot, /health, shutdown gracioso) + AppState { db, config }
config.rs # Config: lê e valida os segredos do ambiente UMA vez (fail-fast)
models.rs # Asset, UserRecord, WalletSummary, Holding, Transaction
error.rs # AppError + IntoResponse (status HTTP, log de 5xx) + conversões
quotes.rs # cotações de mercado (Coinbase) -> atualiza preços dos ativos
repository.rs # Repository: todo o acesso ao banco (queries) + testes do core
auth/
admin.rs # extrator Admin (autenticação por secret key, p/ a API)
user.rs # User/UnauthenticatedUser, hash de senha, JWT e extratores
routes/
api.rs # API REST do admin (JSON) + testes (#[sqlx::test] + insta)
frontend.rs # front-end SSR (HTML): login/logout, carteira, operações, filtros
fixtures/ # dados SQL para testes (bitcoin_asset.sql)
snapshots/ # snapshots aceitos do insta (.snap)
templates/
base.html # esqueleto comum (head, Tailwind, fonte mono); as páginas o estendem
login.html # tela de login ({% extends "base.html" %})
assets.html # carteira: saldo, posições, resumo, transações e operações
migrations/ # assets + users + holdings/transactions + ajustes ({up,down}.sql)
docker-compose.yaml
.env / .env.example
O servidor Axum serve duas coisas ao mesmo tempo:
- API REST do admin (JSON, sob
/api) — cadastra/lista/atualiza ativos. - Front-end SSR (HTML, na raiz) — telas de login/cadastro e index do usuário.
- Banco no estado —
AppState { db: PgPool }. APgPoolé umArcpor dentro, então clonar o estado clona só o ponteiro, não as conexões. - Padrão repository —
Repositoryconcentra todas as queries (de ativos e de usuários). Os handlers não sabem como o banco funciona, só que ele existe; mudou o esquema, muda só o repository. Ele também é um extrator do Axum (FromRequestParts,Rejection = Infallible), injetado direto nos endpoints. - Queries checadas em compilação —
sqlx::query_as!valida cada SQL contra o banco real na hora de compilar. Se a tabela/coluna não existir, o programa não compila. (Por isso o Postgres precisa estar no ar para buildar.) - Migrações — versionadas em
migrations/, comup/downreversíveis (create_assetsecreate_users). - Testes —
#[sqlx::test]cria um banco efêmero por teste, roda as migrações e aplica fixtures; o insta garante que o JSON de resposta não mude sem querer (snapshot testing).
- Modelo
users—id(BIGSERIAL),username(TEXT UNIQUE — é a chave de login, como um e-mail seria) epassword_hash(TEXT). - Nunca senha em texto livre — usamos a lib
password-auth(argon2id por padrão) para gerar/verificar a hash. OUserRecord(linha crua do banco) não derivaSerializede propósito: nada de vazar a hash numa resposta. - Tipos que modelam o fluxo —
UnauthenticatedUser(nome + senha em texto livre, vindos do formulário) vira umUser(autenticado, campos privados) por um de dois caminhos:authenticate(confere a senha de um usuário existente) ouregister(cadastra um novo). A única forma de obter umUseré passando por um desses fluxos — tê-lo em mãos é prova de que a autenticação aconteceu. - Uma tela só — para simplificar, a tela de login serve aos dois propósitos: se o usuário não existe, é cadastrado; se existe, faz login.
- Login grava um cookie e redireciona — ao autenticar/cadastrar, o back-end
gera um JWT assinado (HS256, válido por 10 min), guarda-o num cookie
HttpOnlychamadotokene redireciona para/. - Stateless — não há sessão no servidor. O navegador reenvia o cookie
automaticamente; o back-end valida a assinatura do token com a
SECRET_KEY(só ele a conhece) e reconstrói o usuário. Token fabricado, adulterado ou expirado não passa na validação. - Extratores —
User(exige sessão válida) eOption<User>(tolerante: ausência/invalidez viraNone, sem erro). A telaindexusa oOption<User>: com sessão mostraHello <usuário>; sem ela, redireciona para/loginem vez de devolver um erro feio.
⚠️ Didático, não pronto para produção. Toda a autenticação foi feita "na mão" para desmistificar como JWT e cookies funcionam. Os segredos vêm do ambiente (verconfig.rs), mas não há refresh token nem rotação de chave, e o JWT expira em 10 min sem renovação. Em um projeto real, prefira uma solução de autenticação consolidada.
- Dinheiro exato com
rust_decimal— saldo, preços, quantidades e custo médio sãoDecimal(mapeado emNUMERICno Postgres). Nada def64: não há ruído de arredondamento de ponto flutuante em valores monetários. holdings+transactions, não um log de compras — em vez de derivar tudo de um histórico append-only, o esquema separa duas preocupações:holdings— a posição atual por(user, asset):quantitypossuída eavg_cost(custo médio ponderado). Mutada atomicamente em cada compra/venda.transactions— o livro-razão imutável de tudo o que aconteceu (deposit,buy,sell), usado para a tela de histórico e auditoria. Manter a posição materializada deixa as leituras triviais (sem agregação pesada) e torna a lógica de mover dinheiro umUPDATEtransacional explícito.
- Operações transacionais (
repository.rs):deposit— credita o saldo e registra a transação.buy_asset— trava o ativo e o usuário (FOR UPDATE), confere saldo, debita, abre/atualiza a posição recalculando o custo médio ponderado viaON CONFLICT ... DO UPDATE, e registra a transação. Tudo numa transação: saldo insuficiente reverte sem deixar rastro.sell_asset— confere a posição, credita o saldo ao preço atual, reduz a quantidade (ou apaga a posição se zerar) e registra a transação. Cada uma é coberta por testes#[sqlx::test](ver "Testes").
- Resumo do patrimônio no banco —
wallet_summarycalcula numa query só: saldo em caixa, valor das posições (Σ quantidade × preço), patrimônio total, total investido (Σ quantidade × custo_médio) e resultado dos ativos (Σ quantidade × (preço − custo_médio)). - Cotações de mercado reais (
quotes.rs) — "atualizar cotações" buscaUSD→BRLeBTC→BRLna API pública da Coinbase (concorrente viatokio::try_join!) e aplica os preços aos ativos cujo nome casa (bitcoin, btc, dólar, real…) em um únicoUPDATEcomUNNEST(sem N+1). - Tela
/assets— busca resumo, posições, ativos disponíveis e transações em paralelo (tokio::try_join!), já que nenhuma query depende das outras. As operações (depositar/comprar/vender) abrem um formulário inline na própria tela. - Lucro/prejuízo colorido — o filtro
nonnegativedecide entre verde (text-emerald-400, com prefixo+) e vermelho (text-rose-400), no resumo, por posição e nas transações. - Filtros Askama customizados (
#[askama::filter_fn], módulofiltersemfrontend.rs):human_datetime(AAAA-MM-DD HH:MM),money(formataDecimalcomoR$ 1.234,56, com separador de milhar e duas casas),quantity(normaliza oDecimal, sem zeros à toa),nonnegativeetransaction_kind(rótulo em pt-BR paradeposit/buy/sell). - Estado vazio e acessibilidade — empty states dedicados,
focus-visible,scope="col",inputmode="decimal"/autocompletenos formulários, favicon SVG inline ecolor-scheme: darknobase.html. - Herança de templates —
base.htmlconcentra o esqueleto comum;login.htmleassets.htmlpreenchemtitle/contentcom{% extends "base.html" %}. - Logout —
GET /logoutremove o cookietokene redireciona para/login. - Index = roteador puro —
/com sessão vai para/assets; sem ela,/login.
| Método | Rota | Auth | Descrição |
|---|---|---|---|
GET |
/health |
— | Sonda de saúde (200 se o serviço e o banco respondem) |
GET |
/login · /register |
— | Formulário de login / cadastro |
POST |
/login · /register |
— | Autentica ou cadastra; grava o cookie token e vai para / |
GET |
/logout |
— | Remove o cookie token e redireciona para /login |
GET |
/ |
opcional | Roteador: com sessão vai para /assets, sem ela para /login |
GET |
/assets |
sessão | Carteira: saldo, posições, resumo e transações |
POST |
/deposit |
sessão | Deposita saldo (amount) |
POST |
/buy |
sessão | Compra um ativo (asset_id, quantity) ao preço atual |
POST |
/sell |
sessão | Vende um ativo (asset_id, quantity) ao preço atual |
POST |
/quotes/sync |
sessão | Atualiza os preços com cotações de mercado |
Os de escrita exigem o header Authorization: I'm the admin.
| Método | Rota | Auth | Descrição |
|---|---|---|---|
GET |
/api/assets |
— | Lista os ativos |
POST |
/api/assets |
admin | Cadastra um ativo ({name, unit_value}) |
PATCH |
/api/assets |
admin | Atualiza um ativo ({id, name?, unit_value?}) |
Erros: 400 header ausente / username já em uso, 401 credencial ou token
inválido, 404 ativo/usuário inexistente, 500 erro de banco ou template.
Pré-requisitos: Rust, Docker (ou um Postgres próprio) e,
opcionalmente, psql.
# 1) subir o Postgres
docker compose up -d
# 2) configurar a conexão (copie o exemplo, ajuste se precisar)
Copy-Item .env.example .env
# 3) criar as tabelas (todas as migrações de migrations/). Com a CLI do SQLx:
cargo install sqlx-cli --no-default-features --features postgres,rustls
cargo sqlx migrate run
# Sem a CLI, aplique os arquivos *.up.sql de migrations/ em ordem com psql.
# 4) rodar (o Postgres precisa estar no ar — o SQLx checa as queries ao compilar)
cargo runO
DATABASE_URLprecisa estar disponível na hora de compilar (o.envjá resolve isso, pois o SQLx o lê automaticamente). As credenciais do.envbatem com odocker-compose.yaml.
Abra http://localhost:3000/login, digite um usuário e senha e clique em entrar:
- usuário novo → é cadastrado na hora (a mesma tela serve para login e cadastro), e você já entra;
- usuário existente → faz login validando a senha.
Depois de entrar você cai em /assets. Pode dar F5 à vontade: a sessão
fica no cookie. Se o cookie token for removido ou adulterado, você é mandado
de volta para /login.
Na tela de ativos:
- cada ativo que você já comprou aparece num card, com a quantidade total, o
valor unitário atual e o lucro/prejuízo total (verde com
+se positivo, vermelho se negativo); - clique em histórico de compras para expandir a tabela com cada compra individual (data, quantidade, valor pago e variação);
- clique em registrar compra para abrir o formulário (escolha o ativo, a quantidade e o valor unitário pago) — ao confirmar, a página recarrega com o novo total já recalculado;
- sair remove o cookie e volta para
/login.
No PowerShell, prefira Invoke-RestMethod (ele já desserializa a resposta):
$admin = @{ Authorization = "I'm the admin" }
Invoke-RestMethod http://127.0.0.1:3000/api/assets
Invoke-RestMethod -Method Post http://127.0.0.1:3000/api/assets -Headers $admin `
-ContentType 'application/json' -Body '{"name":"bitcoin","unit_value":10}'
Invoke-RestMethod -Method Patch http://127.0.0.1:3000/api/assets -Headers $admin `
-ContentType 'application/json' -Body '{"id":1,"unit_value":20}'Com
curl.exe, use aspas simples no JSON e não escape as aspas com\(no PowerShell as aspas simples já são literais).
cargo testOs testes usam #[sqlx::test] (precisa do Postgres no ar) e insta para os
snapshots. Para revisar/atualizar snapshots: cargo install cargo-insta e
cargo insta review — ou rode os testes uma vez com $env:INSTA_UPDATE='always'.
Cobertura: além dos snapshots da API do admin, o núcleo financeiro tem
testes dedicados em repository.rs (depósito, compra, venda, custo médio
ponderado, e as guardas de saldo/posição insuficientes), cada um num banco
efêmero isolado.
Sobre a base do curso, alguns ajustes de gente grande:
- Configuração centralizada e fail-fast (
config.rs) — os segredos são lidos e validados uma vez no boot. Falta um segredo? O serviço não sobe, com mensagem clara — em vez de devolver um401confuso na primeira requisição. - Sem releitura de ambiente por requisição —
JWT_SECRET,ADMIN_SECRET_KEYeCOOKIE_SECUREficam naAppState; validar token/credencial não toca mais o ambiente a cada chamada. - Erros 5xx não vazam detalhes — falhas internas são logadas no servidor com a causa raiz e respondidas ao cliente com uma mensagem genérica (nada de texto de erro do SQL na resposta).
- Startup robusto —
.envausente não é fatal (produção usa o ambiente real),RUST_LOGcontrola o nível de log (EnvFilter), desligamento gracioso no Ctrl+C e endereço de escuta configurável (BIND_ADDR). - Sonda
/health— confirma serviço + banco para health checks de orquestradores. - Sem N+1 — a sincronização de cotações virou um único
UPDATE ... UNNEST.
A sequência completa em funcionamento: subir o Postgres (docker compose up),
aplicar as migrações, rodar o servidor (cargo run) e exercer a API com
Invoke-RestMethod (GET/POST/PATCH), além da suíte de testes passando
(cargo test → 13 passed). (A captura abaixo é de uma execução anterior, com a
suíte ainda menor.)
- TLS do cargo: o download de dependências pode falhar com
CRYPT_E_NO_REVOCATION_CHECK; o.cargo/config.tomljá desativa só a checagem de revogação. - Postgres 18 no Docker: o volume é montado em
/var/lib/postgresql(e não mais em/var/lib/postgresql/data, convenção que mudou na imagem 18+). jwt-simplesemcmake: por padrão a lib usa BoringSSL, que exigecmake- toolchain C++. O
Cargo.tomla configura comdefault-features = false, features = ["pure-rust"]para usar criptografia 100% Rust e dispensar ocmake.
- toolchain C++. O
Em uso: axum (+ axum-extra para cookies), tokio, sqlx (Postgres, compile-time checked), askama (templates SSR), password-auth (hash argon2), jwt-simple (JWT), dotenvy (.env), tracing + tracing-subscriber, color-eyre, serde / serde_json, thiserror. Em testes: insta (snapshots).
- Aula 1 — Introdução ✅: visão geral e fundação do repositório.
- Aula 2 — Primeiros passos com Axum ✅: API REST do admin, auth por secret key, armazenamento em memória e tratamento de erros.
- Aula 3 — SQLx + Postgres ✅: banco Postgres via SQLx, padrão repository,
migrações em SQL e testes (
#[sqlx::test]+ insta). - Aula 4 — Primeira tela (Askama) ✅: modelo de usuário no banco (senha com hash argon2), tela de login/cadastro renderizada no servidor.
- Aula 5 — Autenticação stateless com JWT ✅: sessão via JWT assinado em cookie HttpOnly; index que redireciona ao login quando não há sessão válida.
- Final — Carteira completa ✅: dinheiro exato com
rust_decimal, esquemaholdings+transactions, operações transacionais (depositar/comprar/vender) com custo médio ponderado, resumo do patrimônio e cotações de mercado reais (Coinbase). Acrescido de refinamentos de engenharia: configuração fail-fast, sonda/health, desligamento gracioso, censura de erros 5xx na resposta e testes do núcleo financeiro (ver "Refinamentos de engenharia").
