//! REST HTTP API handlers for CreditService //! //! Implements REST endpoints as specified in T050.S7: //! - GET /api/v1/wallets/{project_id} - Get wallet balance //! - POST /api/v1/wallets - Create wallet //! - POST /api/v1/wallets/{project_id}/topup - Top up credits //! - GET /api/v1/wallets/{project_id}/transactions - Get transactions //! - POST /api/v1/reservations - Reserve credits //! - POST /api/v1/reservations/{id}/commit - Commit reservation //! - POST /api/v1/reservations/{id}/release - Release reservation //! - GET /health - Health check use axum::{ extract::{Path, State}, http::{HeaderMap, StatusCode}, routing::{get, post}, Json, Router, }; use creditservice_api::CreditServiceImpl; use creditservice_proto::{ credit_service_server::CreditService, CommitReservationRequest, CreateWalletRequest, GetTransactionsRequest, GetWalletRequest, ReleaseReservationRequest, Reservation as ProtoReservation, ReserveCreditsRequest, TopUpRequest, Transaction as ProtoTransaction, Wallet as ProtoWallet, }; use photon_auth_client::{resolve_tenant_ids_from_context, AuthService, TenantContext}; use serde::{Deserialize, Serialize}; use std::sync::Arc; use tonic::{Code, Request}; /// REST API state #[derive(Clone)] pub struct RestApiState { pub credit_service: Arc, pub auth_service: Arc, } /// Standard REST error response #[derive(Debug, Serialize)] pub struct ErrorResponse { pub error: ErrorDetail, pub meta: ResponseMeta, } #[derive(Debug, Serialize)] pub struct ErrorDetail { pub code: String, pub message: String, #[serde(skip_serializing_if = "Option::is_none")] pub details: Option, } #[derive(Debug, Serialize)] pub struct ResponseMeta { pub request_id: String, pub timestamp: String, } impl ResponseMeta { fn new() -> Self { Self { request_id: uuid::Uuid::new_v4().to_string(), timestamp: chrono::Utc::now().to_rfc3339(), } } } /// Standard REST success response #[derive(Debug, Serialize)] pub struct SuccessResponse { pub data: T, pub meta: ResponseMeta, } impl SuccessResponse { fn new(data: T) -> Self { Self { data, meta: ResponseMeta::new(), } } } /// Create wallet request #[derive(Debug, Deserialize)] pub struct CreateWalletRequestRest { pub project_id: String, pub org_id: String, pub initial_balance: Option, } /// Top up request #[derive(Debug, Deserialize)] pub struct TopUpRequestRest { pub amount: i64, pub description: Option, } /// Reserve credits request #[derive(Debug, Deserialize)] pub struct ReserveCreditsRequestRest { pub project_id: String, pub amount: i64, pub description: Option, pub resource_type: Option, pub ttl_seconds: Option, } /// Commit reservation request #[derive(Debug, Deserialize)] pub struct CommitReservationRequestRest { pub actual_amount: Option, pub resource_id: Option, } /// Release reservation request #[derive(Debug, Deserialize)] pub struct ReleaseReservationRequestRest { pub reason: Option, } /// Wallet response #[derive(Debug, Serialize)] pub struct WalletResponse { pub project_id: String, pub org_id: String, pub balance: i64, pub reserved: i64, pub available: i64, pub total_deposited: i64, pub total_consumed: i64, pub status: String, } impl From for WalletResponse { fn from(w: ProtoWallet) -> Self { let status = match w.status { 1 => "active", 2 => "suspended", 3 => "closed", _ => "unknown", }; Self { project_id: w.project_id, org_id: w.org_id, balance: w.balance, reserved: w.reserved, available: w.balance - w.reserved, total_deposited: w.total_deposited, total_consumed: w.total_consumed, status: status.to_string(), } } } /// Transaction response #[derive(Debug, Serialize)] pub struct TransactionResponse { pub id: String, pub project_id: String, pub transaction_type: String, pub amount: i64, pub balance_after: i64, pub description: String, pub resource_id: Option, } impl From for TransactionResponse { fn from(t: ProtoTransaction) -> Self { let tx_type = match t.r#type { 1 => "top_up", 2 => "reservation", 3 => "charge", 4 => "release", 5 => "refund", 6 => "billing_charge", _ => "unknown", }; Self { id: t.id, project_id: t.project_id, transaction_type: tx_type.to_string(), amount: t.amount, balance_after: t.balance_after, description: t.description, resource_id: if t.resource_id.is_empty() { None } else { Some(t.resource_id) }, } } } /// Reservation response #[derive(Debug, Serialize)] pub struct ReservationResponse { pub id: String, pub project_id: String, pub amount: i64, pub status: String, pub description: String, } impl From for ReservationResponse { fn from(r: ProtoReservation) -> Self { let status = match r.status { 1 => "pending", 2 => "committed", 3 => "released", 4 => "expired", _ => "unknown", }; Self { id: r.id, project_id: r.project_id, amount: r.amount, status: status.to_string(), description: r.description, } } } /// Transactions list response #[derive(Debug, Serialize)] pub struct TransactionsResponse { pub transactions: Vec, pub next_page_token: Option, } /// Build the REST API router pub fn build_router(state: RestApiState) -> Router { Router::new() .route("/api/v1/wallets", post(create_wallet)) .route("/api/v1/wallets/{project_id}", get(get_wallet)) .route("/api/v1/wallets/{project_id}/topup", post(topup)) .route( "/api/v1/wallets/{project_id}/transactions", get(get_transactions), ) .route("/api/v1/reservations", post(reserve_credits)) .route("/api/v1/reservations/{id}/commit", post(commit_reservation)) .route( "/api/v1/reservations/{id}/release", post(release_reservation), ) .route("/health", get(health_check)) .with_state(state) } /// Health check endpoint async fn health_check() -> (StatusCode, Json>) { ( StatusCode::OK, Json(SuccessResponse::new( serde_json::json!({ "status": "healthy" }), )), ) } /// GET /api/v1/wallets/{project_id} - Get wallet balance async fn get_wallet( State(state): State, Path(project_id): Path, headers: HeaderMap, ) -> Result>, (StatusCode, Json)> { let tenant = resolve_rest_tenant(&state, &headers, Some(&project_id)).await?; let mut req = Request::new(GetWalletRequest { project_id }); req.extensions_mut().insert(tenant); let response = state.credit_service.get_wallet(req).await.map_err(|e| { if e.code() == tonic::Code::NotFound { error_response(StatusCode::NOT_FOUND, "NOT_FOUND", "Wallet not found") } else { error_response( StatusCode::INTERNAL_SERVER_ERROR, "GET_FAILED", &e.message(), ) } })?; let wallet = response .into_inner() .wallet .ok_or_else(|| error_response(StatusCode::NOT_FOUND, "NOT_FOUND", "Wallet not found"))?; Ok(Json(SuccessResponse::new(WalletResponse::from(wallet)))) } /// POST /api/v1/wallets - Create wallet async fn create_wallet( State(state): State, headers: HeaderMap, Json(req): Json, ) -> Result<(StatusCode, Json>), (StatusCode, Json)> { let tenant = resolve_rest_tenant(&state, &headers, Some(&req.project_id)).await?; let mut grpc_req = Request::new(CreateWalletRequest { project_id: req.project_id, org_id: req.org_id, initial_balance: req.initial_balance.unwrap_or(0), }); grpc_req.extensions_mut().insert(tenant); let response = state .credit_service .create_wallet(grpc_req) .await .map_err(|e| { error_response( StatusCode::INTERNAL_SERVER_ERROR, "CREATE_FAILED", &e.message(), ) })?; let wallet = response.into_inner().wallet.ok_or_else(|| { error_response( StatusCode::INTERNAL_SERVER_ERROR, "CREATE_FAILED", "No wallet returned", ) })?; Ok(( StatusCode::CREATED, Json(SuccessResponse::new(WalletResponse::from(wallet))), )) } /// POST /api/v1/wallets/{project_id}/topup - Top up credits async fn topup( State(state): State, Path(project_id): Path, headers: HeaderMap, Json(req): Json, ) -> Result>, (StatusCode, Json)> { let tenant = resolve_rest_tenant(&state, &headers, Some(&project_id)).await?; let mut grpc_req = Request::new(TopUpRequest { project_id, amount: req.amount, description: req.description.unwrap_or_default(), }); grpc_req.extensions_mut().insert(tenant); let response = state.credit_service.top_up(grpc_req).await.map_err(|e| { error_response( StatusCode::INTERNAL_SERVER_ERROR, "TOPUP_FAILED", &e.message(), ) })?; let wallet = response.into_inner().wallet.ok_or_else(|| { error_response( StatusCode::INTERNAL_SERVER_ERROR, "TOPUP_FAILED", "No wallet returned", ) })?; Ok(Json(SuccessResponse::new(WalletResponse::from(wallet)))) } /// GET /api/v1/wallets/{project_id}/transactions - Get transactions async fn get_transactions( State(state): State, Path(project_id): Path, headers: HeaderMap, ) -> Result>, (StatusCode, Json)> { let tenant = resolve_rest_tenant(&state, &headers, Some(&project_id)).await?; let mut req = Request::new(GetTransactionsRequest { project_id, page_size: 100, page_token: String::new(), type_filter: 0, start_time: None, end_time: None, }); req.extensions_mut().insert(tenant); let response = state .credit_service .get_transactions(req) .await .map_err(|e| { error_response( StatusCode::INTERNAL_SERVER_ERROR, "LIST_FAILED", &e.message(), ) })?; let inner = response.into_inner(); let transactions: Vec = inner .transactions .into_iter() .map(TransactionResponse::from) .collect(); let next_page_token = if inner.next_page_token.is_empty() { None } else { Some(inner.next_page_token) }; Ok(Json(SuccessResponse::new(TransactionsResponse { transactions, next_page_token, }))) } /// POST /api/v1/reservations - Reserve credits async fn reserve_credits( State(state): State, headers: HeaderMap, Json(req): Json, ) -> Result< (StatusCode, Json>), (StatusCode, Json), > { let tenant = resolve_rest_tenant(&state, &headers, Some(&req.project_id)).await?; let mut grpc_req = Request::new(ReserveCreditsRequest { project_id: req.project_id, amount: req.amount, description: req.description.unwrap_or_default(), resource_type: req.resource_type.unwrap_or_default(), ttl_seconds: req.ttl_seconds.unwrap_or(300), }); grpc_req.extensions_mut().insert(tenant); let response = state .credit_service .reserve_credits(grpc_req) .await .map_err(|e| { error_response( StatusCode::INTERNAL_SERVER_ERROR, "RESERVE_FAILED", &e.message(), ) })?; let reservation = response.into_inner().reservation.ok_or_else(|| { error_response( StatusCode::INTERNAL_SERVER_ERROR, "RESERVE_FAILED", "No reservation returned", ) })?; Ok(( StatusCode::CREATED, Json(SuccessResponse::new(ReservationResponse::from(reservation))), )) } /// POST /api/v1/reservations/{id}/commit - Commit reservation async fn commit_reservation( State(state): State, Path(reservation_id): Path, headers: HeaderMap, Json(req): Json, ) -> Result>, (StatusCode, Json)> { let tenant = resolve_rest_tenant(&state, &headers, None).await?; let mut grpc_req = Request::new(CommitReservationRequest { reservation_id, actual_amount: req.actual_amount.unwrap_or(0), resource_id: req.resource_id.unwrap_or_default(), }); grpc_req.extensions_mut().insert(tenant); let response = state .credit_service .commit_reservation(grpc_req) .await .map_err(|e| { error_response( StatusCode::INTERNAL_SERVER_ERROR, "COMMIT_FAILED", &e.message(), ) })?; let wallet = response.into_inner().wallet.ok_or_else(|| { error_response( StatusCode::INTERNAL_SERVER_ERROR, "COMMIT_FAILED", "No wallet returned", ) })?; Ok(Json(SuccessResponse::new(WalletResponse::from(wallet)))) } /// POST /api/v1/reservations/{id}/release - Release reservation async fn release_reservation( State(state): State, Path(reservation_id): Path, headers: HeaderMap, Json(req): Json, ) -> Result>, (StatusCode, Json)> { let tenant = resolve_rest_tenant(&state, &headers, None).await?; let mut grpc_req = Request::new(ReleaseReservationRequest { reservation_id: reservation_id.clone(), reason: req.reason.unwrap_or_default(), }); grpc_req.extensions_mut().insert(tenant); let response = state .credit_service .release_reservation(grpc_req) .await .map_err(|e| { error_response( StatusCode::INTERNAL_SERVER_ERROR, "RELEASE_FAILED", &e.message(), ) })?; Ok(Json(SuccessResponse::new(serde_json::json!({ "reservation_id": reservation_id, "released": response.into_inner().success })))) } /// Helper to create error response fn error_response( status: StatusCode, code: &str, message: &str, ) -> (StatusCode, Json) { ( status, Json(ErrorResponse { error: ErrorDetail { code: code.to_string(), message: message.to_string(), details: None, }, meta: ResponseMeta::new(), }), ) } async fn resolve_rest_tenant( state: &RestApiState, headers: &HeaderMap, req_project_id: Option<&str>, ) -> Result)> { let tenant = state .auth_service .authenticate_headers(headers) .await .map_err(map_auth_status)?; resolve_tenant_ids_from_context(&tenant, "", req_project_id.unwrap_or("")) .map_err(map_auth_status)?; Ok(tenant) } fn map_auth_status(status: tonic::Status) -> (StatusCode, Json) { let status_code = match status.code() { Code::Unauthenticated => StatusCode::UNAUTHORIZED, Code::PermissionDenied => StatusCode::FORBIDDEN, Code::InvalidArgument => StatusCode::BAD_REQUEST, Code::NotFound => StatusCode::NOT_FOUND, _ => StatusCode::INTERNAL_SERVER_ERROR, }; let code = match status.code() { Code::Unauthenticated => "UNAUTHENTICATED", Code::PermissionDenied => "FORBIDDEN", Code::InvalidArgument => "INVALID_ARGUMENT", Code::NotFound => "NOT_FOUND", _ => "INTERNAL", }; error_response(status_code, code, status.message()) }