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:
158
crates/api/src/main.rs
Normal file
158
crates/api/src/main.rs
Normal 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(())
|
||||
}
|
||||
Reference in New Issue
Block a user