Initial: Rust-Backend mit Clean Architecture (domain/application/infrastructure/api)

Vier-Crate-Workspace mit:
- Domain: Account, Car, Tour, Delivery, DeliveryItem, DeliveryNote, Customer,
  Article, Warehouse, ScanState, AuditAction — alle mit serde + feature-gated
  utoipa::ToSchema.
- Application: Ports (TourRepository, DeliveryRepository, ScanRepository,
  DeliveryNoteRepository, CarRepository, AuthService) und Use Cases.
- Infrastructure: Postgres-Adapter via sqlx (PgTourRepository etc.) +
  Keycloak-AuthService mit JWKS-Cache + OIDC-Discovery.
- API: Axum 0.8, utoipa-OpenAPI + Swagger-UI, JWT-Bearer-Middleware,
  AuthenticatedUser-Extractor.

Endpoints:
- GET /me/tours/today, /tours/{id}, /accounts/{pn}, /me/cars, /health
- POST /sync/tour, /scans (bulk + idempotent via clientScanId),
  /deliveries/{id}/{hold,resume,cancel,complete,notes}, /me/cars
- PUT /tours/{id}/delivery-order, /deliveries/{id}/assigned-car, /me/cars/{id}
- PATCH /me/cars/{id}

Datenmodell:
- 6 Migrationen (accounts, tours/deliveries/items + Stammdaten,
  scan_audit mit clientScanId-UNIQUE, state_reason refactor,
  delivery_notes, cars + FKs nachziehen).
- Business-stabile Beleg-Keys (belegart_id, belegnummer) für ERP-Sync.
- Append-only scan_audit + embedded scan_state als doppelte Wahrheit.

Dev-Setup:
- docker-compose mit Postgres 17 + Keycloak 26
- Keycloak-Realm 'holzleitner' mit Public-Client (PKCE), Testfahrer
  (PN 1001) + Audience-/Personalnummer-Mapper
This commit is contained in:
Dennis Nemec
2026-05-14 22:28:31 +02:00
commit 438040acce
83 changed files with 8922 additions and 0 deletions

158
crates/api/src/main.rs Normal file
View File

