feat: implement filament and spool preset services with CRUD operations
- Add `FilamentService` for managing filament inventory, including creation, retrieval, updating, and deletion of filaments. - Introduce `SpoolPresetService` for handling spool presets, allowing users to create, update, and delete their custom presets. - Create domain models for `Filament` and `SpoolPreset` with necessary fields and methods. - Define repository interfaces for filament and spool preset persistence. - Implement application configuration management from environment variables. - Set up error handling with a centralized `AppError` type. - Build the Axum router with public and protected routes for user authentication and resource management.
This commit is contained in:
@@ -0,0 +1,383 @@
|
||||
# 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 marca
|
||||
- `search` — busca em marca, modelo e notas
|
||||
- `stock_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:**
|
||||
```json
|
||||
{
|
||||
"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`:
|
||||
|
||||
```rust
|
||||
#[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:
|
||||
|
||||
```json
|
||||
{ "error": "not found", "code": "NOT_FOUND" }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Convenções de Código
|
||||
|
||||
### Nomenclatura
|
||||
|
||||
- **Structs de domínio:** `PascalCase` — `User`, `Filament`, `SpoolPreset`
|
||||
- **Traits (ports):** `PascalCase` com sufixo `Repository` — `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`.
|
||||
|
||||
```rust
|
||||
// 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):
|
||||
|
||||
```rust
|
||||
// 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)
|
||||
|
||||
```rust
|
||||
// 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:
|
||||
|
||||
```bash
|
||||
# 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)
|
||||
|
||||
1. **Domain** — adicione ou modifique a entidade em `src/domain/`.
|
||||
2. **Port** — adicione o método necessário no trait em `src/ports/`.
|
||||
3. **Outbound Adapter** — implemente o método no repositório PostgreSQL em `src/adapters/outbound/`.
|
||||
4. **Application Service** — adicione o caso de uso em `src/application/`, chamando o port.
|
||||
5. **Request/Response DTOs** — defina structs com `serde` e `validator` no handler.
|
||||
6. **Inbound Handler** — crie o handler em `src/adapters/inbound/`, delegando para o service.
|
||||
7. **Router** — registre a rota em `src/router.rs`.
|
||||
8. **Migration** — se necessário, crie um arquivo SQL em `migrations/`.
|
||||
|
||||
---
|
||||
|
||||
## Como Rodar Localmente
|
||||
|
||||
```bash
|
||||
# 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_at` local no body do PUT.
|
||||
- Se o `updated_at` do servidor for mais recente, retorna `409 Conflict` com a versão do servidor.
|
||||
- Se o `updated_at` do 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://filaments/:id`
|
||||
- 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+xml`
|
||||
- `Content-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.rs` que valida o token e popula `Extension<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 retorna `403 Forbidden`.
|
||||
Reference in New Issue
Block a user