Aplicação web para acompanhar uma carteira de investimentos: você entra com sua conta, registra compras e vendas de ativos e vê quanto sua carteira vale hoje.
Projeto do desafio Rust Fullstack — Carteira de Investimentos da Digital Innovation One, construído a partir do repositório base e evoluído com a melhoria descrita abaixo.
- O que o projeto faz
- Qual melhoria foi implementada
- Estatísticas do trabalho
- Tecnologias usadas
- Como executar
- Como testar
- Rotas
- Estrutura do projeto
- Decisões de projeto
- O que eu aprendi
- Limitações conhecidas
- Autenticação — cadastro e login com senha protegida por hash Argon2. A sessão é mantida por um token JWT guardado em cookie.
- Catálogo de ativos — uma API REST cria, lista e atualiza os ativos disponíveis (nome e valor unitário). Alterar o catálogo exige credencial de administrador.
- Carteira pessoal — cada pessoa registra quanto possui de cada ativo. As carteiras são isoladas: ninguém vê a posição de ninguém.
- Dashboard — uma página mostra o valor total da carteira, cada posição com seu total e o quanto cada ativo representa do patrimônio.
O projeto base tinha uma tabela de ativos e uma tela de login, mas não tinha carteira. Depois de entrar, a pessoa via exatamente isto:
// src/routes/frontend.rs, no projeto base
Some(user) => Ok(Html(format!("Hello, {}", user.username())).into_response()),Uma string. Não havia como registrar o que se possui, nem saber quanto isso vale. A melhoria fecha essa lacuna.
Uma tabela nova, holdings, liga pessoa ↔ ativo ↔ quantidade, com chave
primária composta (user_id, asset_id). Isso garante, no próprio banco, que
não existam duas linhas para o mesmo ativo na mesma carteira.
A compra usa INSERT ... ON CONFLICT DO UPDATE, então comprar um ativo que já
está na carteira soma à posição existente em uma única operação atômica —
duas compras simultâneas não se sobrescrevem.
A venda roda dentro de uma transação com SELECT ... FOR UPDATE, para que duas
vendas concorrentes não consigam vender mais do que a pessoa tem. Quando a venda
zera a posição, a linha é removida em vez de ficar com quantidade zero.
A rota / deixou de ser uma string e virou uma página que mostra:
- o valor total da carteira, somando quantidade × valor unitário de tudo;
- uma tabela de posições com quantidade, valor unitário, total e a participação de cada ativo no patrimônio;
- formulário de compra com os ativos do catálogo;
- venda direta em cada linha da tabela;
- um estado vazio explicativo para quem ainda não comprou nada.
Valores aparecem no formato brasileiro (R$ 212.200,00) e quantidades mantêm
até 8 casas decimais sem zeros à direita, para acomodar ativos fracionários.
- Quantidade precisa ser um número finito maior que zero —
NaNe infinito são recusados antes de chegar ao banco. - Vender mais do que se tem devolve uma mensagem que diz quanto você tem e quanto tentou vender, em vez de um erro de constraint do Postgres.
- Senha exige no mínimo 8 caracteres; nome de usuário aceita até 40 caracteres entre letras, números, ponto, hífen e underline.
- Erros em rotas de navegador viram página HTML; erros de API continuam JSON. Falhas internas (banco, template) mostram uma mensagem genérica — o detalhe vai para o log, não para a tela.
- As chaves de assinatura de token e de administrador saíram do código-fonte
e passaram a vir do ambiente, com validação de tamanho mínimo no boot. Antes
eram constantes literais (
b"im-so-secret","im-the-admin") — qualquer pessoa com acesso ao repositório conseguiria forjar um token de sessão válido. - O cookie de sessão ganhou
SameSite=Lax,Path=/e expiração alinhada ao token. Antes o cookie era permanente enquanto o token durava 10 minutos, o que deslogava a pessoa em silêncio. - Passou a existir logout.
A suíte saiu de 3 para 35 testes, entre eles:
- toda a mecânica de compra e venda contra um Postgres real — venda parcial, venda total, e os dois casos de erro;
- isolamento entre carteiras: a posição de uma pessoa não pode aparecer na carteira de outra;
- cálculo de total e participação, incluindo a carteira vazia (divisão por zero);
- formatação de moeda, quantidade e porcentagem, com arredondamento de centavos;
- validação de segredos curtos ou ausentes.
Comparação entre o repositório base da DIO e esta entrega. Todos os números são reproduzíveis com os comandos indicados.
| Métrica | Base DIO | Esta entrega | Variação |
|---|---|---|---|
Linhas de Rust (src/) |
556 | 1.684 | +203% |
Arquivos .rs |
11 | 13 | +2 |
| Testes automatizados | 3 | 35 | +32 |
| Templates HTML | 1 | 4 | +3 |
| Linhas de HTML | 35 | 194 | +454% |
| Tabelas no banco | 2 | 3 | +1 |
| Arquivos de migração | 4 | 6 | +2 |
| Rotas de navegador | 2 | 5 | +3 |
| Endpoints de API | 3 | 3 | — |
Como reproduzir esses números
# Linhas de Rust
find src -name "*.rs" -exec cat {} \; | wc -l
# Testes (unitários + de integração com banco)
grep -rhoE "#\[(test|sqlx::test)" src --include="*.rs" | wc -l
# Rotas de navegador
grep -ohE '\.route\("[^"]+"' src/routes/frontend.rs | wc -l| Arquivo | Base | Entrega | Situação |
|---|---|---|---|
src/repository.rs |
104 | 439 | reescrito — queries da carteira + 10 testes |
src/routes/frontend.rs |
61 | 272 | reescrito — dashboard, compra, venda, logout |
src/error.rs |
47 | 192 | reescrito — erros em HTML e JSON |
src/models.rs |
14 | 157 | reescrito — Holding, Wallet e cálculos |
src/auth/user.rs |
124 | 166 | validações + segredo do ambiente |
src/format.rs |
— | 129 | novo — moeda e quantidade em pt-BR |
src/config.rs |
— | 110 | novo — segredos lidos do ambiente |
src/app.rs |
50 | 62 | migrações no boot |
src/auth/admin.rs |
26 | 24 | segredo do ambiente |
src/main.rs |
13 | 16 | novos módulos |
src/routes/api.rs |
113 | 113 | intocado |
templates/dashboard.html |
— | 129 | novo |
templates/base.html |
— | 18 | novo — layout compartilhado |
templates/error.html |
— | 13 | novo |
templates/login.html |
35 | 34 | passou a herdar do layout |
Também foram criados: a migração holdings, uma fixture de testes,
.env.example, rust-toolchain.toml e os 10 arquivos de metadados em .sqlx/.
A aplicação não foi apenas compilada — foi executada e percorrida de ponta a ponta por HTTP antes da entrega:
| Cenário | Resultado esperado | Verificado |
|---|---|---|
GET / sem sessão |
redireciona para /login |
303 → /login |
| Login criando conta | cookie HttpOnly com expiração |
303 + cookie |
POST /api/assets sem credencial |
recusado | 400 |
| Compra de 0,5 BTC + 3 ETH | R$ 212.200,00 | R$ 212.200,00 |
| Participação do Bitcoin | 82,5% | 82,5% |
| Vender mais do que possui | erro explicativo | 400 + mensagem |
| Vender ativo que não possui | não encontrado | 404 |
Quantidade zero, negativa ou NaN |
recusada | 400 |
| Venda total | posição encerrada | 303 + posição removida |
| Segunda conta vê carteira alheia | não | carteira vazia |
| Logout | sessão encerrada | 303 → /login |
| Build com o Postgres desligado | compila via .sqlx/ |
compilou |
Além disso: cargo test com 35 testes passando, cargo clippy --all-targets
sem nenhum aviso e cargo fmt aplicado.
| Camada | Ferramenta |
|---|---|
| Linguagem | Rust (edição 2024) |
| Servidor web | Axum 0.8 |
| Runtime assíncrono | Tokio |
| Banco de dados | PostgreSQL 18 via SQLx 0.8 |
| Templates HTML | Askama 0.15 |
| Senhas | password-auth (Argon2) |
| Sessão | jwt-simple (HS256) |
| Estilo | Tailwind CSS via CDN |
| Testes | sqlx::test + insta |
| Infra local | Docker Compose |
No Windows, o Rust precisa de um linker. Instale os Visual Studio Build Tools com o workload Desenvolvimento para desktop com C++ e o Windows SDK:
winget install Microsoft.VisualStudio.2022.BuildTools --override ` "--quiet --wait --add Microsoft.VisualStudio.Workload.VCTools --includeRecommended"
# 1. Configure o ambiente
cp .env.example .env
# edite o .env e troque JWT_SECRET e ADMIN_SECRET por valores próprios
# 2. Suba o banco
docker compose up -d
# 3. Rode a aplicação (as migrações são aplicadas sozinhas no boot)
cargo runA aplicação sobe em http://localhost:3000.
Sobre o
SQLX_OFFLINE=true: as macros do SQLx conferem cada query contra o banco durante a compilação. Os metadados dessa conferência estão versionados em.sqlx/, então o projeto compila mesmo com o Postgres desligado. Se você alterar alguma query, rodecargo sqlx preparecom o banco no ar para regravar esses metadados.
- Abra http://localhost:3000; você será levado para a tela de login.
- Digite um usuário e uma senha de pelo menos 8 caracteres. Se a conta não existir, ela é criada na hora.
- A carteira aparece vazia — é esperado, o catálogo de ativos ainda está sem nada.
- Cadastre alguns ativos pela API (use o
ADMIN_SECRETdo seu.env):
curl -X POST http://localhost:3000/api/assets \
-H "Authorization: SEU_ADMIN_SECRET" \
-H "Content-Type: application/json" \
-d '{"name": "Bitcoin", "unit_value": 350000.0}'
curl -X POST http://localhost:3000/api/assets \
-H "Authorization: SEU_ADMIN_SECRET" \
-H "Content-Type: application/json" \
-d '{"name": "Ethereum", "unit_value": 12400.0}'- Recarregue o dashboard: os ativos agora aparecem no formulário de compra.
Compre
0,5de Bitcoin e3de Ethereum — o total deve marcar R$ 212.200,00, com Bitcoin representando 82,5% do patrimônio.
Os testes sobem bancos temporários isolados, então o Docker precisa estar no ar:
docker compose up -d
cargo testPara conferir a saúde geral do código:
cargo clippy --all-targets
cargo fmt --check| Método | Rota | O que faz |
|---|---|---|
GET |
/ |
Dashboard da carteira (redireciona para o login se não autenticado) |
GET |
/login |
Formulário de entrada |
POST |
/login |
Autentica ou cria a conta |
POST |
/logout |
Encerra a sessão |
POST |
/carteira/comprar |
Registra uma compra |
POST |
/carteira/vender |
Registra uma venda |
| Método | Rota | Autenticação | O que faz |
|---|---|---|---|
GET |
/api/assets |
— | Lista o catálogo de ativos |
POST |
/api/assets |
Authorization: <ADMIN_SECRET> |
Cadastra um ativo |
PATCH |
/api/assets |
Authorization: <ADMIN_SECRET> |
Atualiza nome ou valor |
src/
├── main.rs ponto de entrada
├── app.rs boot: logs, configuração, migrações, rotas
├── config.rs segredos lidos do ambiente
├── error.rs erros da aplicação em JSON (API) e HTML (navegador)
├── format.rs moeda e quantidade no padrão brasileiro
├── models.rs Asset, Holding, Wallet e os cálculos da carteira
├── repository.rs todo o acesso ao banco
├── auth/
│ ├── admin.rs credencial de administrador da API
│ └── user.rs cadastro, login e token de sessão
└── routes/
├── api.rs endpoints JSON
└── frontend.rs páginas e formulários
templates/ base, login, dashboard e página de erro
migrations/ evolução do banco (assets, users, holdings)
.sqlx/ metadados das queries, para compilar sem banco
users assets holdings
───── ────── ────────
id BIGSERIAL id BIGSERIAL user_id ─┐ FK → users.id
username UNIQUE name UNIQUE asset_id ─┤ FK → assets.id
password_hash unit_value quantity │ CHECK > 0
PK (user_id, asset_id)
Por que a posição, e não o extrato. holdings guarda quanto se tem agora,
não a sequência de operações. É o suficiente para responder "quanto vale minha
carteira", que é a pergunta do desafio, e mantém a leitura do dashboard em uma
única consulta. O custo está registrado em limitações.
Por que a venda usa transação, e a compra não. A compra é uma única
instrução (INSERT ... ON CONFLICT), e o Postgres já a executa atomicamente. A
venda precisa ler para decidir — se sobra posição ou se ela é encerrada — e
essa leitura precisa de FOR UPDATE para não competir com outra venda.
Por que os erros têm dois formatos. O mesmo AppError serve à API e ao
navegador; um invólucro (HtmlError) escolhe entre JSON e página HTML. Sem
isso, um erro de venda devolveria JSON cru na cara de quem clicou num botão.
Por que as conversões de erro são escritas uma a uma. Um impl genérico
sobre Into<AppError> colidiria com a conversão reflexiva da biblioteca padrão.
O compilador rejeita — está anotado no código para não ser "simplificado" depois.
Por que jwt-simple em pure-rust. A configuração padrão traz o BoringSSL,
que exige CMake e um compilador C++. Com a feature pure-rust, o projeto compila
com o toolchain do Rust e mais nada.
Extractors do Axum são o ponto de organização do projeto. Repository,
User e Admin implementam FromRequestParts, então um handler declara o que
precisa na assinatura e o Axum resolve. Autorização deixa de ser um if no
começo da função e vira um tipo: se Admin está na assinatura, a rota é
protegida — não tem como esquecer.
Option<User> como extractor separa "não logado" de "erro". É o que permite
o dashboard redirecionar para o login em vez de devolver 401.
As macros do SQLx conferem SQL em tempo de compilação. Um nome de coluna
errado não passa do cargo build. Em troca, elas precisam de um banco acessível
durante a compilação — ou dos metadados versionados em .sqlx/. Descobri isso
da pior forma: derrubei o banco para testar as migrações no boot e o projeto
parou de compilar, então nem dava para rodar a aplicação que criaria as tabelas.
Restrições no banco valem mais que validação na aplicação. O
CHECK (quantity > 0) e a chave primária composta impedem estados inválidos
mesmo que a aplicação tenha um bug. A validação em Rust existe para produzir uma
mensagem boa, não para ser a única linha de defesa.
Ponto flutuante e dinheiro se dão mal. Vender exatamente tudo que se tem pode
deixar um resíduo como 2,7e-17 em vez de zero. Por isso a venda trata qualquer
sobra abaixo de 1e-9 como posição encerrada.
Segredo em constante de código é segredo vazado. b"im-so-secret" no fonte
significa que quem lê o repositório assina tokens válidos para qualquer usuário.
Mover para o ambiente foi de longe a correção de maior impacto por linha escrita.
- Valores são
f64. O ideal para dinheiro seriaNUMERICno Postgres com um tipo decimal em Rust. Manteve-sef64por consistência com o projeto base; a migração é a próxima evolução natural. - Não há histórico de operações. A carteira guarda a posição atual, não a sequência de compras e vendas — então não há preço médio nem cálculo de lucro.
- Os preços são cadastrados à mão. Não existe integração com cotação de mercado.
- O login cadastra quem ainda não tem conta. É o fluxo do projeto base, mantido para não exigir dois formulários; em um sistema real o cadastro seria uma tela separada.
- O catálogo de ativos só é gerenciado por API. Não há tela de administração;
criar ou atualizar ativos exige
curlou equivalente.
Projeto educacional, desenvolvido para o desafio da Digital Innovation One.