From 1ae4ba3a58c1b9a07bacbda52421b209a72f0abf Mon Sep 17 00:00:00 2001 From: Antoine Pelletier Date: Sun, 23 Aug 2026 22:20:40 +0200 Subject: [PATCH] feat: add cargobike structure --- src/api/bikes.rs | 49 ++++++++++++++++++--- src/api/docs.rs | 4 ++ src/api/mod.rs | 2 + src/api/reservations.rs | 94 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 144 insertions(+), 5 deletions(-) create mode 100644 src/api/reservations.rs diff --git a/src/api/bikes.rs b/src/api/bikes.rs index f64da36..fcc71a3 100644 --- a/src/api/bikes.rs +++ b/src/api/bikes.rs @@ -2,18 +2,57 @@ //! cargo bikes can be picked. use aide::{ - axum::{ApiRouter, routing::get_with}, + axum::{ + ApiRouter, + routing::{get_with, put_with}, + }, transform::TransformOperation, }; -use axum::{Json, http::StatusCode}; +use axum::{Json, extract::Path, http::StatusCode}; +use schemars::JsonSchema; +use serde::Deserialize; use crate::{ - api::helpers::unexpected_error, - core::{controller::AnonAppController, models::bike::Bike}, + api::helpers::{admin, admin_desc, unexpected_error}, + core::{ + controller::{AnonAppController, AppController}, + models::bike::{Bike, BikeId, BikeStatus}, + }, }; pub fn routes() -> ApiRouter { - ApiRouter::new().api_route("/", get_with(get_bikes, get_bikes_docs)) + ApiRouter::new() + .api_route("/", get_with(get_bikes, get_bikes_docs)) + .api_route("/{id}/status", put_with(set_status, set_status_docs)) +} + +#[derive(Debug, Deserialize, JsonSchema)] +struct SetStatusForm { + status: BikeStatus, +} + +/// Takes a bike in or out of the fleet. Deleting is not offered: a bike that +/// has been booked must stay, for the history. +#[axum::debug_handler] +async fn set_status( + ac: AppController, + Path(id): Path, + Json(SetStatusForm { status }): Json, +) -> Result<(), (StatusCode, String)> { + match admin(ac)?.set_bike_status(id, status).await { + Ok(()) => Ok(()), + Err(err) if err.is_not_found() => { + Err((StatusCode::NOT_FOUND, "No such bike".to_owned())) + } + Err(err) => unexpected_error("set_bike_status", err), + } +} + +fn set_status_docs(op: TransformOperation) -> TransformOperation { + op.tag("Bikes") + .summary("Put a bike in or out of service") + .response_with::<403, (), _>(admin_desc) + .response::<404, ()>() } #[axum::debug_handler] diff --git a/src/api/docs.rs b/src/api/docs.rs index e85a317..f0b7c1b 100644 --- a/src/api/docs.rs +++ b/src/api/docs.rs @@ -23,6 +23,10 @@ pub fn api_docs_metadata(api: TransformOpenApi) -> TransformOpenApi { name: "Bikes".to_owned(), ..Default::default() }) + .tag(Tag { + name: "Reservations".to_owned(), + ..Default::default() + }) .tag(Tag { name: "misc".to_owned(), ..Default::default() diff --git a/src/api/mod.rs b/src/api/mod.rs index 2170003..3db8526 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -35,6 +35,7 @@ mod auth; mod bikes; mod docs; mod helpers; +mod reservations; pub fn get_router(aac: AnonAppController) -> Router { aide::generate::on_error(|err| error!("aide generated error: {err}")); @@ -66,6 +67,7 @@ pub fn get_router(aac: AnonAppController) -> Router { // `auth` carries its own `/api/...` paths, so it is merged, not nested .merge(auth::routes()) .nest_api_service("/api/bikes", bikes::routes()) + .nest_api_service("/api/reservations", reservations::routes()) .nest_api_service("/api/docs", docs::routes()) .finish_api_with(&mut api, docs::api_docs_metadata) .layer(Extension(aac)) diff --git a/src/api/reservations.rs b/src/api/reservations.rs new file mode 100644 index 0000000..977f2dd --- /dev/null +++ b/src/api/reservations.rs @@ -0,0 +1,94 @@ +//! Reservations, from the administration side. +//! +//! Reading the whole list is an admin action for now; changing a status is +//! reserved to the unit the reservation belongs to (an admin manages every +//! unit), which is why the handler resolves the unit before narrowing the +//! controller down. + +use aide::{ + axum::{ApiRouter, routing::get_with}, + transform::TransformOperation, +}; +use axum::{ + Json, + extract::Path, + http::StatusCode, +}; +use schemars::JsonSchema; +use serde::Deserialize; + +use crate::{ + api::helpers::{admin, admin_desc, manager, manager_desc, unexpected_error}, + core::{ + controller::{AppController, ControllerError, reservations::ReservationsControllerError}, + models::reservation::{Reservation, ReservationId, ReservationStatus}, + }, +}; + +pub fn routes() -> ApiRouter { + ApiRouter::new() + .api_route("/", get_with(get_reservations, get_reservations_docs)) + .api_route( + "/{id}/status", + aide::axum::routing::put_with(set_status, set_status_docs), + ) +} + +#[axum::debug_handler] +async fn get_reservations( + ac: AppController, +) -> Result>, (StatusCode, String)> { + match admin(ac)?.get_reservations().await { + Ok(reservations) => Ok(Json(reservations)), + Err(err) => unexpected_error("get_reservations", err), + } +} + +fn get_reservations_docs(op: TransformOperation) -> TransformOperation { + op.tag("Reservations") + .summary("Get every reservation") + .response_with::<403, (), _>(admin_desc) +} + +#[derive(Debug, Deserialize, JsonSchema)] +struct SetStatusForm { + status: ReservationStatus, +} + +#[axum::debug_handler] +async fn set_status( + ac: AppController, + Path(id): Path, + Json(SetStatusForm { status }): Json, +) -> Result<(), (StatusCode, String)> { + // The unit is not in the body: it is the reservation's own + let reservation = match ac.get_reservation(id).await { + Ok(reservation) => reservation, + Err(err) if err.is_not_found() => { + return Err((StatusCode::NOT_FOUND, "No such reservation".to_owned())); + } + Err(err) => return unexpected_error("set_status", err), + }; + + match manager(ac, reservation.unit.id)? + .set_reservation_status(id, status) + .await + { + Ok(()) => Ok(()), + Err(ControllerError::Reservation(err @ ReservationsControllerError::InvalidTransition(..))) => { + Err((StatusCode::CONFLICT, err.to_string())) + } + Err(err) => unexpected_error("set_status", err), + } +} + +fn set_status_docs(op: TransformOperation) -> TransformOperation { + op.tag("Reservations") + .summary("Move a reservation through its state machine") + .description( + "Refuses a transition the state machine does not allow, with a 409.", + ) + .response_with::<403, (), _>(manager_desc) + .response::<404, ()>() + .response::<409, ()>() +}