@ -0,0 +1,158 @@
//! Holzleitner-API — HTTP-Layer und Composition Root.
//!
//! Bootstrap-Reihenfolge:
//! 1. Tracing/Logging initialisieren
//! 2. Konfiguration aus Env-Variablen / `.env` laden
//! 3. Postgres-Pool aufbauen und Migrations ausführen
//! 4. Keycloak-AuthService instanziieren
//! 5. Use Cases zusammenstellen und in `AppState` packen
//! 6. Public + Protected Router komponieren und Server starten
mod config;
mod error;
mod extractors;
mod middleware;
mod openapi;
mod routes;
mod state;
use std::net::SocketAddr;
use std::sync::Arc;
use std::time::Duration;
use anyhow::Context;
use axum::Router;
use axum::middleware::from_fn_with_state;
use holzleitner_application::usecases::{
ApplyDeliveryActionUseCase, ApplyScansUseCase, AssignCarToDeliveryUseCase,
CreateDeliveryNoteUseCase, CreateMyCarUseCase, GetAccountUseCase, GetTourUseCase,
ListMyCarsUseCase, ListMyToursTodayUseCase, SetDeliveryOrderUseCase, SyncTourUseCase,
UpdateMyCarUseCase,
};
use holzleitner_infrastructure::auth::{KeycloakAdapterConfig, KeycloakAuthService};
use holzleitner_infrastructure::persistence::{
PgAccountRepository, PgCarRepository, PgDeliveryNoteRepository, PgDeliveryRepository,
PgScanRepository, PgTourRepository, PoolConfig, connect_and_migrate,
};
use tower_http::trace::TraceLayer;
use utoipa::OpenApi;
use utoipa_swagger_ui::SwaggerUi;
use crate::middleware::jwt_middleware;
use crate::openapi::ApiDoc;
use crate::state::AppState;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| {
"holzleitner_api=info,holzleitner_infrastructure=info,tower_http=info".into()
}),
)
.init();
let cfg = config::load().context("config laden fehlgeschlagen")?;
tracing::info!(host = %cfg.server.host, port = cfg.server.port, "starting up");
// --- Persistence ---------------------------------------------------
let pool = connect_and_migrate(&PoolConfig {
url: cfg.database.url.clone(),
max_connections: cfg.database.max_connections,
})
.await
.context("Postgres-Verbindung / Migrations fehlgeschlagen")?;
tracing::info!("postgres connected, migrations applied");
let account_repository = Arc::new(PgAccountRepository::new(pool.clone()));
let tour_repository = Arc::new(PgTourRepository::new(pool.clone()));
let scan_repository = Arc::new(PgScanRepository::new(pool.clone()));
let delivery_repository = Arc::new(PgDeliveryRepository::new(pool.clone()));
let delivery_note_repository = Arc::new(PgDeliveryNoteRepository::new(pool.clone()));
let car_repository = Arc::new(PgCarRepository::new(pool));
// --- Auth ----------------------------------------------------------
let auth_service = Arc::new(KeycloakAuthService::new(KeycloakAdapterConfig {
issuer_url: cfg.keycloak.issuer_url.clone(),
audience: cfg.keycloak.audience.clone(),
jwks_cache_ttl: Duration::from_secs(cfg.keycloak.jwks_cache_ttl_seconds),
}));
tracing::info!(
issuer = %cfg.keycloak.issuer_url,
audience = %cfg.keycloak.audience,
"keycloak adapter ready"
);
// --- Use Cases -----------------------------------------------------
let get_account = Arc::new(GetAccountUseCase::new(account_repository));
let get_tour = Arc::new(GetTourUseCase::new(tour_repository.clone()));
let list_my_tours_today = Arc::new(ListMyToursTodayUseCase::new(tour_repository.clone()));
let sync_tour = Arc::new(SyncTourUseCase::new(tour_repository.clone()));
let set_delivery_order = Arc::new(SetDeliveryOrderUseCase::new(tour_repository));
let apply_scans = Arc::new(ApplyScansUseCase::new(
scan_repository,
car_repository.clone(),
));
let apply_delivery_action =
Arc::new(ApplyDeliveryActionUseCase::new(delivery_repository.clone()));
let create_delivery_note = Arc::new(CreateDeliveryNoteUseCase::new(
delivery_note_repository,
car_repository.clone(),
));
let list_my_cars = Arc::new(ListMyCarsUseCase::new(car_repository.clone()));
let create_my_car = Arc::new(CreateMyCarUseCase::new(car_repository.clone()));
let update_my_car = Arc::new(UpdateMyCarUseCase::new(car_repository.clone()));
let assign_car_to_delivery = Arc::new(AssignCarToDeliveryUseCase::new(
car_repository,
delivery_repository,
));
let state = AppState {
get_account,
get_tour,
list_my_tours_today,
sync_tour,
set_delivery_order,
apply_scans,
apply_delivery_action,
create_delivery_note,
list_my_cars,
create_my_car,
update_my_car,
assign_car_to_delivery,
auth_service,
};
// --- Router --------------------------------------------------------
//
// Public-Routen (Health, OpenAPI-Spec, Swagger-UI) und Protected-
// Routen werden getrennt gebaut; die JWT-Middleware liegt **nur**
// auf dem protected-Subtree. `route_layer` greift nur für die jetzt
// definierten Routen — neue Routen darunter müssten explizit
// nochmal angehängt werden.
let public = Router::new()
.merge(routes::health::router())
.merge(SwaggerUi::new("/swagger-ui").url("/openapi.json", ApiDoc::openapi()));
let protected = Router::new()
.merge(routes::accounts::router())
.merge(routes::tours::router())
.merge(routes::scans::router())
.merge(routes::deliveries::router())
.merge(routes::cars::router())
.route_layer(from_fn_with_state(state.clone(), jwt_middleware));
let app = Router::new()
.merge(public)
.merge(protected)
.layer(TraceLayer::new_for_http())
.with_state(state);
let addr: SocketAddr = format!("{}:{}", cfg.server.host, cfg.server.port)
.parse()
.with_context(|| format!("ungültige Adresse {}:{}", cfg.server.host, cfg.server.port))?;
let listener = tokio::net::TcpListener::bind(addr).await?;
tracing::info!("server läuft auf http://{}", addr);
axum::serve(listener, app).await?;
Ok(())
}