579 lines
17 KiB
Rust
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())
|
|
}
|