photoncloud-monorepo/creditservice/crates/creditservice-server/src/rest.rs

579 lines
17 KiB
Rust

//! 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<CreditServiceImpl>,
pub auth_service: Arc<AuthService>,
}
/// 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<serde_json::Value>,
}
#[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<T> {
pub data: T,
pub meta: ResponseMeta,
}
impl<T> SuccessResponse<T> {
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<i64>,
}
/// Top up request
#[derive(Debug, Deserialize)]
pub struct TopUpRequestRest {
pub amount: i64,
pub description: Option<String>,
}
/// Reserve credits request
#[derive(Debug, Deserialize)]
pub struct ReserveCreditsRequestRest {
pub project_id: String,
pub amount: i64,
pub description: Option<String>,
pub resource_type: Option<String>,
pub ttl_seconds: Option<i32>,
}
/// Commit reservation request
#[derive(Debug, Deserialize)]
pub struct CommitReservationRequestRest {
pub actual_amount: Option<i64>,
pub resource_id: Option<String>,
}
/// Release reservation request
#[derive(Debug, Deserialize)]
pub struct ReleaseReservationRequestRest {
pub reason: Option<String>,
}
/// 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<ProtoWallet> 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<String>,
}
impl From<ProtoTransaction> 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<ProtoReservation> 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<TransactionResponse>,
pub next_page_token: Option<String>,
}
/// 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<SuccessResponse<serde_json::Value>>) {
(
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<RestApiState>,
Path(project_id): Path<String>,
headers: HeaderMap,
) -> Result<Json<SuccessResponse<WalletResponse>>, (StatusCode, Json<ErrorResponse>)> {
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<RestApiState>,
headers: HeaderMap,
Json(req): Json<CreateWalletRequestRest>,
) -> Result<(StatusCode, Json<SuccessResponse<WalletResponse>>), (StatusCode, Json<ErrorResponse>)>
{
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<RestApiState>,
Path(project_id): Path<String>,
headers: HeaderMap,
Json(req): Json<TopUpRequestRest>,
) -> Result<Json<SuccessResponse<WalletResponse>>, (StatusCode, Json<ErrorResponse>)> {
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<RestApiState>,
Path(project_id): Path<String>,
headers: HeaderMap,
) -> Result<Json<SuccessResponse<TransactionsResponse>>, (StatusCode, Json<ErrorResponse>)> {
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<TransactionResponse> = 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<RestApiState>,
headers: HeaderMap,
Json(req): Json<ReserveCreditsRequestRest>,
) -> Result<
(StatusCode, Json<SuccessResponse<ReservationResponse>>),
(StatusCode, Json<ErrorResponse>),
> {
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<RestApiState>,
Path(reservation_id): Path<String>,
headers: HeaderMap,
Json(req): Json<CommitReservationRequestRest>,
) -> Result<Json<SuccessResponse<WalletResponse>>, (StatusCode, Json<ErrorResponse>)> {
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<RestApiState>,
Path(reservation_id): Path<String>,
headers: HeaderMap,
Json(req): Json<ReleaseReservationRequestRest>,
) -> Result<Json<SuccessResponse<serde_json::Value>>, (StatusCode, Json<ErrorResponse>)> {
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<ErrorResponse>) {
(
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<TenantContext, (StatusCode, Json<ErrorResponse>)> {
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<ErrorResponse>) {
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())
}