- Update SpoolPresetRepository to include user_id in the update query. - Expand .gitignore to include various environment and temporary files. - Revise agent documentation for better clarity and formatting. - Implement pull-to-refresh functionality in the inventory list. - Integrate API calls for deleting and updating filaments, ensuring state synchronization. - Add custom color picker for filament color selection with hex validation. - Update AndroidManifest and Gradle files for improved configuration and permissions. - Refactor MainActivity and MainApplication for better splash screen handling. - Update styles and colors for a cohesive UI experience. - Replace splash screen logos and icons with new assets.
15 KiB
MeowSpool Backend — Agent Guide
Visão Geral
Backend da aplicação MeowSpool escrito em Rust, utilizando Axum como framework HTTP e SQLx com PostgreSQL como banco de dados. A arquitetura segue o padrão Hexagonal (Ports & Adapters), garantindo que o núcleo do domínio seja completamente isolado de detalhes de infraestrutura.
Stack
| Componente | Biblioteca | Versão mínima |
|---|---|---|
| HTTP Framework | axum |
0.7 |
| Async Runtime | tokio (full) |
1.x |
| ORM / Query | sqlx (postgres, uuid, time, macros) |
0.8 |
| Autenticação JWT | jsonwebtoken |
9.x |
| Hash de senha | argon2 |
0.5 |
| OAuth Google | oauth2 |
4.x |
| UUID | uuid (v4, serde) |
1.x |
| Serialização | serde, serde_json |
1.x |
| Erros | thiserror |
1.x |
| Env vars | dotenvy |
0.15 |
| Logging | tracing, tracing-subscriber |
0.1 |
| Validação | validator |
0.18 |
| HTTP Client | reqwest (json, rustls-tls) |
0.12 |
| Geração QR Code | qrcode |
0.14 |
Estrutura de Pastas
backend/
├── Cargo.toml
├── Cargo.lock
├── .env.example
├── agent.md <- este arquivo
├── migrations/ <- SQL puro, gerenciado pelo SQLx CLI
│ ├── 20240101000001_create_users.sql
│ ├── 20240101000002_create_spool_presets.sql
│ └── 20240101000003_create_filaments.sql
└── src/
├── main.rs <- entry point: inicializa config, DB, router e servidor
├── config.rs <- struct Config lida de variáveis de ambiente
├── error.rs <- AppError unificado com IntoResponse
├── router.rs <- composição de todas as rotas Axum
│
├── domain/ <- NÚCLEO: entidades puras, sem dependências externas
│ ├── mod.rs
│ ├── user.rs <- struct User, enum AuthProvider
│ ├── filament.rs <- struct Filament, enum Material
│ └── spool_preset.rs <- struct SpoolPreset
│
├── ports/ <- INTERFACES: traits que o domínio exige
│ ├── mod.rs
│ ├── user_repository.rs <- trait UserRepository
│ ├── filament_repository.rs <- trait FilamentRepository
│ └── spool_preset_repository.rs <- trait SpoolPresetRepository
│
├── application/ <- CASOS DE USO: orquestram domínio + ports
│ ├── mod.rs
│ ├── auth_service.rs <- login, register, OAuth, refresh, logout
│ ├── filament_service.rs <- CRUD, cálculo de peso líquido, QR, SVG
│ └── spool_preset_service.rs <- CRUD presets (system read-only, user CRUD)
│
└── adapters/
├── inbound/ <- HTTP: recebe requisições, delega ao application
│ ├── mod.rs
│ ├── auth_handler.rs
│ ├── filament_handler.rs
│ ├── spool_preset_handler.rs
│ ├── user_handler.rs
│ └── middleware/
│ └── auth.rs <- extrator JWT que popula CurrentUser no estado
└── outbound/ <- INFRAESTRUTURA: implementa as traits de ports
├── mod.rs
├── postgres_user_repo.rs
├── postgres_filament_repo.rs
└── postgres_spool_preset_repo.rs
Arquitetura Hexagonal — Regras de Dependência
adapters/inbound (HTTP)
|
v
application (services)
|
v
domain (entidades)
|
^
ports (traits)
|
^
adapters/outbound (PostgreSQL)
Regra fundamental: O domain e os ports nunca importam nada de adapters ou application. Dependências permitidas no domínio: uuid, serde, time. Qualquer violação é um bug arquitetural.
Rotas da API
Todas as rotas são prefixadas com /api/v1.
Auth — /api/v1/auth
| Método | Rota | Handler | Acesso |
|---|---|---|---|
| POST | /register |
register_handler |
Público |
| POST | /login |
login_handler |
Público |
| POST | /oauth/google |
google_oauth_handler |
Público |
| POST | /refresh |
refresh_token_handler |
Público (requer refresh token) |
| POST | /logout |
logout_handler |
Autenticado |
| POST | /forgot-password |
forgot_password_handler |
Público |
| POST | /verify-email |
verify_email_handler |
Público |
| POST | /reset-password |
reset_password_handler |
Público (requer token de reset) |
Users — /api/v1/users
| Método | Rota | Handler | Acesso |
|---|---|---|---|
| GET | /me |
get_me_handler |
Autenticado |
| PUT | /me |
update_me_handler |
Autenticado |
Filaments — /api/v1/filaments
| Método | Rota | Handler | Acesso |
|---|---|---|---|
| GET | / |
list_filaments_handler |
Autenticado |
| POST | / |
create_filament_handler |
Autenticado |
| GET | /:id |
get_filament_handler |
Autenticado |
| PUT | /:id |
update_filament_handler |
Autenticado |
| DELETE | /:id |
delete_filament_handler |
Autenticado |
| GET | /:id/qrcode |
get_qrcode_handler |
Autenticado |
| GET | /:id/label.svg |
export_label_handler |
Autenticado |
Query params de listagem (GET /filaments):
material— filtra por tipo (PLA, ABS, PETG, TPU, ASA, PA, PC...)brand— filtra por marcasearch— busca em marca, modelo e notasstock_level—low(<=15%),medium(<=35%),ok(>35%)sort—net_weight_asc,net_weight_desc,created_at_desc(padrão)page/per_page— paginação (padrão: página 1, 20 itens)
Spool Presets — /api/v1/spool-presets
| Método | Rota | Handler | Acesso |
|---|---|---|---|
| GET | / |
list_presets_handler |
Autenticado |
| POST | / |
create_preset_handler |
Autenticado |
| PUT | /:id |
update_preset_handler |
Autenticado (apenas presets do user) |
| DELETE | /:id |
delete_preset_handler |
Autenticado (apenas presets do user) |
Dashboard — /api/v1/dashboard
| Método | Rota | Handler | Acesso |
|---|---|---|---|
| GET | / |
dashboard_handler |
Autenticado |
Resposta do dashboard:
{
"total_stock_kg": 14.2,
"low_stock_count": 3,
"by_material": [
{ "material": "PLA", "count": 4, "total_kg": 5.8 }
],
"low_stock_filaments": [
{ "id": "...", "model": "PETG", "net_weight_g": 120, "percentage": 12 }
],
"recent_filaments": [...]
}
Padrão de Erros
Use thiserror em todos os módulos e um AppError central em src/error.rs:
#[derive(Debug, thiserror::Error)]
pub enum AppError {
#[error("not found")]
NotFound,
#[error("unauthorized")]
Unauthorized,
#[error("forbidden")]
Forbidden,
#[error("validation error: {0}")]
Validation(String),
#[error("conflict: {0}")]
Conflict(String),
#[error("internal error")]
Internal(#[from] anyhow::Error),
}
AppError implementa IntoResponse do Axum, mapeando cada variante para o status HTTP correto e body JSON consistente:
{ "error": "not found", "code": "NOT_FOUND" }
Convenções de Código
Nomenclatura
- Structs de domínio:
PascalCase—User,Filament,SpoolPreset - Traits (ports):
PascalCasecom sufixoRepository—UserRepository - Implementações concretas: prefixo do banco —
PostgresUserRepository - Serviços: sufixo
Service—AuthService,FilamentService - Handlers: sufixo
_handler—login_handler,create_filament_handler - DTOs de entrada: sufixo
Request—CreateFilamentRequest - DTOs de saída: sufixo
Response—FilamentResponse
Estrutura de um Handler
Todo handler deve ser uma função async pequena. A lógica de negócio nunca fica no handler — ela fica no application/*_service.rs.
// adapters/inbound/filament_handler.rs
pub async fn create_filament_handler(
State(state): State<AppState>,
Extension(current_user): Extension<CurrentUser>,
Json(req): Json<CreateFilamentRequest>,
) -> Result<impl IntoResponse, AppError> {
req.validate()?;
let filament = state.filament_service.create(current_user.id, req).await?;
Ok((StatusCode::CREATED, Json(FilamentResponse::from(filament))))
}
Estrutura de um Service
O service recebe repositórios via injeção de dependência (trait objects em Arc):
// application/filament_service.rs
pub struct FilamentService {
repo: Arc<dyn FilamentRepository>,
preset_repo: Arc<dyn SpoolPresetRepository>,
}
impl FilamentService {
pub async fn create(&self, user_id: Uuid, req: CreateFilamentRequest) -> Result<Filament, AppError> {
// 1. buscar preset para calcular net_weight
// 2. calcular net_weight_g = total_weight_g - spool_weight_g
// 3. construir entidade Filament
// 4. persistir via repo
// 5. retornar entidade
}
}
Estrutura de um Repository (Port)
// ports/filament_repository.rs
#[async_trait::async_trait]
pub trait FilamentRepository: Send + Sync {
async fn find_by_id(&self, id: Uuid, user_id: Uuid) -> Result<Option<Filament>, AppError>;
async fn list(&self, user_id: Uuid, filter: FilamentFilter) -> Result<Vec<Filament>, AppError>;
async fn create(&self, filament: &Filament) -> Result<Filament, AppError>;
async fn update(&self, filament: &Filament) -> Result<Filament, AppError>;
async fn delete(&self, id: Uuid, user_id: Uuid) -> Result<(), AppError>;
}
Migrations
Use o SQLx CLI para gerenciar migrations:
# instalar
cargo install sqlx-cli --no-default-features --features postgres
# criar nova migration
sqlx migrate add <nome_descritivo>
# aplicar
sqlx migrate run
# reverter
sqlx migrate revert
Arquivos ficam em backend/migrations/. Nomeie com timestamp e descrição clara.
Variáveis de Ambiente
Copie .env.example para .env antes de rodar. Veja o arquivo .env.example para a lista completa.
Como Adicionar um Novo Endpoint (Passo a Passo)
- Domain — adicione ou modifique a entidade em
src/domain/. - Port — adicione o método necessário no trait em
src/ports/. - Outbound Adapter — implemente o método no repositório PostgreSQL em
src/adapters/outbound/. - Application Service — adicione o caso de uso em
src/application/, chamando o port. - Request/Response DTOs — defina structs com
serdeevalidatorno handler. - Inbound Handler — crie o handler em
src/adapters/inbound/, delegando para o service. - Router — registre a rota em
src/router.rs. - Migration — se necessário, crie um arquivo SQL em
migrations/.
Como Rodar Localmente
# na raiz do backend/
cp .env.example .env
# edite .env com suas credenciais locais
# subir PostgreSQL via Docker
docker run -d \
--name meowspool-db \
-e POSTGRES_USER=meowspool \
-e POSTGRES_PASSWORD=meowspool \
-e POSTGRES_DB=meowspool \
-p 5432:5432 \
postgres:16-alpine
# rodar migrations
sqlx migrate run
# rodar em modo watch (requer cargo-watch)
cargo watch -x run
Sincronização Offline (Estratégia)
O backend implementa last-write-wins com timestamp:
- Toda entidade possui
updated_at(timestamp UTC). - O mobile envia o
updated_atlocal no body do PUT. - Se o
updated_atdo servidor for mais recente, retorna409 Conflictcom a versão do servidor. - Se o
updated_atdo cliente for mais recente (ou igual), o servidor aceita a atualização.
Geração de QR Code e Etiqueta SVG
QR Code (GET /filaments/:id/qrcode)
- Gera QR Code com deep link:
meowspool://filament/:id(singular) - Retorna PNG (
image/png) por padrão, ou SVG com?format=svg - Biblioteca: crate
qrcode
Etiqueta SVG (GET /filaments/:id/label.svg)
- Query params:
width_mm(padrão: 50),height_mm(padrão: 30) - Retorna SVG com: cor visual, modelo, material, marca, peso líquido e QR Code embutido
Content-Type: image/svg+xmlContent-Disposition: attachment; filename="meowspool-label-{id}.svg"
Segurança
- Senhas hasheadas com Argon2id (crate
argon2). - JWT assinado com HS256 — nunca exponha o
JWT_SECRET. - Toda rota autenticada passa pelo middleware
auth.rsque valida o token e populaExtension<CurrentUser>. - Queries usam bind parameters do SQLx — nunca interpolação de string em SQL.
user_idé sempre extraído do token JWT, nunca aceito como parâmetro de URL ou body.- Presets do sistema (
is_system = true) são protegidos no nível de serviço: edição ou deleção retorna403 Forbidden.
Mudanças Recentes (14/03/2026)
✅ Correção: PUT Spool Preset retornava FORBIDDEN
Local: src/adapters/outbound/postgres_spool_preset_repo.rs — método update()
Problema:
O SQL UPDATE não validava o user_id na cláusula WHERE. Qualquer usuário poderia tentar editar presets de outros usuários ou presets do sistema.
// ❌ ANTES (inseguro)
UPDATE spool_presets
SET name = $2, spool_weight_g = $3
WHERE id = $1 AND is_system = false
RETURNING ...
Solução:
Adicionado AND user_id = $4 para validar propriedade antes de atualizar:
// ✅ DEPOIS (seguro)
UPDATE spool_presets
SET name = $2, spool_weight_g = $3
WHERE id = $1 AND is_system = false AND user_id = $4
RETURNING ...
Agora o repositório valida que o preset pertence ao usuário autenticado (extraído do JWT). Tentativas de editar presets de outro usuário ou do sistema recebem 404 Not Found (sem vazar que o preset existe).