Update 5 REST API files to use axum 0.8 path parameter syntax. - creditservice-server - flaredb-server - k8shost-server - plasmavmc-server - prismnet-server 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
429 lines
13 KiB
Rust
429 lines
13 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::StatusCode,
|
|
routing::{get, post},
|
|
Json, Router,
|
|
};
|
|
use creditservice_api::CreditServiceImpl;
|
|
use creditservice_proto::{
|
|
credit_service_server::CreditService,
|
|
GetWalletRequest, CreateWalletRequest, TopUpRequest, GetTransactionsRequest,
|
|
ReserveCreditsRequest, CommitReservationRequest, ReleaseReservationRequest,
|
|
Wallet as ProtoWallet, Transaction as ProtoTransaction, Reservation as ProtoReservation,
|
|
};
|
|
use serde::{Deserialize, Serialize};
|
|
use std::sync::Arc;
|
|
use tonic::Request;
|
|
|
|
/// REST API state
|
|
#[derive(Clone)]
|
|
pub struct RestApiState {
|
|
pub credit_service: Arc<CreditServiceImpl>,
|
|
}
|
|
|
|
/// 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>,
|
|
) -> Result<Json<SuccessResponse<WalletResponse>>, (StatusCode, Json<ErrorResponse>)> {
|
|
let req = Request::new(GetWalletRequest { project_id });
|
|
|
|
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>,
|
|
Json(req): Json<CreateWalletRequestRest>,
|
|
) -> Result<(StatusCode, Json<SuccessResponse<WalletResponse>>), (StatusCode, Json<ErrorResponse>)> {
|
|
let grpc_req = Request::new(CreateWalletRequest {
|
|
project_id: req.project_id,
|
|
org_id: req.org_id,
|
|
initial_balance: req.initial_balance.unwrap_or(0),
|
|
});
|
|
|
|
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>,
|
|
Json(req): Json<TopUpRequestRest>,
|
|
) -> Result<Json<SuccessResponse<WalletResponse>>, (StatusCode, Json<ErrorResponse>)> {
|
|
let grpc_req = Request::new(TopUpRequest {
|
|
project_id,
|
|
amount: req.amount,
|
|
description: req.description.unwrap_or_default(),
|
|
});
|
|
|
|
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>,
|
|
) -> Result<Json<SuccessResponse<TransactionsResponse>>, (StatusCode, Json<ErrorResponse>)> {
|
|
let req = Request::new(GetTransactionsRequest {
|
|
project_id,
|
|
page_size: 100,
|
|
page_token: String::new(),
|
|
type_filter: 0,
|
|
start_time: None,
|
|
end_time: None,
|
|
});
|
|
|
|
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>,
|
|
Json(req): Json<ReserveCreditsRequestRest>,
|
|
) -> Result<(StatusCode, Json<SuccessResponse<ReservationResponse>>), (StatusCode, Json<ErrorResponse>)> {
|
|
let 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),
|
|
});
|
|
|
|
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>,
|
|
Json(req): Json<CommitReservationRequestRest>,
|
|
) -> Result<Json<SuccessResponse<WalletResponse>>, (StatusCode, Json<ErrorResponse>)> {
|
|
let grpc_req = Request::new(CommitReservationRequest {
|
|
reservation_id,
|
|
actual_amount: req.actual_amount.unwrap_or(0),
|
|
resource_id: req.resource_id.unwrap_or_default(),
|
|
});
|
|
|
|
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>,
|
|
Json(req): Json<ReleaseReservationRequestRest>,
|
|
) -> Result<Json<SuccessResponse<serde_json::Value>>, (StatusCode, Json<ErrorResponse>)> {
|
|
let grpc_req = Request::new(ReleaseReservationRequest {
|
|
reservation_id: reservation_id.clone(),
|
|
reason: req.reason.unwrap_or_default(),
|
|
});
|
|
|
|
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(),
|
|
}),
|
|
)
|
|
}
|