use axum::Json; use axum::extract::{Path, Query, State}; use axum::http::StatusCode; use uuid::Uuid; use validator::Validate; use crate::error::{ApiError, ApiResult}; use crate::models::item::{CreateItemRequest, Item, ItemPage, ItemResponse, UpdateItemRequest}; use crate::models::pagination::PaginationQuery; use crate::state::AppState; /// List items, newest first. #[utoipa::path( get, path = "/api/v1/items", tag = "items", params(PaginationQuery), responses( (status = 200, description = "Paged item list", body = ItemPage) ) )] pub async fn list_items( State(state): State, Query(query): Query, ) -> ApiResult> { let query = query.sanitize(); let total = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM items").fetch_one(state.db()).await?; let items = sqlx::query_as::<_, Item>( r#" SELECT id, name, description, created_at, updated_at FROM items ORDER BY created_at DESC LIMIT $1 OFFSET $2 "#, ) .bind(query.limit()) .bind(query.offset()) .fetch_all(state.db()) .await?; Ok(Json(ItemPage { items: items.into_iter().map(ItemResponse::from).collect(), page: query.page, per_page: query.per_page, total, })) } /// Fetch a single item. #[utoipa::path( get, path = "/api/v1/items/{id}", tag = "items", params(("id" = Uuid, Path, description = "Item id")), responses( (status = 200, description = "Item", body = ItemResponse), (status = 404, description = "Missing item", body = crate::error::ErrorBody) ) )] pub async fn get_item( State(state): State, Path(id): Path, ) -> ApiResult> { let item = sqlx::query_as::<_, Item>( r#" SELECT id, name, description, created_at, updated_at FROM items WHERE id = $1 "#, ) .bind(id) .fetch_optional(state.db()) .await? .ok_or_else(|| ApiError::NotFound(format!("item {id} not found")))?; Ok(Json(item.into())) } /// Create an item. #[utoipa::path( post, path = "/api/v1/items", tag = "items", request_body = CreateItemRequest, responses( (status = 201, description = "Created item", body = ItemResponse), (status = 400, description = "Validation error", body = crate::error::ErrorBody) ) )] pub async fn create_item( State(state): State, Json(body): Json, ) -> ApiResult<(StatusCode, Json)> { body.validate().map_err(|err| ApiError::Validation(err.to_string()))?; let item = sqlx::query_as::<_, Item>( r#" INSERT INTO items (name, description) VALUES ($1, $2) RETURNING id, name, description, created_at, updated_at "#, ) .bind(body.name.trim()) .bind(body.description.as_deref()) .fetch_one(state.db()) .await?; Ok((StatusCode::CREATED, Json(item.into()))) } /// Replace selected item fields. #[utoipa::path( patch, path = "/api/v1/items/{id}", tag = "items", params(("id" = Uuid, Path, description = "Item id")), request_body = UpdateItemRequest, responses( (status = 200, description = "Updated item", body = ItemResponse), (status = 404, description = "Missing item", body = crate::error::ErrorBody) ) )] pub async fn update_item( State(state): State, Path(id): Path, Json(body): Json, ) -> ApiResult> { body.validate().map_err(|err| ApiError::Validation(err.to_string()))?; let item = sqlx::query_as::<_, Item>( r#" UPDATE items SET name = COALESCE($2, name), description = COALESCE($3, description), updated_at = now() WHERE id = $1 RETURNING id, name, description, created_at, updated_at "#, ) .bind(id) .bind(body.name.as_deref().map(str::trim)) .bind(body.description.as_deref()) .fetch_optional(state.db()) .await? .ok_or_else(|| ApiError::NotFound(format!("item {id} not found")))?; Ok(Json(item.into())) } /// Delete an item. #[utoipa::path( delete, path = "/api/v1/items/{id}", tag = "items", params(("id" = Uuid, Path, description = "Item id")), responses( (status = 204, description = "Deleted"), (status = 404, description = "Missing item", body = crate::error::ErrorBody) ) )] pub async fn delete_item( State(state): State, Path(id): Path, ) -> ApiResult { let result = sqlx::query("DELETE FROM items WHERE id = $1").bind(id).execute(state.db()).await?; if result.rows_affected() == 0 { return Err(ApiError::NotFound(format!("item {id} not found"))); } Ok(StatusCode::NO_CONTENT) }