photoncloud-monorepo/creditservice/crates/creditservice-server/src/rest.rs
centra ac903f438c fix(rest): axum route syntax :param to {param}
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>
2025-12-19 04:13:16 +09:00

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(),
}),
)
}