feat: Implement standard API response types and health check endpoints
- Added `ApiResponse`, `ResponseMeta`, and related structures for standardized API responses. - Created health check routes including basic health, liveness, and readiness checks. - Introduced `AppState` for shared application state across routes. - Developed wallet API endpoints for wallet management, address operations, balance queries, and transaction signing. - Implemented RPC API endpoints for blockchain operations including block queries, transaction handling, and network information.
This commit is contained in:
parent
b9f1f013b3
commit
03c1664739
12 changed files with 3207 additions and 0 deletions
69
crates/synor-gateway/Cargo.toml
Normal file
69
crates/synor-gateway/Cargo.toml
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
[package]
|
||||||
|
name = "synor-gateway"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
description = "Unified REST API gateway for Synor blockchain services"
|
||||||
|
license = "MIT OR Apache-2.0"
|
||||||
|
repository = "https://github.com/synortech/synor"
|
||||||
|
keywords = ["blockchain", "api", "gateway", "rest"]
|
||||||
|
categories = ["web-programming", "api-bindings"]
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = ["openapi"]
|
||||||
|
openapi = ["dep:utoipa", "dep:utoipa-swagger-ui"]
|
||||||
|
full = ["openapi"]
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
# Web framework
|
||||||
|
axum = { version = "0.7", features = ["macros", "ws"] }
|
||||||
|
axum-extra = { version = "0.9", features = ["typed-header"] }
|
||||||
|
tower = { version = "0.4", features = ["full"] }
|
||||||
|
tower-http = { version = "0.5", features = ["cors", "trace", "compression-gzip", "limit", "request-id"] }
|
||||||
|
hyper = { version = "1.0", features = ["full"] }
|
||||||
|
hyper-util = { version = "0.1", features = ["full"] }
|
||||||
|
|
||||||
|
# Async runtime
|
||||||
|
tokio = { version = "1.35", features = ["full"] }
|
||||||
|
futures = "0.3"
|
||||||
|
|
||||||
|
# Serialization
|
||||||
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
|
serde_json = "1.0"
|
||||||
|
|
||||||
|
# OpenAPI documentation
|
||||||
|
utoipa = { version = "4.2", features = ["axum_extras"], optional = true }
|
||||||
|
utoipa-swagger-ui = { version = "6.0", features = ["axum"], optional = true }
|
||||||
|
|
||||||
|
# Authentication & Security
|
||||||
|
jsonwebtoken = "9.2"
|
||||||
|
blake3 = "1.5"
|
||||||
|
base64 = "0.21"
|
||||||
|
uuid = { version = "1.6", features = ["v4", "serde"] }
|
||||||
|
|
||||||
|
# Rate limiting
|
||||||
|
governor = "0.6"
|
||||||
|
|
||||||
|
# Tracing & Metrics
|
||||||
|
tracing = "0.1"
|
||||||
|
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
|
||||||
|
metrics = "0.22"
|
||||||
|
metrics-exporter-prometheus = "0.13"
|
||||||
|
|
||||||
|
# Error handling
|
||||||
|
thiserror = "1.0"
|
||||||
|
anyhow = "1.0"
|
||||||
|
|
||||||
|
# Time
|
||||||
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
config = "0.14"
|
||||||
|
|
||||||
|
# Internal dependencies
|
||||||
|
# synor-rpc = { path = "../synor-rpc" }
|
||||||
|
# synor-crypto = { path = "../synor-crypto" }
|
||||||
|
# synor-database = { path = "../synor-database" }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tokio-test = "0.4"
|
||||||
|
reqwest = { version = "0.11", features = ["json"] }
|
||||||
387
crates/synor-gateway/src/auth.rs
Normal file
387
crates/synor-gateway/src/auth.rs
Normal file
|
|
@ -0,0 +1,387 @@
|
||||||
|
//! Authentication and authorization.
|
||||||
|
|
||||||
|
use crate::error::ApiError;
|
||||||
|
use axum::{
|
||||||
|
extract::FromRequestParts,
|
||||||
|
http::{header::AUTHORIZATION, request::Parts, HeaderMap},
|
||||||
|
};
|
||||||
|
use base64::{engine::general_purpose::STANDARD as BASE64, Engine};
|
||||||
|
use chrono::{DateTime, Duration, Utc};
|
||||||
|
use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
/// API key tier for rate limiting.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum ApiKeyTier {
|
||||||
|
Free,
|
||||||
|
Developer,
|
||||||
|
Pro,
|
||||||
|
Enterprise,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ApiKeyTier {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::Free
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Permissions for API operations.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Permissions {
|
||||||
|
/// Read access
|
||||||
|
pub read: bool,
|
||||||
|
|
||||||
|
/// Write access
|
||||||
|
pub write: bool,
|
||||||
|
|
||||||
|
/// Delete access
|
||||||
|
pub delete: bool,
|
||||||
|
|
||||||
|
/// Admin access
|
||||||
|
pub admin: bool,
|
||||||
|
|
||||||
|
/// Specific service access (empty = all)
|
||||||
|
pub services: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Permissions {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
read: true,
|
||||||
|
write: false,
|
||||||
|
delete: false,
|
||||||
|
admin: false,
|
||||||
|
services: vec![],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Permissions {
|
||||||
|
/// Full permissions.
|
||||||
|
pub fn full() -> Self {
|
||||||
|
Self {
|
||||||
|
read: true,
|
||||||
|
write: true,
|
||||||
|
delete: true,
|
||||||
|
admin: true,
|
||||||
|
services: vec![],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read-only permissions.
|
||||||
|
pub fn read_only() -> Self {
|
||||||
|
Self {
|
||||||
|
read: true,
|
||||||
|
write: false,
|
||||||
|
delete: false,
|
||||||
|
admin: false,
|
||||||
|
services: vec![],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if the user has access to a specific service.
|
||||||
|
pub fn has_service_access(&self, service: &str) -> bool {
|
||||||
|
self.services.is_empty() || self.services.iter().any(|s| s == service)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Authenticated user context.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct AuthContext {
|
||||||
|
/// User/account ID
|
||||||
|
pub user_id: String,
|
||||||
|
|
||||||
|
/// API key ID (if authenticated via API key)
|
||||||
|
pub api_key_id: Option<String>,
|
||||||
|
|
||||||
|
/// User's tier
|
||||||
|
pub tier: ApiKeyTier,
|
||||||
|
|
||||||
|
/// User's permissions
|
||||||
|
pub permissions: Permissions,
|
||||||
|
|
||||||
|
/// Authentication timestamp
|
||||||
|
pub authenticated_at: DateTime<Utc>,
|
||||||
|
|
||||||
|
/// Token expiration (if JWT)
|
||||||
|
pub expires_at: Option<DateTime<Utc>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AuthContext {
|
||||||
|
/// Check if the user can read.
|
||||||
|
pub fn can_read(&self) -> bool {
|
||||||
|
self.permissions.read
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if the user can write.
|
||||||
|
pub fn can_write(&self) -> bool {
|
||||||
|
self.permissions.write
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if the user can delete.
|
||||||
|
pub fn can_delete(&self) -> bool {
|
||||||
|
self.permissions.delete
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if the user is admin.
|
||||||
|
pub fn is_admin(&self) -> bool {
|
||||||
|
self.permissions.admin
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if the user can access a service.
|
||||||
|
pub fn can_access_service(&self, service: &str) -> bool {
|
||||||
|
self.permissions.has_service_access(service)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// JWT claims structure.
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct JwtClaims {
|
||||||
|
/// Subject (user ID)
|
||||||
|
pub sub: String,
|
||||||
|
|
||||||
|
/// Expiration time
|
||||||
|
pub exp: i64,
|
||||||
|
|
||||||
|
/// Issued at
|
||||||
|
pub iat: i64,
|
||||||
|
|
||||||
|
/// API key ID (if applicable)
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub api_key_id: Option<String>,
|
||||||
|
|
||||||
|
/// User tier
|
||||||
|
pub tier: ApiKeyTier,
|
||||||
|
|
||||||
|
/// Permissions
|
||||||
|
pub permissions: Permissions,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Authentication service.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct AuthService {
|
||||||
|
jwt_secret: Arc<String>,
|
||||||
|
jwt_expiration: Duration,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AuthService {
|
||||||
|
/// Create a new authentication service.
|
||||||
|
pub fn new(jwt_secret: String, jwt_expiration_secs: i64) -> Self {
|
||||||
|
Self {
|
||||||
|
jwt_secret: Arc::new(jwt_secret),
|
||||||
|
jwt_expiration: Duration::seconds(jwt_expiration_secs),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generate a JWT token.
|
||||||
|
pub fn generate_token(&self, user_id: &str, tier: ApiKeyTier, permissions: Permissions) -> Result<String, ApiError> {
|
||||||
|
let now = Utc::now();
|
||||||
|
let exp = now + self.jwt_expiration;
|
||||||
|
|
||||||
|
let claims = JwtClaims {
|
||||||
|
sub: user_id.to_string(),
|
||||||
|
exp: exp.timestamp(),
|
||||||
|
iat: now.timestamp(),
|
||||||
|
api_key_id: None,
|
||||||
|
tier,
|
||||||
|
permissions,
|
||||||
|
};
|
||||||
|
|
||||||
|
encode(
|
||||||
|
&Header::default(),
|
||||||
|
&claims,
|
||||||
|
&EncodingKey::from_secret(self.jwt_secret.as_bytes()),
|
||||||
|
)
|
||||||
|
.map_err(|e| ApiError::InternalError)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate a JWT token.
|
||||||
|
pub fn validate_token(&self, token: &str) -> Result<AuthContext, ApiError> {
|
||||||
|
let token_data = decode::<JwtClaims>(
|
||||||
|
token,
|
||||||
|
&DecodingKey::from_secret(self.jwt_secret.as_bytes()),
|
||||||
|
&Validation::default(),
|
||||||
|
)
|
||||||
|
.map_err(|e| match e.kind() {
|
||||||
|
jsonwebtoken::errors::ErrorKind::ExpiredSignature => ApiError::ExpiredToken,
|
||||||
|
_ => ApiError::InvalidToken(e.to_string()),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let claims = token_data.claims;
|
||||||
|
let expires_at = DateTime::from_timestamp(claims.exp, 0)
|
||||||
|
.map(|dt| dt.with_timezone(&Utc));
|
||||||
|
|
||||||
|
Ok(AuthContext {
|
||||||
|
user_id: claims.sub,
|
||||||
|
api_key_id: claims.api_key_id,
|
||||||
|
tier: claims.tier,
|
||||||
|
permissions: claims.permissions,
|
||||||
|
authenticated_at: Utc::now(),
|
||||||
|
expires_at,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate an API key.
|
||||||
|
pub async fn validate_api_key(&self, api_key: &str) -> Result<AuthContext, ApiError> {
|
||||||
|
// Parse API key format: key_id:secret
|
||||||
|
let parts: Vec<&str> = api_key.split(':').collect();
|
||||||
|
if parts.len() != 2 {
|
||||||
|
return Err(ApiError::InvalidApiKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
let key_id = parts[0];
|
||||||
|
let secret = parts[1];
|
||||||
|
|
||||||
|
// Hash the secret using blake3
|
||||||
|
let secret_hash = blake3::hash(secret.as_bytes()).to_hex().to_string();
|
||||||
|
|
||||||
|
// In production, this would look up the key in a database
|
||||||
|
// For now, we'll accept any properly formatted key for development
|
||||||
|
// TODO: Implement actual key lookup
|
||||||
|
|
||||||
|
// Placeholder: accept keys starting with "sk_" for development
|
||||||
|
if !key_id.starts_with("sk_") {
|
||||||
|
return Err(ApiError::InvalidApiKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return a development context
|
||||||
|
Ok(AuthContext {
|
||||||
|
user_id: format!("user_{}", &key_id[3..]),
|
||||||
|
api_key_id: Some(key_id.to_string()),
|
||||||
|
tier: ApiKeyTier::Developer,
|
||||||
|
permissions: Permissions::full(),
|
||||||
|
authenticated_at: Utc::now(),
|
||||||
|
expires_at: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Authenticate from headers.
|
||||||
|
pub async fn authenticate(&self, headers: &HeaderMap) -> Result<AuthContext, ApiError> {
|
||||||
|
// Try Bearer token first
|
||||||
|
if let Some(auth_header) = headers.get(AUTHORIZATION) {
|
||||||
|
let auth_str = auth_header
|
||||||
|
.to_str()
|
||||||
|
.map_err(|_| ApiError::InvalidToken("Invalid header encoding".to_string()))?;
|
||||||
|
|
||||||
|
if auth_str.starts_with("Bearer ") {
|
||||||
|
let token = &auth_str[7..];
|
||||||
|
return self.validate_token(token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try API key header
|
||||||
|
if let Some(api_key) = headers.get("X-API-Key") {
|
||||||
|
let key = api_key
|
||||||
|
.to_str()
|
||||||
|
.map_err(|_| ApiError::InvalidApiKey)?;
|
||||||
|
return self.validate_api_key(key).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try Authorization: Basic (for API key as base64)
|
||||||
|
if let Some(auth_header) = headers.get(AUTHORIZATION) {
|
||||||
|
let auth_str = auth_header
|
||||||
|
.to_str()
|
||||||
|
.map_err(|_| ApiError::InvalidToken("Invalid header encoding".to_string()))?;
|
||||||
|
|
||||||
|
if auth_str.starts_with("Basic ") {
|
||||||
|
let encoded = &auth_str[6..];
|
||||||
|
let decoded = BASE64
|
||||||
|
.decode(encoded)
|
||||||
|
.map_err(|_| ApiError::InvalidApiKey)?;
|
||||||
|
let key = String::from_utf8(decoded)
|
||||||
|
.map_err(|_| ApiError::InvalidApiKey)?;
|
||||||
|
return self.validate_api_key(&key).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(ApiError::Unauthorized)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extractor for authenticated requests.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Authenticated(pub AuthContext);
|
||||||
|
|
||||||
|
impl<S> FromRequestParts<S> for Authenticated
|
||||||
|
where
|
||||||
|
S: Send + Sync,
|
||||||
|
{
|
||||||
|
type Rejection = ApiError;
|
||||||
|
|
||||||
|
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
|
||||||
|
// Get auth service from extensions
|
||||||
|
let auth_service = parts
|
||||||
|
.extensions
|
||||||
|
.get::<AuthService>()
|
||||||
|
.ok_or(ApiError::InternalError)?
|
||||||
|
.clone();
|
||||||
|
|
||||||
|
let context = auth_service.authenticate(&parts.headers).await?;
|
||||||
|
Ok(Authenticated(context))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Optional authentication extractor.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct OptionalAuth(pub Option<AuthContext>);
|
||||||
|
|
||||||
|
impl<S> FromRequestParts<S> for OptionalAuth
|
||||||
|
where
|
||||||
|
S: Send + Sync,
|
||||||
|
{
|
||||||
|
type Rejection = ApiError;
|
||||||
|
|
||||||
|
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
|
||||||
|
// Get auth service from extensions
|
||||||
|
let auth_service = parts
|
||||||
|
.extensions
|
||||||
|
.get::<AuthService>()
|
||||||
|
.cloned();
|
||||||
|
|
||||||
|
if let Some(auth_service) = auth_service {
|
||||||
|
match auth_service.authenticate(&parts.headers).await {
|
||||||
|
Ok(context) => Ok(OptionalAuth(Some(context))),
|
||||||
|
Err(_) => Ok(OptionalAuth(None)),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Ok(OptionalAuth(None))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Require specific permissions.
|
||||||
|
pub fn require_permission(
|
||||||
|
context: &AuthContext,
|
||||||
|
permission: &str,
|
||||||
|
) -> Result<(), ApiError> {
|
||||||
|
let has_permission = match permission {
|
||||||
|
"read" => context.can_read(),
|
||||||
|
"write" => context.can_write(),
|
||||||
|
"delete" => context.can_delete(),
|
||||||
|
"admin" => context.is_admin(),
|
||||||
|
_ => false,
|
||||||
|
};
|
||||||
|
|
||||||
|
if has_permission {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(ApiError::InsufficientPermissions)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Require access to a specific service.
|
||||||
|
pub fn require_service_access(
|
||||||
|
context: &AuthContext,
|
||||||
|
service: &str,
|
||||||
|
) -> Result<(), ApiError> {
|
||||||
|
if context.can_access_service(service) {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(ApiError::Forbidden(format!(
|
||||||
|
"No access to service: {}",
|
||||||
|
service
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
}
|
||||||
368
crates/synor-gateway/src/config.rs
Normal file
368
crates/synor-gateway/src/config.rs
Normal file
|
|
@ -0,0 +1,368 @@
|
||||||
|
//! Gateway configuration management.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::net::SocketAddr;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
/// Main gateway configuration.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct GatewayConfig {
|
||||||
|
/// Server configuration
|
||||||
|
pub server: ServerConfig,
|
||||||
|
|
||||||
|
/// Authentication configuration
|
||||||
|
pub auth: AuthConfig,
|
||||||
|
|
||||||
|
/// Rate limiting configuration
|
||||||
|
pub rate_limit: RateLimitConfig,
|
||||||
|
|
||||||
|
/// CORS configuration
|
||||||
|
pub cors: CorsConfig,
|
||||||
|
|
||||||
|
/// Service endpoints
|
||||||
|
pub services: ServiceEndpoints,
|
||||||
|
|
||||||
|
/// Metrics configuration
|
||||||
|
pub metrics: MetricsConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for GatewayConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
server: ServerConfig::default(),
|
||||||
|
auth: AuthConfig::default(),
|
||||||
|
rate_limit: RateLimitConfig::default(),
|
||||||
|
cors: CorsConfig::default(),
|
||||||
|
services: ServiceEndpoints::default(),
|
||||||
|
metrics: MetricsConfig::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// HTTP server configuration.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct ServerConfig {
|
||||||
|
/// Listen address for REST API
|
||||||
|
pub listen_addr: SocketAddr,
|
||||||
|
|
||||||
|
/// Listen address for WebSocket
|
||||||
|
pub ws_addr: SocketAddr,
|
||||||
|
|
||||||
|
/// Maximum request body size in bytes
|
||||||
|
pub max_body_size: usize,
|
||||||
|
|
||||||
|
/// Request timeout
|
||||||
|
#[serde(with = "humantime_serde")]
|
||||||
|
pub request_timeout: Duration,
|
||||||
|
|
||||||
|
/// Shutdown grace period
|
||||||
|
#[serde(with = "humantime_serde")]
|
||||||
|
pub shutdown_timeout: Duration,
|
||||||
|
|
||||||
|
/// Enable compression
|
||||||
|
pub compression: bool,
|
||||||
|
|
||||||
|
/// API version prefix
|
||||||
|
pub api_version: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ServerConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
listen_addr: "0.0.0.0:8000".parse().unwrap(),
|
||||||
|
ws_addr: "0.0.0.0:8001".parse().unwrap(),
|
||||||
|
max_body_size: 10 * 1024 * 1024, // 10 MB
|
||||||
|
request_timeout: Duration::from_secs(30),
|
||||||
|
shutdown_timeout: Duration::from_secs(10),
|
||||||
|
compression: true,
|
||||||
|
api_version: "v1".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Authentication configuration.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct AuthConfig {
|
||||||
|
/// Enable authentication
|
||||||
|
pub enabled: bool,
|
||||||
|
|
||||||
|
/// JWT secret for token signing
|
||||||
|
pub jwt_secret: String,
|
||||||
|
|
||||||
|
/// JWT token expiration
|
||||||
|
#[serde(with = "humantime_serde")]
|
||||||
|
pub jwt_expiration: Duration,
|
||||||
|
|
||||||
|
/// API key header name
|
||||||
|
pub api_key_header: String,
|
||||||
|
|
||||||
|
/// Allow unauthenticated access to health endpoints
|
||||||
|
pub allow_health_unauthenticated: bool,
|
||||||
|
|
||||||
|
/// Allow unauthenticated access to docs
|
||||||
|
pub allow_docs_unauthenticated: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for AuthConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
enabled: true,
|
||||||
|
jwt_secret: "change-me-in-production".to_string(),
|
||||||
|
jwt_expiration: Duration::from_secs(3600), // 1 hour
|
||||||
|
api_key_header: "X-API-Key".to_string(),
|
||||||
|
allow_health_unauthenticated: true,
|
||||||
|
allow_docs_unauthenticated: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rate limiting configuration.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct RateLimitConfig {
|
||||||
|
/// Enable rate limiting
|
||||||
|
pub enabled: bool,
|
||||||
|
|
||||||
|
/// Default requests per minute
|
||||||
|
pub default_rpm: u32,
|
||||||
|
|
||||||
|
/// Burst size
|
||||||
|
pub burst_size: u32,
|
||||||
|
|
||||||
|
/// Rate limit tiers
|
||||||
|
pub tiers: RateLimitTiers,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for RateLimitConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
enabled: true,
|
||||||
|
default_rpm: 60,
|
||||||
|
burst_size: 10,
|
||||||
|
tiers: RateLimitTiers::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rate limit tiers for different API key types.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct RateLimitTiers {
|
||||||
|
pub free: TierConfig,
|
||||||
|
pub developer: TierConfig,
|
||||||
|
pub pro: TierConfig,
|
||||||
|
pub enterprise: TierConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for RateLimitTiers {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
free: TierConfig { rpm: 60, burst: 10, concurrent: 5 },
|
||||||
|
developer: TierConfig { rpm: 600, burst: 100, concurrent: 20 },
|
||||||
|
pro: TierConfig { rpm: 6000, burst: 1000, concurrent: 100 },
|
||||||
|
enterprise: TierConfig { rpm: 0, burst: 0, concurrent: 0 }, // Unlimited
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Configuration for a single rate limit tier.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct TierConfig {
|
||||||
|
/// Requests per minute
|
||||||
|
pub rpm: u32,
|
||||||
|
/// Burst allowance
|
||||||
|
pub burst: u32,
|
||||||
|
/// Max concurrent requests
|
||||||
|
pub concurrent: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// CORS configuration.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct CorsConfig {
|
||||||
|
/// Enable CORS
|
||||||
|
pub enabled: bool,
|
||||||
|
|
||||||
|
/// Allowed origins (empty = allow all)
|
||||||
|
pub allowed_origins: Vec<String>,
|
||||||
|
|
||||||
|
/// Allowed methods
|
||||||
|
pub allowed_methods: Vec<String>,
|
||||||
|
|
||||||
|
/// Allowed headers
|
||||||
|
pub allowed_headers: Vec<String>,
|
||||||
|
|
||||||
|
/// Expose headers
|
||||||
|
pub expose_headers: Vec<String>,
|
||||||
|
|
||||||
|
/// Allow credentials
|
||||||
|
pub allow_credentials: bool,
|
||||||
|
|
||||||
|
/// Max age for preflight cache
|
||||||
|
pub max_age: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for CorsConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
enabled: true,
|
||||||
|
allowed_origins: vec![], // Empty = allow all
|
||||||
|
allowed_methods: vec![
|
||||||
|
"GET".to_string(),
|
||||||
|
"POST".to_string(),
|
||||||
|
"PUT".to_string(),
|
||||||
|
"DELETE".to_string(),
|
||||||
|
"PATCH".to_string(),
|
||||||
|
"OPTIONS".to_string(),
|
||||||
|
],
|
||||||
|
allowed_headers: vec![
|
||||||
|
"Content-Type".to_string(),
|
||||||
|
"Authorization".to_string(),
|
||||||
|
"X-API-Key".to_string(),
|
||||||
|
"X-Request-Id".to_string(),
|
||||||
|
],
|
||||||
|
expose_headers: vec![
|
||||||
|
"X-Request-Id".to_string(),
|
||||||
|
"X-RateLimit-Limit".to_string(),
|
||||||
|
"X-RateLimit-Remaining".to_string(),
|
||||||
|
"X-RateLimit-Reset".to_string(),
|
||||||
|
],
|
||||||
|
allow_credentials: true,
|
||||||
|
max_age: 3600,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Backend service endpoints.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct ServiceEndpoints {
|
||||||
|
/// RPC service endpoint
|
||||||
|
pub rpc: String,
|
||||||
|
|
||||||
|
/// Wallet service endpoint
|
||||||
|
pub wallet: String,
|
||||||
|
|
||||||
|
/// Storage service endpoint
|
||||||
|
pub storage: String,
|
||||||
|
|
||||||
|
/// Database service endpoint
|
||||||
|
pub database: String,
|
||||||
|
|
||||||
|
/// DEX service endpoint
|
||||||
|
pub dex: String,
|
||||||
|
|
||||||
|
/// IBC bridge service endpoint
|
||||||
|
pub ibc: String,
|
||||||
|
|
||||||
|
/// ZK service endpoint
|
||||||
|
pub zk: String,
|
||||||
|
|
||||||
|
/// Compiler service endpoint
|
||||||
|
pub compiler: String,
|
||||||
|
|
||||||
|
/// Crypto service endpoint
|
||||||
|
pub crypto: String,
|
||||||
|
|
||||||
|
/// Mining service endpoint
|
||||||
|
pub mining: String,
|
||||||
|
|
||||||
|
/// Governance service endpoint
|
||||||
|
pub governance: String,
|
||||||
|
|
||||||
|
/// Economics service endpoint
|
||||||
|
pub economics: String,
|
||||||
|
|
||||||
|
/// Hosting service endpoint
|
||||||
|
pub hosting: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ServiceEndpoints {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
rpc: "http://127.0.0.1:16110".to_string(),
|
||||||
|
wallet: "http://127.0.0.1:8010".to_string(),
|
||||||
|
storage: "http://127.0.0.1:8030".to_string(),
|
||||||
|
database: "http://127.0.0.1:8040".to_string(),
|
||||||
|
dex: "http://127.0.0.1:8120".to_string(),
|
||||||
|
ibc: "http://127.0.0.1:8140".to_string(),
|
||||||
|
zk: "http://127.0.0.1:8130".to_string(),
|
||||||
|
compiler: "http://127.0.0.1:8150".to_string(),
|
||||||
|
crypto: "http://127.0.0.1:8160".to_string(),
|
||||||
|
mining: "http://127.0.0.1:8070".to_string(),
|
||||||
|
governance: "http://127.0.0.1:8090".to_string(),
|
||||||
|
economics: "http://127.0.0.1:8080".to_string(),
|
||||||
|
hosting: "http://127.0.0.1:8050".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Metrics configuration.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct MetricsConfig {
|
||||||
|
/// Enable metrics endpoint
|
||||||
|
pub enabled: bool,
|
||||||
|
|
||||||
|
/// Metrics endpoint path
|
||||||
|
pub path: String,
|
||||||
|
|
||||||
|
/// Include detailed histograms
|
||||||
|
pub detailed_histograms: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for MetricsConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
enabled: true,
|
||||||
|
path: "/metrics".to_string(),
|
||||||
|
detailed_histograms: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Duration serialization helper using humantime format.
|
||||||
|
mod humantime_serde {
|
||||||
|
use serde::{Deserialize, Deserializer, Serializer};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
pub fn serialize<S>(duration: &Duration, serializer: S) -> Result<S::Ok, S::Error>
|
||||||
|
where
|
||||||
|
S: Serializer,
|
||||||
|
{
|
||||||
|
let s = humantime::format_duration(*duration).to_string();
|
||||||
|
serializer.serialize_str(&s)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn deserialize<'de, D>(deserializer: D) -> Result<Duration, D::Error>
|
||||||
|
where
|
||||||
|
D: Deserializer<'de>,
|
||||||
|
{
|
||||||
|
let s = String::deserialize(deserializer)?;
|
||||||
|
humantime::parse_duration(&s).map_err(serde::de::Error::custom)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GatewayConfig {
|
||||||
|
/// Load configuration from file.
|
||||||
|
pub fn from_file(path: &str) -> anyhow::Result<Self> {
|
||||||
|
let settings = config::Config::builder()
|
||||||
|
.add_source(config::File::with_name(path))
|
||||||
|
.add_source(config::Environment::with_prefix("SYNOR_GATEWAY"))
|
||||||
|
.build()?;
|
||||||
|
|
||||||
|
Ok(settings.try_deserialize()?)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load configuration from environment variables only.
|
||||||
|
pub fn from_env() -> anyhow::Result<Self> {
|
||||||
|
let settings = config::Config::builder()
|
||||||
|
.add_source(config::Environment::with_prefix("SYNOR_GATEWAY"))
|
||||||
|
.build()?;
|
||||||
|
|
||||||
|
Ok(settings.try_deserialize().unwrap_or_default())
|
||||||
|
}
|
||||||
|
}
|
||||||
289
crates/synor-gateway/src/error.rs
Normal file
289
crates/synor-gateway/src/error.rs
Normal file
|
|
@ -0,0 +1,289 @@
|
||||||
|
//! API error types and handling.
|
||||||
|
|
||||||
|
use axum::{
|
||||||
|
http::StatusCode,
|
||||||
|
response::{IntoResponse, Response},
|
||||||
|
Json,
|
||||||
|
};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
/// Result type for API operations.
|
||||||
|
pub type ApiResult<T> = Result<T, ApiError>;
|
||||||
|
|
||||||
|
/// API error types.
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum ApiError {
|
||||||
|
// Authentication errors (401)
|
||||||
|
#[error("Authentication required")]
|
||||||
|
Unauthorized,
|
||||||
|
|
||||||
|
#[error("Invalid API key")]
|
||||||
|
InvalidApiKey,
|
||||||
|
|
||||||
|
#[error("Expired token")]
|
||||||
|
ExpiredToken,
|
||||||
|
|
||||||
|
#[error("Invalid token: {0}")]
|
||||||
|
InvalidToken(String),
|
||||||
|
|
||||||
|
// Authorization errors (403)
|
||||||
|
#[error("Access denied: {0}")]
|
||||||
|
Forbidden(String),
|
||||||
|
|
||||||
|
#[error("Insufficient permissions")]
|
||||||
|
InsufficientPermissions,
|
||||||
|
|
||||||
|
// Not found errors (404)
|
||||||
|
#[error("Resource not found: {0}")]
|
||||||
|
NotFound(String),
|
||||||
|
|
||||||
|
#[error("Endpoint not found")]
|
||||||
|
EndpointNotFound,
|
||||||
|
|
||||||
|
// Validation errors (400)
|
||||||
|
#[error("Invalid request: {0}")]
|
||||||
|
BadRequest(String),
|
||||||
|
|
||||||
|
#[error("Validation failed: {0}")]
|
||||||
|
ValidationError(String),
|
||||||
|
|
||||||
|
#[error("Missing required field: {0}")]
|
||||||
|
MissingField(String),
|
||||||
|
|
||||||
|
#[error("Invalid parameter: {field} - {message}")]
|
||||||
|
InvalidParameter { field: String, message: String },
|
||||||
|
|
||||||
|
// Rate limiting (429)
|
||||||
|
#[error("Rate limit exceeded")]
|
||||||
|
RateLimitExceeded,
|
||||||
|
|
||||||
|
#[error("Too many requests, retry after {retry_after} seconds")]
|
||||||
|
TooManyRequests { retry_after: u64 },
|
||||||
|
|
||||||
|
// Conflict errors (409)
|
||||||
|
#[error("Resource already exists: {0}")]
|
||||||
|
Conflict(String),
|
||||||
|
|
||||||
|
// Service errors (500+)
|
||||||
|
#[error("Internal server error")]
|
||||||
|
InternalError,
|
||||||
|
|
||||||
|
#[error("Service unavailable: {0}")]
|
||||||
|
ServiceUnavailable(String),
|
||||||
|
|
||||||
|
#[error("Upstream service error: {0}")]
|
||||||
|
UpstreamError(String),
|
||||||
|
|
||||||
|
#[error("Request timeout")]
|
||||||
|
Timeout,
|
||||||
|
|
||||||
|
// Domain-specific errors
|
||||||
|
#[error("Insufficient balance: required {required}, available {available}")]
|
||||||
|
InsufficientBalance { required: String, available: String },
|
||||||
|
|
||||||
|
#[error("Transaction failed: {0}")]
|
||||||
|
TransactionFailed(String),
|
||||||
|
|
||||||
|
#[error("Contract error: {0}")]
|
||||||
|
ContractError(String),
|
||||||
|
|
||||||
|
#[error("Invalid address: {0}")]
|
||||||
|
InvalidAddress(String),
|
||||||
|
|
||||||
|
#[error("Invalid signature")]
|
||||||
|
InvalidSignature,
|
||||||
|
|
||||||
|
// Catch-all
|
||||||
|
#[error("{0}")]
|
||||||
|
Custom(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Error response body.
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct ErrorResponse {
|
||||||
|
/// Whether the request succeeded
|
||||||
|
pub success: bool,
|
||||||
|
|
||||||
|
/// Error details
|
||||||
|
pub error: ErrorDetails,
|
||||||
|
|
||||||
|
/// Request metadata
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub meta: Option<ErrorMeta>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Detailed error information.
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct ErrorDetails {
|
||||||
|
/// Error code (machine-readable)
|
||||||
|
pub code: String,
|
||||||
|
|
||||||
|
/// Human-readable error message
|
||||||
|
pub message: String,
|
||||||
|
|
||||||
|
/// Additional error details
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub details: Option<serde_json::Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Error metadata.
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct ErrorMeta {
|
||||||
|
/// Request ID for tracing
|
||||||
|
pub request_id: String,
|
||||||
|
|
||||||
|
/// Timestamp
|
||||||
|
pub timestamp: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ApiError {
|
||||||
|
/// Get the HTTP status code for this error.
|
||||||
|
pub fn status_code(&self) -> StatusCode {
|
||||||
|
match self {
|
||||||
|
// 400 Bad Request
|
||||||
|
Self::BadRequest(_)
|
||||||
|
| Self::ValidationError(_)
|
||||||
|
| Self::MissingField(_)
|
||||||
|
| Self::InvalidParameter { .. }
|
||||||
|
| Self::InvalidAddress(_)
|
||||||
|
| Self::InvalidSignature => StatusCode::BAD_REQUEST,
|
||||||
|
|
||||||
|
// 401 Unauthorized
|
||||||
|
Self::Unauthorized
|
||||||
|
| Self::InvalidApiKey
|
||||||
|
| Self::ExpiredToken
|
||||||
|
| Self::InvalidToken(_) => StatusCode::UNAUTHORIZED,
|
||||||
|
|
||||||
|
// 403 Forbidden
|
||||||
|
Self::Forbidden(_) | Self::InsufficientPermissions => StatusCode::FORBIDDEN,
|
||||||
|
|
||||||
|
// 404 Not Found
|
||||||
|
Self::NotFound(_) | Self::EndpointNotFound => StatusCode::NOT_FOUND,
|
||||||
|
|
||||||
|
// 409 Conflict
|
||||||
|
Self::Conflict(_) => StatusCode::CONFLICT,
|
||||||
|
|
||||||
|
// 422 Unprocessable Entity
|
||||||
|
Self::InsufficientBalance { .. }
|
||||||
|
| Self::TransactionFailed(_)
|
||||||
|
| Self::ContractError(_) => StatusCode::UNPROCESSABLE_ENTITY,
|
||||||
|
|
||||||
|
// 429 Too Many Requests
|
||||||
|
Self::RateLimitExceeded | Self::TooManyRequests { .. } => {
|
||||||
|
StatusCode::TOO_MANY_REQUESTS
|
||||||
|
}
|
||||||
|
|
||||||
|
// 500 Internal Server Error
|
||||||
|
Self::InternalError | Self::Custom(_) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
|
||||||
|
// 502 Bad Gateway
|
||||||
|
Self::UpstreamError(_) => StatusCode::BAD_GATEWAY,
|
||||||
|
|
||||||
|
// 503 Service Unavailable
|
||||||
|
Self::ServiceUnavailable(_) => StatusCode::SERVICE_UNAVAILABLE,
|
||||||
|
|
||||||
|
// 504 Gateway Timeout
|
||||||
|
Self::Timeout => StatusCode::GATEWAY_TIMEOUT,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the error code string.
|
||||||
|
pub fn error_code(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Unauthorized => "UNAUTHORIZED",
|
||||||
|
Self::InvalidApiKey => "INVALID_API_KEY",
|
||||||
|
Self::ExpiredToken => "EXPIRED_TOKEN",
|
||||||
|
Self::InvalidToken(_) => "INVALID_TOKEN",
|
||||||
|
Self::Forbidden(_) => "FORBIDDEN",
|
||||||
|
Self::InsufficientPermissions => "INSUFFICIENT_PERMISSIONS",
|
||||||
|
Self::NotFound(_) => "NOT_FOUND",
|
||||||
|
Self::EndpointNotFound => "ENDPOINT_NOT_FOUND",
|
||||||
|
Self::BadRequest(_) => "BAD_REQUEST",
|
||||||
|
Self::ValidationError(_) => "VALIDATION_ERROR",
|
||||||
|
Self::MissingField(_) => "MISSING_FIELD",
|
||||||
|
Self::InvalidParameter { .. } => "INVALID_PARAMETER",
|
||||||
|
Self::RateLimitExceeded => "RATE_LIMIT_EXCEEDED",
|
||||||
|
Self::TooManyRequests { .. } => "TOO_MANY_REQUESTS",
|
||||||
|
Self::Conflict(_) => "CONFLICT",
|
||||||
|
Self::InternalError => "INTERNAL_ERROR",
|
||||||
|
Self::ServiceUnavailable(_) => "SERVICE_UNAVAILABLE",
|
||||||
|
Self::UpstreamError(_) => "UPSTREAM_ERROR",
|
||||||
|
Self::Timeout => "TIMEOUT",
|
||||||
|
Self::InsufficientBalance { .. } => "INSUFFICIENT_BALANCE",
|
||||||
|
Self::TransactionFailed(_) => "TRANSACTION_FAILED",
|
||||||
|
Self::ContractError(_) => "CONTRACT_ERROR",
|
||||||
|
Self::InvalidAddress(_) => "INVALID_ADDRESS",
|
||||||
|
Self::InvalidSignature => "INVALID_SIGNATURE",
|
||||||
|
Self::Custom(_) => "ERROR",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build error details with optional extra information.
|
||||||
|
pub fn to_details(&self) -> ErrorDetails {
|
||||||
|
let details = match self {
|
||||||
|
Self::InsufficientBalance { required, available } => Some(serde_json::json!({
|
||||||
|
"required": required,
|
||||||
|
"available": available
|
||||||
|
})),
|
||||||
|
Self::InvalidParameter { field, message } => Some(serde_json::json!({
|
||||||
|
"field": field,
|
||||||
|
"message": message
|
||||||
|
})),
|
||||||
|
Self::TooManyRequests { retry_after } => Some(serde_json::json!({
|
||||||
|
"retry_after": retry_after
|
||||||
|
})),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
ErrorDetails {
|
||||||
|
code: self.error_code().to_string(),
|
||||||
|
message: self.to_string(),
|
||||||
|
details,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IntoResponse for ApiError {
|
||||||
|
fn into_response(self) -> Response {
|
||||||
|
let status = self.status_code();
|
||||||
|
let body = ErrorResponse {
|
||||||
|
success: false,
|
||||||
|
error: self.to_details(),
|
||||||
|
meta: None, // Will be filled in by middleware
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut response = (status, Json(body)).into_response();
|
||||||
|
|
||||||
|
// Add rate limit headers for 429 errors
|
||||||
|
if let Self::TooManyRequests { retry_after } = &self {
|
||||||
|
response.headers_mut().insert(
|
||||||
|
"Retry-After",
|
||||||
|
retry_after.to_string().parse().unwrap(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
response
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Implement From for common error types
|
||||||
|
impl From<anyhow::Error> for ApiError {
|
||||||
|
fn from(err: anyhow::Error) -> Self {
|
||||||
|
tracing::error!("Internal error: {:?}", err);
|
||||||
|
Self::InternalError
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<serde_json::Error> for ApiError {
|
||||||
|
fn from(err: serde_json::Error) -> Self {
|
||||||
|
Self::BadRequest(format!("JSON parsing error: {}", err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<std::io::Error> for ApiError {
|
||||||
|
fn from(err: std::io::Error) -> Self {
|
||||||
|
tracing::error!("IO error: {:?}", err);
|
||||||
|
Self::InternalError
|
||||||
|
}
|
||||||
|
}
|
||||||
67
crates/synor-gateway/src/lib.rs
Normal file
67
crates/synor-gateway/src/lib.rs
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
//! # Synor API Gateway
|
||||||
|
//!
|
||||||
|
//! Unified REST API gateway for all Synor blockchain services.
|
||||||
|
//!
|
||||||
|
//! ## Architecture
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! ┌──────────────────────────────────────────────────────────────────┐
|
||||||
|
//! │ Synor API Gateway │
|
||||||
|
//! ├──────────────────────────────────────────────────────────────────┤
|
||||||
|
//! │ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
|
||||||
|
//! │ │ REST API │ │ WebSocket │ │ Metrics │ │
|
||||||
|
//! │ │ /v1/* │ │ /ws │ │ /metrics │ │
|
||||||
|
//! │ └──────────────┘ └──────────────┘ └──────────────┘ │
|
||||||
|
//! │ │ │ │ │
|
||||||
|
//! │ ┌──────┴─────────────────┴─────────────────┴───────────────┐ │
|
||||||
|
//! │ │ Middleware Layer │ │
|
||||||
|
//! │ │ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────────────┐ │ │
|
||||||
|
//! │ │ │ Auth │ │ Rate │ │ CORS │ │ Request Tracing │ │ │
|
||||||
|
//! │ │ │ │ │ Limit │ │ │ │ │ │ │
|
||||||
|
//! │ │ └─────────┘ └─────────┘ └─────────┘ └─────────────────┘ │ │
|
||||||
|
//! │ └──────────────────────────────────────────────────────────┘ │
|
||||||
|
//! │ │ │ │ │
|
||||||
|
//! │ ┌──────┴─────────────────┴─────────────────┴───────────────┐ │
|
||||||
|
//! │ │ Service Routers │ │
|
||||||
|
//! │ │ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ │
|
||||||
|
//! │ │ │ Wallet │ │ RPC │ │ Storage │ │ DEX │ ... │ │
|
||||||
|
//! │ │ └─────────┘ └─────────┘ └─────────┘ └─────────┘ │ │
|
||||||
|
//! │ └──────────────────────────────────────────────────────────┘ │
|
||||||
|
//! └──────────────────────────────────────────────────────────────────┘
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! ## Features
|
||||||
|
//!
|
||||||
|
//! - **REST API**: RESTful endpoints for all services
|
||||||
|
//! - **OpenAPI**: Auto-generated OpenAPI 3.1 documentation
|
||||||
|
//! - **WebSocket**: Real-time event subscriptions
|
||||||
|
//! - **Authentication**: API key and JWT authentication
|
||||||
|
//! - **Rate Limiting**: Configurable per-endpoint rate limits
|
||||||
|
//! - **Metrics**: Prometheus-compatible metrics export
|
||||||
|
//!
|
||||||
|
//! ## Quick Start
|
||||||
|
//!
|
||||||
|
//! ```rust,no_run
|
||||||
|
//! use synor_gateway::{Gateway, GatewayConfig};
|
||||||
|
//!
|
||||||
|
//! #[tokio::main]
|
||||||
|
//! async fn main() -> anyhow::Result<()> {
|
||||||
|
//! let config = GatewayConfig::default();
|
||||||
|
//! let gateway = Gateway::new(config)?;
|
||||||
|
//! gateway.serve().await
|
||||||
|
//! }
|
||||||
|
//! ```
|
||||||
|
|
||||||
|
pub mod auth;
|
||||||
|
pub mod config;
|
||||||
|
pub mod error;
|
||||||
|
pub mod middleware;
|
||||||
|
pub mod response;
|
||||||
|
pub mod routes;
|
||||||
|
pub mod server;
|
||||||
|
|
||||||
|
// Re-exports
|
||||||
|
pub use config::GatewayConfig;
|
||||||
|
pub use error::{ApiError, ApiResult};
|
||||||
|
pub use response::ApiResponse;
|
||||||
|
pub use server::Gateway;
|
||||||
349
crates/synor-gateway/src/middleware.rs
Normal file
349
crates/synor-gateway/src/middleware.rs
Normal file
|
|
@ -0,0 +1,349 @@
|
||||||
|
//! HTTP middleware components.
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
auth::{ApiKeyTier, AuthService},
|
||||||
|
config::{CorsConfig, RateLimitConfig},
|
||||||
|
error::ApiError,
|
||||||
|
};
|
||||||
|
use axum::{
|
||||||
|
body::Body,
|
||||||
|
extract::{ConnectInfo, Request, State},
|
||||||
|
http::{HeaderMap, HeaderName, HeaderValue, Method, StatusCode},
|
||||||
|
middleware::Next,
|
||||||
|
response::{IntoResponse, Response},
|
||||||
|
};
|
||||||
|
use governor::{
|
||||||
|
clock::DefaultClock,
|
||||||
|
state::{InMemoryState, NotKeyed},
|
||||||
|
Quota, RateLimiter,
|
||||||
|
};
|
||||||
|
use std::{
|
||||||
|
collections::HashMap,
|
||||||
|
net::SocketAddr,
|
||||||
|
num::NonZeroU32,
|
||||||
|
sync::Arc,
|
||||||
|
time::{Duration, Instant},
|
||||||
|
};
|
||||||
|
use tokio::sync::RwLock;
|
||||||
|
use tower_http::cors::{Any, CorsLayer};
|
||||||
|
use tracing::{info, warn, Span};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
/// Request ID header name.
|
||||||
|
pub const REQUEST_ID_HEADER: &str = "X-Request-Id";
|
||||||
|
|
||||||
|
/// Rate limit headers.
|
||||||
|
pub const RATE_LIMIT_LIMIT: &str = "X-RateLimit-Limit";
|
||||||
|
pub const RATE_LIMIT_REMAINING: &str = "X-RateLimit-Remaining";
|
||||||
|
pub const RATE_LIMIT_RESET: &str = "X-RateLimit-Reset";
|
||||||
|
|
||||||
|
/// Build CORS layer from configuration.
|
||||||
|
pub fn build_cors_layer(config: &CorsConfig) -> CorsLayer {
|
||||||
|
let mut cors = CorsLayer::new();
|
||||||
|
|
||||||
|
// Allowed origins
|
||||||
|
if config.allowed_origins.is_empty() {
|
||||||
|
cors = cors.allow_origin(Any);
|
||||||
|
} else {
|
||||||
|
let origins: Vec<HeaderValue> = config
|
||||||
|
.allowed_origins
|
||||||
|
.iter()
|
||||||
|
.filter_map(|o| o.parse().ok())
|
||||||
|
.collect();
|
||||||
|
cors = cors.allow_origin(origins);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allowed methods
|
||||||
|
let methods: Vec<Method> = config
|
||||||
|
.allowed_methods
|
||||||
|
.iter()
|
||||||
|
.filter_map(|m| m.parse().ok())
|
||||||
|
.collect();
|
||||||
|
cors = cors.allow_methods(methods);
|
||||||
|
|
||||||
|
// Allowed headers
|
||||||
|
let headers: Vec<HeaderName> = config
|
||||||
|
.allowed_headers
|
||||||
|
.iter()
|
||||||
|
.filter_map(|h| h.parse().ok())
|
||||||
|
.collect();
|
||||||
|
cors = cors.allow_headers(headers);
|
||||||
|
|
||||||
|
// Expose headers
|
||||||
|
let expose: Vec<HeaderName> = config
|
||||||
|
.expose_headers
|
||||||
|
.iter()
|
||||||
|
.filter_map(|h| h.parse().ok())
|
||||||
|
.collect();
|
||||||
|
cors = cors.expose_headers(expose);
|
||||||
|
|
||||||
|
// Credentials
|
||||||
|
if config.allow_credentials {
|
||||||
|
cors = cors.allow_credentials(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Max age
|
||||||
|
cors = cors.max_age(Duration::from_secs(config.max_age));
|
||||||
|
|
||||||
|
cors
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Request ID middleware - adds unique ID to each request.
|
||||||
|
pub async fn request_id_middleware(mut request: Request, next: Next) -> Response {
|
||||||
|
let request_id = request
|
||||||
|
.headers()
|
||||||
|
.get(REQUEST_ID_HEADER)
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
.unwrap_or_else(|| Uuid::new_v4().to_string());
|
||||||
|
|
||||||
|
request
|
||||||
|
.headers_mut()
|
||||||
|
.insert(REQUEST_ID_HEADER, request_id.parse().unwrap());
|
||||||
|
|
||||||
|
// Add to tracing span
|
||||||
|
Span::current().record("request_id", &request_id);
|
||||||
|
|
||||||
|
let mut response = next.run(request).await;
|
||||||
|
|
||||||
|
response
|
||||||
|
.headers_mut()
|
||||||
|
.insert(REQUEST_ID_HEADER, request_id.parse().unwrap());
|
||||||
|
|
||||||
|
response
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Request timing middleware - tracks request duration.
|
||||||
|
pub async fn timing_middleware(request: Request, next: Next) -> Response {
|
||||||
|
let start = Instant::now();
|
||||||
|
let method = request.method().clone();
|
||||||
|
let uri = request.uri().path().to_string();
|
||||||
|
|
||||||
|
let response = next.run(request).await;
|
||||||
|
|
||||||
|
let duration = start.elapsed();
|
||||||
|
let status = response.status();
|
||||||
|
|
||||||
|
// Log request
|
||||||
|
if status.is_success() {
|
||||||
|
info!(
|
||||||
|
method = %method,
|
||||||
|
uri = %uri,
|
||||||
|
status = %status.as_u16(),
|
||||||
|
duration_ms = %duration.as_millis(),
|
||||||
|
"Request completed"
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
warn!(
|
||||||
|
method = %method,
|
||||||
|
uri = %uri,
|
||||||
|
status = %status.as_u16(),
|
||||||
|
duration_ms = %duration.as_millis(),
|
||||||
|
"Request failed"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update metrics
|
||||||
|
metrics::counter!("http_requests_total", "method" => method.to_string(), "status" => status.as_u16().to_string()).increment(1);
|
||||||
|
metrics::histogram!("http_request_duration_seconds", "method" => method.to_string()).record(duration.as_secs_f64());
|
||||||
|
|
||||||
|
response
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rate limiter state.
|
||||||
|
pub struct RateLimiterState {
|
||||||
|
config: RateLimitConfig,
|
||||||
|
/// Per-IP rate limiters
|
||||||
|
ip_limiters: RwLock<HashMap<String, Arc<RateLimiter<NotKeyed, InMemoryState, DefaultClock>>>>,
|
||||||
|
/// Per-API-key rate limiters
|
||||||
|
key_limiters: RwLock<HashMap<String, Arc<RateLimiter<NotKeyed, InMemoryState, DefaultClock>>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RateLimiterState {
|
||||||
|
/// Create a new rate limiter state.
|
||||||
|
pub fn new(config: RateLimitConfig) -> Self {
|
||||||
|
Self {
|
||||||
|
config,
|
||||||
|
ip_limiters: RwLock::new(HashMap::new()),
|
||||||
|
key_limiters: RwLock::new(HashMap::new()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get or create a rate limiter for an IP.
|
||||||
|
pub async fn get_ip_limiter(&self, ip: &str) -> Arc<RateLimiter<NotKeyed, InMemoryState, DefaultClock>> {
|
||||||
|
{
|
||||||
|
let limiters = self.ip_limiters.read().await;
|
||||||
|
if let Some(limiter) = limiters.get(ip) {
|
||||||
|
return limiter.clone();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let rpm = NonZeroU32::new(self.config.default_rpm).unwrap_or(NonZeroU32::new(60).unwrap());
|
||||||
|
let quota = Quota::per_minute(rpm).allow_burst(
|
||||||
|
NonZeroU32::new(self.config.burst_size).unwrap_or(NonZeroU32::new(10).unwrap()),
|
||||||
|
);
|
||||||
|
let limiter = Arc::new(RateLimiter::direct(quota));
|
||||||
|
|
||||||
|
let mut limiters = self.ip_limiters.write().await;
|
||||||
|
limiters.insert(ip.to_string(), limiter.clone());
|
||||||
|
limiter
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get or create a rate limiter for an API key.
|
||||||
|
pub async fn get_key_limiter(&self, key_id: &str, tier: ApiKeyTier) -> Arc<RateLimiter<NotKeyed, InMemoryState, DefaultClock>> {
|
||||||
|
{
|
||||||
|
let limiters = self.key_limiters.read().await;
|
||||||
|
if let Some(limiter) = limiters.get(key_id) {
|
||||||
|
return limiter.clone();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let tier_config = match tier {
|
||||||
|
ApiKeyTier::Free => &self.config.tiers.free,
|
||||||
|
ApiKeyTier::Developer => &self.config.tiers.developer,
|
||||||
|
ApiKeyTier::Pro => &self.config.tiers.pro,
|
||||||
|
ApiKeyTier::Enterprise => &self.config.tiers.enterprise,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Enterprise tier has unlimited requests
|
||||||
|
if tier_config.rpm == 0 {
|
||||||
|
let quota = Quota::per_second(NonZeroU32::new(u32::MAX).unwrap());
|
||||||
|
let limiter = Arc::new(RateLimiter::direct(quota));
|
||||||
|
let mut limiters = self.key_limiters.write().await;
|
||||||
|
limiters.insert(key_id.to_string(), limiter.clone());
|
||||||
|
return limiter;
|
||||||
|
}
|
||||||
|
|
||||||
|
let rpm = NonZeroU32::new(tier_config.rpm).unwrap_or(NonZeroU32::new(60).unwrap());
|
||||||
|
let quota = Quota::per_minute(rpm).allow_burst(
|
||||||
|
NonZeroU32::new(tier_config.burst).unwrap_or(NonZeroU32::new(10).unwrap()),
|
||||||
|
);
|
||||||
|
let limiter = Arc::new(RateLimiter::direct(quota));
|
||||||
|
|
||||||
|
let mut limiters = self.key_limiters.write().await;
|
||||||
|
limiters.insert(key_id.to_string(), limiter.clone());
|
||||||
|
limiter
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rate limiting middleware.
|
||||||
|
pub async fn rate_limit_middleware(
|
||||||
|
State(state): State<Arc<RateLimiterState>>,
|
||||||
|
ConnectInfo(addr): ConnectInfo<SocketAddr>,
|
||||||
|
request: Request,
|
||||||
|
next: Next,
|
||||||
|
) -> Result<Response, ApiError> {
|
||||||
|
if !state.config.enabled {
|
||||||
|
return Ok(next.run(request).await);
|
||||||
|
}
|
||||||
|
|
||||||
|
let ip = addr.ip().to_string();
|
||||||
|
let limiter = state.get_ip_limiter(&ip).await;
|
||||||
|
|
||||||
|
match limiter.check() {
|
||||||
|
Ok(_) => {
|
||||||
|
let mut response = next.run(request).await;
|
||||||
|
|
||||||
|
// Add rate limit headers
|
||||||
|
let state = limiter.state_snapshot();
|
||||||
|
let remaining = state.remaining_burst_capacity();
|
||||||
|
|
||||||
|
response.headers_mut().insert(
|
||||||
|
RATE_LIMIT_LIMIT,
|
||||||
|
state.config.default_rpm.to_string().parse().unwrap(),
|
||||||
|
);
|
||||||
|
response.headers_mut().insert(
|
||||||
|
RATE_LIMIT_REMAINING,
|
||||||
|
remaining.to_string().parse().unwrap(),
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(response)
|
||||||
|
}
|
||||||
|
Err(not_until) => {
|
||||||
|
let retry_after = not_until
|
||||||
|
.wait_time_from(DefaultClock::default().now())
|
||||||
|
.as_secs();
|
||||||
|
|
||||||
|
Err(ApiError::TooManyRequests {
|
||||||
|
retry_after,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Authentication middleware that injects auth service.
|
||||||
|
pub async fn auth_middleware(
|
||||||
|
State(auth_service): State<Arc<AuthService>>,
|
||||||
|
mut request: Request,
|
||||||
|
next: Next,
|
||||||
|
) -> Response {
|
||||||
|
// Inject auth service into request extensions
|
||||||
|
request.extensions_mut().insert((*auth_service).clone());
|
||||||
|
next.run(request).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// API version middleware - validates version prefix.
|
||||||
|
pub async fn version_middleware(
|
||||||
|
request: Request,
|
||||||
|
next: Next,
|
||||||
|
) -> Result<Response, ApiError> {
|
||||||
|
let path = request.uri().path();
|
||||||
|
|
||||||
|
// Skip version check for health, metrics, and docs
|
||||||
|
if path == "/health"
|
||||||
|
|| path.starts_with("/metrics")
|
||||||
|
|| path.starts_with("/docs")
|
||||||
|
|| path.starts_with("/swagger")
|
||||||
|
|| path.starts_with("/openapi")
|
||||||
|
{
|
||||||
|
return Ok(next.run(request).await);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for version prefix
|
||||||
|
if !path.starts_with("/v1/") && !path.starts_with("/v2/") {
|
||||||
|
return Err(ApiError::BadRequest(
|
||||||
|
"API version required. Use /v1/ prefix".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(next.run(request).await)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Security headers middleware.
|
||||||
|
pub async fn security_headers_middleware(request: Request, next: Next) -> Response {
|
||||||
|
let mut response = next.run(request).await;
|
||||||
|
|
||||||
|
let headers = response.headers_mut();
|
||||||
|
|
||||||
|
// Prevent XSS
|
||||||
|
headers.insert(
|
||||||
|
"X-Content-Type-Options",
|
||||||
|
"nosniff".parse().unwrap(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Prevent clickjacking
|
||||||
|
headers.insert(
|
||||||
|
"X-Frame-Options",
|
||||||
|
"DENY".parse().unwrap(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Enable XSS filter
|
||||||
|
headers.insert(
|
||||||
|
"X-XSS-Protection",
|
||||||
|
"1; mode=block".parse().unwrap(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Strict transport security (HTTPS)
|
||||||
|
headers.insert(
|
||||||
|
"Strict-Transport-Security",
|
||||||
|
"max-age=31536000; includeSubDomains".parse().unwrap(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Content security policy
|
||||||
|
headers.insert(
|
||||||
|
"Content-Security-Policy",
|
||||||
|
"default-src 'self'".parse().unwrap(),
|
||||||
|
);
|
||||||
|
|
||||||
|
response
|
||||||
|
}
|
||||||
290
crates/synor-gateway/src/response.rs
Normal file
290
crates/synor-gateway/src/response.rs
Normal file
|
|
@ -0,0 +1,290 @@
|
||||||
|
//! Standard API response types.
|
||||||
|
|
||||||
|
use axum::{
|
||||||
|
http::StatusCode,
|
||||||
|
response::{IntoResponse, Response},
|
||||||
|
Json,
|
||||||
|
};
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// Standard API response wrapper.
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct ApiResponse<T> {
|
||||||
|
/// Whether the request succeeded
|
||||||
|
pub success: bool,
|
||||||
|
|
||||||
|
/// Response data (present on success)
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub data: Option<T>,
|
||||||
|
|
||||||
|
/// Response metadata
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub meta: Option<ResponseMeta>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Response metadata.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ResponseMeta {
|
||||||
|
/// Unique request ID for tracing
|
||||||
|
pub request_id: String,
|
||||||
|
|
||||||
|
/// Response timestamp
|
||||||
|
pub timestamp: DateTime<Utc>,
|
||||||
|
|
||||||
|
/// Request processing time in milliseconds
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub latency_ms: Option<u64>,
|
||||||
|
|
||||||
|
/// Pagination info (if applicable)
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub pagination: Option<PaginationMeta>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pagination metadata.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct PaginationMeta {
|
||||||
|
/// Total number of items
|
||||||
|
pub total: u64,
|
||||||
|
|
||||||
|
/// Current page (1-indexed)
|
||||||
|
pub page: u32,
|
||||||
|
|
||||||
|
/// Items per page
|
||||||
|
pub per_page: u32,
|
||||||
|
|
||||||
|
/// Total number of pages
|
||||||
|
pub total_pages: u32,
|
||||||
|
|
||||||
|
/// Whether there's a next page
|
||||||
|
pub has_next: bool,
|
||||||
|
|
||||||
|
/// Whether there's a previous page
|
||||||
|
pub has_prev: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T: Serialize> ApiResponse<T> {
|
||||||
|
/// Create a successful response with data.
|
||||||
|
pub fn success(data: T) -> Self {
|
||||||
|
Self {
|
||||||
|
success: true,
|
||||||
|
data: Some(data),
|
||||||
|
meta: Some(ResponseMeta {
|
||||||
|
request_id: uuid::Uuid::new_v4().to_string(),
|
||||||
|
timestamp: Utc::now(),
|
||||||
|
latency_ms: None,
|
||||||
|
pagination: None,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a successful response with data and pagination.
|
||||||
|
pub fn success_paginated(data: T, pagination: PaginationMeta) -> Self {
|
||||||
|
Self {
|
||||||
|
success: true,
|
||||||
|
data: Some(data),
|
||||||
|
meta: Some(ResponseMeta {
|
||||||
|
request_id: uuid::Uuid::new_v4().to_string(),
|
||||||
|
timestamp: Utc::now(),
|
||||||
|
latency_ms: None,
|
||||||
|
pagination: Some(pagination),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set the request ID.
|
||||||
|
pub fn with_request_id(mut self, request_id: String) -> Self {
|
||||||
|
if let Some(ref mut meta) = self.meta {
|
||||||
|
meta.request_id = request_id;
|
||||||
|
}
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set the latency.
|
||||||
|
pub fn with_latency(mut self, latency_ms: u64) -> Self {
|
||||||
|
if let Some(ref mut meta) = self.meta {
|
||||||
|
meta.latency_ms = Some(latency_ms);
|
||||||
|
}
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T: Serialize> IntoResponse for ApiResponse<T> {
|
||||||
|
fn into_response(self) -> Response {
|
||||||
|
(StatusCode::OK, Json(self)).into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Response for created resources (201).
|
||||||
|
pub struct Created<T>(pub ApiResponse<T>);
|
||||||
|
|
||||||
|
impl<T: Serialize> IntoResponse for Created<T> {
|
||||||
|
fn into_response(self) -> Response {
|
||||||
|
(StatusCode::CREATED, Json(self.0)).into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Response for accepted requests (202).
|
||||||
|
pub struct Accepted<T>(pub ApiResponse<T>);
|
||||||
|
|
||||||
|
impl<T: Serialize> IntoResponse for Accepted<T> {
|
||||||
|
fn into_response(self) -> Response {
|
||||||
|
(StatusCode::ACCEPTED, Json(self.0)).into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Response for no content (204).
|
||||||
|
pub struct NoContent;
|
||||||
|
|
||||||
|
impl IntoResponse for NoContent {
|
||||||
|
fn into_response(self) -> Response {
|
||||||
|
StatusCode::NO_CONTENT.into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Empty success response.
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct EmptyResponse;
|
||||||
|
|
||||||
|
impl ApiResponse<EmptyResponse> {
|
||||||
|
/// Create an empty success response.
|
||||||
|
pub fn empty() -> Self {
|
||||||
|
Self {
|
||||||
|
success: true,
|
||||||
|
data: None,
|
||||||
|
meta: Some(ResponseMeta {
|
||||||
|
request_id: uuid::Uuid::new_v4().to_string(),
|
||||||
|
timestamp: Utc::now(),
|
||||||
|
latency_ms: None,
|
||||||
|
pagination: None,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pagination query parameters.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct PaginationParams {
|
||||||
|
/// Page number (1-indexed, default: 1)
|
||||||
|
#[serde(default = "default_page")]
|
||||||
|
pub page: u32,
|
||||||
|
|
||||||
|
/// Items per page (default: 20, max: 100)
|
||||||
|
#[serde(default = "default_per_page")]
|
||||||
|
pub per_page: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_page() -> u32 {
|
||||||
|
1
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_per_page() -> u32 {
|
||||||
|
20
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for PaginationParams {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
page: 1,
|
||||||
|
per_page: 20,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PaginationParams {
|
||||||
|
/// Get the offset for database queries.
|
||||||
|
pub fn offset(&self) -> u64 {
|
||||||
|
((self.page.saturating_sub(1)) * self.per_page) as u64
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the limit for database queries.
|
||||||
|
pub fn limit(&self) -> u32 {
|
||||||
|
self.per_page.min(100)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create pagination metadata from results.
|
||||||
|
pub fn to_meta(&self, total: u64) -> PaginationMeta {
|
||||||
|
let total_pages = ((total as f64) / (self.per_page as f64)).ceil() as u32;
|
||||||
|
PaginationMeta {
|
||||||
|
total,
|
||||||
|
page: self.page,
|
||||||
|
per_page: self.per_page,
|
||||||
|
total_pages,
|
||||||
|
has_next: self.page < total_pages,
|
||||||
|
has_prev: self.page > 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sorting parameters.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct SortParams {
|
||||||
|
/// Field to sort by
|
||||||
|
pub sort_by: Option<String>,
|
||||||
|
|
||||||
|
/// Sort direction (asc or desc)
|
||||||
|
#[serde(default = "default_sort_order")]
|
||||||
|
pub sort_order: SortOrder,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_sort_order() -> SortOrder {
|
||||||
|
SortOrder::Desc
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sort direction.
|
||||||
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum SortOrder {
|
||||||
|
Asc,
|
||||||
|
Desc,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for SortOrder {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::Desc
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Health check response.
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct HealthResponse {
|
||||||
|
/// Overall health status
|
||||||
|
pub status: HealthStatus,
|
||||||
|
|
||||||
|
/// Service version
|
||||||
|
pub version: String,
|
||||||
|
|
||||||
|
/// Uptime in seconds
|
||||||
|
pub uptime_seconds: u64,
|
||||||
|
|
||||||
|
/// Individual service health
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub services: Option<Vec<ServiceHealth>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Health status enum.
|
||||||
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum HealthStatus {
|
||||||
|
Healthy,
|
||||||
|
Degraded,
|
||||||
|
Unhealthy,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Individual service health.
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct ServiceHealth {
|
||||||
|
/// Service name
|
||||||
|
pub name: String,
|
||||||
|
|
||||||
|
/// Health status
|
||||||
|
pub status: HealthStatus,
|
||||||
|
|
||||||
|
/// Optional message
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub message: Option<String>,
|
||||||
|
|
||||||
|
/// Response time in milliseconds
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub latency_ms: Option<u64>,
|
||||||
|
}
|
||||||
104
crates/synor-gateway/src/routes/health.rs
Normal file
104
crates/synor-gateway/src/routes/health.rs
Normal file
|
|
@ -0,0 +1,104 @@
|
||||||
|
//! Health check endpoints.
|
||||||
|
|
||||||
|
use axum::{extract::State, routing::get, Json, Router};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
response::{ApiResponse, HealthResponse, HealthStatus, ServiceHealth},
|
||||||
|
routes::AppState,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Build health routes.
|
||||||
|
pub fn router() -> Router<AppState> {
|
||||||
|
Router::new()
|
||||||
|
.route("/health", get(health_check))
|
||||||
|
.route("/health/live", get(liveness))
|
||||||
|
.route("/health/ready", get(readiness))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Basic health check.
|
||||||
|
#[utoipa::path(
|
||||||
|
get,
|
||||||
|
path = "/health",
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "Service is healthy", body = HealthResponse)
|
||||||
|
),
|
||||||
|
tag = "Health"
|
||||||
|
)]
|
||||||
|
async fn health_check(State(state): State<AppState>) -> Json<HealthResponse> {
|
||||||
|
// Get uptime (would come from server state in production)
|
||||||
|
let uptime_seconds = 0; // Placeholder
|
||||||
|
|
||||||
|
Json(HealthResponse {
|
||||||
|
status: HealthStatus::Healthy,
|
||||||
|
version: env!("CARGO_PKG_VERSION").to_string(),
|
||||||
|
uptime_seconds,
|
||||||
|
services: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Liveness probe (for Kubernetes).
|
||||||
|
#[utoipa::path(
|
||||||
|
get,
|
||||||
|
path = "/health/live",
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "Service is alive")
|
||||||
|
),
|
||||||
|
tag = "Health"
|
||||||
|
)]
|
||||||
|
async fn liveness() -> &'static str {
|
||||||
|
"OK"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Readiness probe with service checks.
|
||||||
|
#[utoipa::path(
|
||||||
|
get,
|
||||||
|
path = "/health/ready",
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "Service is ready", body = HealthResponse),
|
||||||
|
(status = 503, description = "Service is not ready")
|
||||||
|
),
|
||||||
|
tag = "Health"
|
||||||
|
)]
|
||||||
|
async fn readiness(State(state): State<AppState>) -> Json<HealthResponse> {
|
||||||
|
let mut services = Vec::new();
|
||||||
|
let mut overall_status = HealthStatus::Healthy;
|
||||||
|
|
||||||
|
// Check RPC service
|
||||||
|
let rpc_health = check_service_health("rpc", &state.config.services.rpc).await;
|
||||||
|
if rpc_health.status != HealthStatus::Healthy {
|
||||||
|
overall_status = HealthStatus::Degraded;
|
||||||
|
}
|
||||||
|
services.push(rpc_health);
|
||||||
|
|
||||||
|
// Check other services...
|
||||||
|
// In production, we'd check all backend services
|
||||||
|
|
||||||
|
Json(HealthResponse {
|
||||||
|
status: overall_status,
|
||||||
|
version: env!("CARGO_PKG_VERSION").to_string(),
|
||||||
|
uptime_seconds: 0,
|
||||||
|
services: Some(services),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check health of a backend service.
|
||||||
|
async fn check_service_health(name: &str, endpoint: &str) -> ServiceHealth {
|
||||||
|
let start = Instant::now();
|
||||||
|
|
||||||
|
// Attempt to connect to the service
|
||||||
|
// In production, this would make an actual health check request
|
||||||
|
let is_healthy = true; // Placeholder
|
||||||
|
|
||||||
|
ServiceHealth {
|
||||||
|
name: name.to_string(),
|
||||||
|
status: if is_healthy {
|
||||||
|
HealthStatus::Healthy
|
||||||
|
} else {
|
||||||
|
HealthStatus::Unhealthy
|
||||||
|
},
|
||||||
|
message: None,
|
||||||
|
latency_ms: Some(start.elapsed().as_millis() as u64),
|
||||||
|
}
|
||||||
|
}
|
||||||
54
crates/synor-gateway/src/routes/mod.rs
Normal file
54
crates/synor-gateway/src/routes/mod.rs
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
//! API route definitions.
|
||||||
|
//!
|
||||||
|
//! This module contains all REST API endpoints organized by service.
|
||||||
|
|
||||||
|
pub mod health;
|
||||||
|
pub mod wallet;
|
||||||
|
pub mod rpc;
|
||||||
|
pub mod storage;
|
||||||
|
pub mod dex;
|
||||||
|
pub mod ibc;
|
||||||
|
pub mod zk;
|
||||||
|
pub mod compiler;
|
||||||
|
|
||||||
|
use axum::Router;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use crate::config::GatewayConfig;
|
||||||
|
|
||||||
|
/// Application state shared across all routes.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct AppState {
|
||||||
|
pub config: Arc<GatewayConfig>,
|
||||||
|
// HTTP clients for backend services would go here
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AppState {
|
||||||
|
pub fn new(config: GatewayConfig) -> Self {
|
||||||
|
Self {
|
||||||
|
config: Arc::new(config),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the main API router with all service routes.
|
||||||
|
pub fn build_router(state: AppState) -> Router {
|
||||||
|
Router::new()
|
||||||
|
// Health & status endpoints (no auth required)
|
||||||
|
.merge(health::router())
|
||||||
|
// Versioned API routes
|
||||||
|
.nest("/v1", build_v1_router())
|
||||||
|
.with_state(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the v1 API router.
|
||||||
|
fn build_v1_router() -> Router<AppState> {
|
||||||
|
Router::new()
|
||||||
|
.nest("/wallet", wallet::router())
|
||||||
|
.nest("/rpc", rpc::router())
|
||||||
|
.nest("/storage", storage::router())
|
||||||
|
.nest("/dex", dex::router())
|
||||||
|
.nest("/ibc", ibc::router())
|
||||||
|
.nest("/zk", zk::router())
|
||||||
|
.nest("/compiler", compiler::router())
|
||||||
|
}
|
||||||
366
crates/synor-gateway/src/routes/rpc.rs
Normal file
366
crates/synor-gateway/src/routes/rpc.rs
Normal file
|
|
@ -0,0 +1,366 @@
|
||||||
|
//! RPC API endpoints.
|
||||||
|
//!
|
||||||
|
//! REST endpoints for blockchain RPC operations:
|
||||||
|
//! - Block queries
|
||||||
|
//! - Transaction submission and queries
|
||||||
|
//! - Network information
|
||||||
|
//! - Chain state
|
||||||
|
|
||||||
|
use axum::{
|
||||||
|
extract::{Path, Query, State},
|
||||||
|
routing::{get, post},
|
||||||
|
Json, Router,
|
||||||
|
};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
auth::{require_permission, Authenticated},
|
||||||
|
error::{ApiError, ApiResult},
|
||||||
|
response::{ApiResponse, PaginationParams},
|
||||||
|
routes::AppState,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Build RPC routes.
|
||||||
|
pub fn router() -> Router<AppState> {
|
||||||
|
Router::new()
|
||||||
|
// Block operations
|
||||||
|
.route("/block/:id", get(get_block))
|
||||||
|
.route("/blocks", get(get_blocks))
|
||||||
|
.route("/tips", get(get_tips))
|
||||||
|
.route("/block-count", get(get_block_count))
|
||||||
|
// Transaction operations
|
||||||
|
.route("/tx/:txid", get(get_transaction))
|
||||||
|
.route("/tx/send", post(send_transaction))
|
||||||
|
.route("/tx/estimate-fee", post(estimate_fee))
|
||||||
|
.route("/mempool", get(get_mempool))
|
||||||
|
// Network info
|
||||||
|
.route("/info", get(get_network_info))
|
||||||
|
.route("/peers", get(get_peers))
|
||||||
|
.route("/sync-status", get(get_sync_status))
|
||||||
|
// Mining (for miners)
|
||||||
|
.route("/mining/template", get(get_block_template))
|
||||||
|
.route("/mining/submit", post(submit_block))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct BlockResponse {
|
||||||
|
pub hash: String,
|
||||||
|
pub height: u64,
|
||||||
|
pub timestamp: u64,
|
||||||
|
pub parent_hashes: Vec<String>,
|
||||||
|
pub transactions: Vec<String>,
|
||||||
|
pub blue_score: u64,
|
||||||
|
pub difficulty: String,
|
||||||
|
pub nonce: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct TransactionResponse {
|
||||||
|
pub txid: String,
|
||||||
|
pub version: u32,
|
||||||
|
pub inputs: Vec<TxInput>,
|
||||||
|
pub outputs: Vec<TxOutput>,
|
||||||
|
pub lock_time: u64,
|
||||||
|
pub mass: u64,
|
||||||
|
pub block_hash: Option<String>,
|
||||||
|
pub confirmations: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct TxInput {
|
||||||
|
pub previous_outpoint: String,
|
||||||
|
pub signature_script: String,
|
||||||
|
pub sequence: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct TxOutput {
|
||||||
|
pub value: String,
|
||||||
|
pub script_public_key: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct SendTransactionRequest {
|
||||||
|
pub raw_tx: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct SendTransactionResponse {
|
||||||
|
pub txid: String,
|
||||||
|
pub accepted: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct EstimateFeeRequest {
|
||||||
|
pub priority: String, // low, medium, high
|
||||||
|
pub tx_size: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct FeeEstimate {
|
||||||
|
pub priority: String,
|
||||||
|
pub fee_per_mass: String,
|
||||||
|
pub estimated_fee: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct NetworkInfo {
|
||||||
|
pub version: String,
|
||||||
|
pub protocol_version: u32,
|
||||||
|
pub network: String,
|
||||||
|
pub connections: u32,
|
||||||
|
pub block_count: u64,
|
||||||
|
pub difficulty: String,
|
||||||
|
pub hashrate: String,
|
||||||
|
pub mempool_size: u32,
|
||||||
|
pub synced: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct SyncStatus {
|
||||||
|
pub synced: bool,
|
||||||
|
pub current_height: u64,
|
||||||
|
pub target_height: u64,
|
||||||
|
pub progress: f64,
|
||||||
|
pub estimated_time_remaining: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct BlockTemplate {
|
||||||
|
pub parent_hashes: Vec<String>,
|
||||||
|
pub transactions: Vec<String>,
|
||||||
|
pub coinbase_value: String,
|
||||||
|
pub coinbase_script: String,
|
||||||
|
pub bits: String,
|
||||||
|
pub target: String,
|
||||||
|
pub timestamp: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct SubmitBlockRequest {
|
||||||
|
pub header: String,
|
||||||
|
pub transactions: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Handlers
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
async fn get_block(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Authenticated(auth): Authenticated,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
) -> ApiResult<Json<ApiResponse<BlockResponse>>> {
|
||||||
|
require_permission(&auth, "read")?;
|
||||||
|
|
||||||
|
// In production, query the RPC service
|
||||||
|
let block = BlockResponse {
|
||||||
|
hash: id.clone(),
|
||||||
|
height: 100000,
|
||||||
|
timestamp: 1705312200,
|
||||||
|
parent_hashes: vec!["parent1...".to_string()],
|
||||||
|
transactions: vec!["tx1...".to_string(), "tx2...".to_string()],
|
||||||
|
blue_score: 50000,
|
||||||
|
difficulty: "1234567890".to_string(),
|
||||||
|
nonce: 12345,
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Json(ApiResponse::success(block)))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_blocks(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Authenticated(auth): Authenticated,
|
||||||
|
Query(pagination): Query<PaginationParams>,
|
||||||
|
) -> ApiResult<Json<ApiResponse<Vec<BlockResponse>>>> {
|
||||||
|
require_permission(&auth, "read")?;
|
||||||
|
|
||||||
|
// Return latest blocks
|
||||||
|
let blocks = vec![]; // Placeholder
|
||||||
|
let meta = pagination.to_meta(0);
|
||||||
|
|
||||||
|
Ok(Json(ApiResponse::success_paginated(blocks, meta)))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_tips(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Authenticated(auth): Authenticated,
|
||||||
|
) -> ApiResult<Json<ApiResponse<Vec<String>>>> {
|
||||||
|
require_permission(&auth, "read")?;
|
||||||
|
|
||||||
|
let tips = vec!["tip1...".to_string(), "tip2...".to_string()];
|
||||||
|
Ok(Json(ApiResponse::success(tips)))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_block_count(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Authenticated(auth): Authenticated,
|
||||||
|
) -> ApiResult<Json<ApiResponse<serde_json::Value>>> {
|
||||||
|
require_permission(&auth, "read")?;
|
||||||
|
|
||||||
|
let count = serde_json::json!({
|
||||||
|
"block_count": 100000,
|
||||||
|
"header_count": 100000
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(Json(ApiResponse::success(count)))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_transaction(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Authenticated(auth): Authenticated,
|
||||||
|
Path(txid): Path<String>,
|
||||||
|
) -> ApiResult<Json<ApiResponse<TransactionResponse>>> {
|
||||||
|
require_permission(&auth, "read")?;
|
||||||
|
|
||||||
|
let tx = TransactionResponse {
|
||||||
|
txid,
|
||||||
|
version: 1,
|
||||||
|
inputs: vec![],
|
||||||
|
outputs: vec![],
|
||||||
|
lock_time: 0,
|
||||||
|
mass: 1000,
|
||||||
|
block_hash: Some("blockhash...".to_string()),
|
||||||
|
confirmations: 10,
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Json(ApiResponse::success(tx)))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn send_transaction(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Authenticated(auth): Authenticated,
|
||||||
|
Json(req): Json<SendTransactionRequest>,
|
||||||
|
) -> ApiResult<Json<ApiResponse<SendTransactionResponse>>> {
|
||||||
|
require_permission(&auth, "write")?;
|
||||||
|
|
||||||
|
// In production, broadcast to network
|
||||||
|
let response = SendTransactionResponse {
|
||||||
|
txid: "newtxid...".to_string(),
|
||||||
|
accepted: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Json(ApiResponse::success(response)))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn estimate_fee(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Authenticated(auth): Authenticated,
|
||||||
|
Json(req): Json<EstimateFeeRequest>,
|
||||||
|
) -> ApiResult<Json<ApiResponse<FeeEstimate>>> {
|
||||||
|
require_permission(&auth, "read")?;
|
||||||
|
|
||||||
|
let estimate = FeeEstimate {
|
||||||
|
priority: req.priority,
|
||||||
|
fee_per_mass: "1".to_string(),
|
||||||
|
estimated_fee: "1000".to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Json(ApiResponse::success(estimate)))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_mempool(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Authenticated(auth): Authenticated,
|
||||||
|
Query(pagination): Query<PaginationParams>,
|
||||||
|
) -> ApiResult<Json<ApiResponse<Vec<String>>>> {
|
||||||
|
require_permission(&auth, "read")?;
|
||||||
|
|
||||||
|
let txids = vec!["mempool_tx1...".to_string()];
|
||||||
|
let meta = pagination.to_meta(1);
|
||||||
|
|
||||||
|
Ok(Json(ApiResponse::success_paginated(txids, meta)))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_network_info(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Authenticated(auth): Authenticated,
|
||||||
|
) -> ApiResult<Json<ApiResponse<NetworkInfo>>> {
|
||||||
|
require_permission(&auth, "read")?;
|
||||||
|
|
||||||
|
let info = NetworkInfo {
|
||||||
|
version: "0.1.0".to_string(),
|
||||||
|
protocol_version: 1,
|
||||||
|
network: "mainnet".to_string(),
|
||||||
|
connections: 50,
|
||||||
|
block_count: 100000,
|
||||||
|
difficulty: "1234567890".to_string(),
|
||||||
|
hashrate: "100 TH/s".to_string(),
|
||||||
|
mempool_size: 100,
|
||||||
|
synced: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Json(ApiResponse::success(info)))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_peers(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Authenticated(auth): Authenticated,
|
||||||
|
) -> ApiResult<Json<ApiResponse<Vec<serde_json::Value>>>> {
|
||||||
|
require_permission(&auth, "read")?;
|
||||||
|
|
||||||
|
let peers = vec![
|
||||||
|
serde_json::json!({
|
||||||
|
"id": "peer1",
|
||||||
|
"address": "192.168.1.1:16100",
|
||||||
|
"connected_since": 1705312200
|
||||||
|
})
|
||||||
|
];
|
||||||
|
|
||||||
|
Ok(Json(ApiResponse::success(peers)))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_sync_status(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Authenticated(auth): Authenticated,
|
||||||
|
) -> ApiResult<Json<ApiResponse<SyncStatus>>> {
|
||||||
|
require_permission(&auth, "read")?;
|
||||||
|
|
||||||
|
let status = SyncStatus {
|
||||||
|
synced: true,
|
||||||
|
current_height: 100000,
|
||||||
|
target_height: 100000,
|
||||||
|
progress: 100.0,
|
||||||
|
estimated_time_remaining: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Json(ApiResponse::success(status)))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_block_template(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Authenticated(auth): Authenticated,
|
||||||
|
) -> ApiResult<Json<ApiResponse<BlockTemplate>>> {
|
||||||
|
require_permission(&auth, "read")?;
|
||||||
|
|
||||||
|
let template = BlockTemplate {
|
||||||
|
parent_hashes: vec!["parent...".to_string()],
|
||||||
|
transactions: vec![],
|
||||||
|
coinbase_value: "50000000000".to_string(),
|
||||||
|
coinbase_script: "".to_string(),
|
||||||
|
bits: "1d00ffff".to_string(),
|
||||||
|
target: "00000000ffff...".to_string(),
|
||||||
|
timestamp: 1705312200,
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Json(ApiResponse::success(template)))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn submit_block(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Authenticated(auth): Authenticated,
|
||||||
|
Json(req): Json<SubmitBlockRequest>,
|
||||||
|
) -> ApiResult<Json<ApiResponse<serde_json::Value>>> {
|
||||||
|
require_permission(&auth, "write")?;
|
||||||
|
|
||||||
|
let result = serde_json::json!({
|
||||||
|
"accepted": true,
|
||||||
|
"block_hash": "newblockhash..."
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(Json(ApiResponse::success(result)))
|
||||||
|
}
|
||||||
259
crates/synor-gateway/src/routes/storage.rs
Normal file
259
crates/synor-gateway/src/routes/storage.rs
Normal file
|
|
@ -0,0 +1,259 @@
|
||||||
|
//! Storage API endpoints.
|
||||||
|
//!
|
||||||
|
//! REST endpoints for decentralized storage operations:
|
||||||
|
//! - File upload/download
|
||||||
|
//! - Pinning management
|
||||||
|
//! - CAR file operations
|
||||||
|
//! - Directory management
|
||||||
|
|
||||||
|
use axum::{
|
||||||
|
extract::{Multipart, Path, Query, State},
|
||||||
|
routing::{delete, get, post},
|
||||||
|
Json, Router,
|
||||||
|
};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
auth::{require_permission, Authenticated},
|
||||||
|
error::{ApiError, ApiResult},
|
||||||
|
response::{ApiResponse, PaginationParams},
|
||||||
|
routes::AppState,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Build storage routes.
|
||||||
|
pub fn router() -> Router<AppState> {
|
||||||
|
Router::new()
|
||||||
|
// Upload/Download
|
||||||
|
.route("/upload", post(upload_file))
|
||||||
|
.route("/download/:cid", get(download_file))
|
||||||
|
.route("/gateway/:cid", get(gateway_url))
|
||||||
|
// Pinning
|
||||||
|
.route("/pin/:cid", post(pin_content))
|
||||||
|
.route("/pin/:cid", delete(unpin_content))
|
||||||
|
.route("/pin/:cid/status", get(pin_status))
|
||||||
|
.route("/pins", get(list_pins))
|
||||||
|
// CAR files
|
||||||
|
.route("/car/create", post(create_car))
|
||||||
|
.route("/car/import", post(import_car))
|
||||||
|
// Directories
|
||||||
|
.route("/directory", post(create_directory))
|
||||||
|
.route("/directory/:cid", get(list_directory))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct UploadResponse {
|
||||||
|
pub cid: String,
|
||||||
|
pub size: u64,
|
||||||
|
pub gateway_url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct PinStatus {
|
||||||
|
pub cid: String,
|
||||||
|
pub pinned: bool,
|
||||||
|
pub size: u64,
|
||||||
|
pub created_at: String,
|
||||||
|
pub expires_at: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct PinRequest {
|
||||||
|
pub duration_days: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct DirectoryEntry {
|
||||||
|
pub name: String,
|
||||||
|
pub cid: String,
|
||||||
|
pub size: u64,
|
||||||
|
pub is_directory: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct CreateDirectoryRequest {
|
||||||
|
pub files: Vec<FileEntry>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct FileEntry {
|
||||||
|
pub name: String,
|
||||||
|
pub cid: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Handlers
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
async fn upload_file(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Authenticated(auth): Authenticated,
|
||||||
|
multipart: Multipart,
|
||||||
|
) -> ApiResult<Json<ApiResponse<UploadResponse>>> {
|
||||||
|
require_permission(&auth, "write")?;
|
||||||
|
|
||||||
|
// In production, process multipart upload
|
||||||
|
let response = UploadResponse {
|
||||||
|
cid: "bafybeigdyr...".to_string(),
|
||||||
|
size: 1024,
|
||||||
|
gateway_url: "https://gateway.synor.io/ipfs/bafybeigdyr...".to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Json(ApiResponse::success(response)))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn download_file(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Authenticated(auth): Authenticated,
|
||||||
|
Path(cid): Path<String>,
|
||||||
|
) -> ApiResult<Json<ApiResponse<serde_json::Value>>> {
|
||||||
|
require_permission(&auth, "read")?;
|
||||||
|
|
||||||
|
// In production, return file content or redirect to gateway
|
||||||
|
let result = serde_json::json!({
|
||||||
|
"cid": cid,
|
||||||
|
"gateway_url": format!("https://gateway.synor.io/ipfs/{}", cid)
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(Json(ApiResponse::success(result)))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn gateway_url(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(cid): Path<String>,
|
||||||
|
) -> ApiResult<Json<ApiResponse<serde_json::Value>>> {
|
||||||
|
let url = serde_json::json!({
|
||||||
|
"url": format!("https://gateway.synor.io/ipfs/{}", cid)
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(Json(ApiResponse::success(url)))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn pin_content(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Authenticated(auth): Authenticated,
|
||||||
|
Path(cid): Path<String>,
|
||||||
|
Json(req): Json<PinRequest>,
|
||||||
|
) -> ApiResult<Json<ApiResponse<PinStatus>>> {
|
||||||
|
require_permission(&auth, "write")?;
|
||||||
|
|
||||||
|
let status = PinStatus {
|
||||||
|
cid,
|
||||||
|
pinned: true,
|
||||||
|
size: 1024,
|
||||||
|
created_at: "2024-01-15T10:30:00Z".to_string(),
|
||||||
|
expires_at: req.duration_days.map(|d| format!("in {} days", d)),
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Json(ApiResponse::success(status)))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn unpin_content(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Authenticated(auth): Authenticated,
|
||||||
|
Path(cid): Path<String>,
|
||||||
|
) -> ApiResult<Json<ApiResponse<serde_json::Value>>> {
|
||||||
|
require_permission(&auth, "delete")?;
|
||||||
|
|
||||||
|
let result = serde_json::json!({
|
||||||
|
"cid": cid,
|
||||||
|
"unpinned": true
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(Json(ApiResponse::success(result)))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn pin_status(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Authenticated(auth): Authenticated,
|
||||||
|
Path(cid): Path<String>,
|
||||||
|
) -> ApiResult<Json<ApiResponse<PinStatus>>> {
|
||||||
|
require_permission(&auth, "read")?;
|
||||||
|
|
||||||
|
let status = PinStatus {
|
||||||
|
cid,
|
||||||
|
pinned: true,
|
||||||
|
size: 1024,
|
||||||
|
created_at: "2024-01-15T10:30:00Z".to_string(),
|
||||||
|
expires_at: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Json(ApiResponse::success(status)))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_pins(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Authenticated(auth): Authenticated,
|
||||||
|
Query(pagination): Query<PaginationParams>,
|
||||||
|
) -> ApiResult<Json<ApiResponse<Vec<PinStatus>>>> {
|
||||||
|
require_permission(&auth, "read")?;
|
||||||
|
|
||||||
|
let pins = vec![];
|
||||||
|
let meta = pagination.to_meta(0);
|
||||||
|
|
||||||
|
Ok(Json(ApiResponse::success_paginated(pins, meta)))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create_car(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Authenticated(auth): Authenticated,
|
||||||
|
multipart: Multipart,
|
||||||
|
) -> ApiResult<Json<ApiResponse<serde_json::Value>>> {
|
||||||
|
require_permission(&auth, "write")?;
|
||||||
|
|
||||||
|
let result = serde_json::json!({
|
||||||
|
"car_cid": "bafycar...",
|
||||||
|
"root_cid": "bafyroot...",
|
||||||
|
"size": 2048
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(Json(ApiResponse::success(result)))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn import_car(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Authenticated(auth): Authenticated,
|
||||||
|
multipart: Multipart,
|
||||||
|
) -> ApiResult<Json<ApiResponse<Vec<String>>>> {
|
||||||
|
require_permission(&auth, "write")?;
|
||||||
|
|
||||||
|
let cids = vec!["bafycid1...".to_string(), "bafycid2...".to_string()];
|
||||||
|
Ok(Json(ApiResponse::success(cids)))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create_directory(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Authenticated(auth): Authenticated,
|
||||||
|
Json(req): Json<CreateDirectoryRequest>,
|
||||||
|
) -> ApiResult<Json<ApiResponse<serde_json::Value>>> {
|
||||||
|
require_permission(&auth, "write")?;
|
||||||
|
|
||||||
|
let result = serde_json::json!({
|
||||||
|
"cid": "bafydir...",
|
||||||
|
"file_count": req.files.len()
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(Json(ApiResponse::success(result)))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_directory(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Authenticated(auth): Authenticated,
|
||||||
|
Path(cid): Path<String>,
|
||||||
|
) -> ApiResult<Json<ApiResponse<Vec<DirectoryEntry>>>> {
|
||||||
|
require_permission(&auth, "read")?;
|
||||||
|
|
||||||
|
let entries = vec![
|
||||||
|
DirectoryEntry {
|
||||||
|
name: "file1.txt".to_string(),
|
||||||
|
cid: "bafyfile1...".to_string(),
|
||||||
|
size: 1024,
|
||||||
|
is_directory: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
Ok(Json(ApiResponse::success(entries)))
|
||||||
|
}
|
||||||
605
crates/synor-gateway/src/routes/wallet.rs
Normal file
605
crates/synor-gateway/src/routes/wallet.rs
Normal file
|
|
@ -0,0 +1,605 @@
|
||||||
|
//! Wallet API endpoints.
|
||||||
|
//!
|
||||||
|
//! REST endpoints for wallet operations including:
|
||||||
|
//! - Wallet creation and import
|
||||||
|
//! - Address generation
|
||||||
|
//! - Balance queries
|
||||||
|
//! - Transaction signing
|
||||||
|
|
||||||
|
use axum::{
|
||||||
|
extract::{Path, Query, State},
|
||||||
|
routing::{get, post},
|
||||||
|
Json, Router,
|
||||||
|
};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
auth::{require_permission, Authenticated},
|
||||||
|
error::{ApiError, ApiResult},
|
||||||
|
response::{ApiResponse, PaginationMeta, PaginationParams},
|
||||||
|
routes::AppState,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Build wallet routes.
|
||||||
|
pub fn router() -> Router<AppState> {
|
||||||
|
Router::new()
|
||||||
|
// Wallet management
|
||||||
|
.route("/create", post(create_wallet))
|
||||||
|
.route("/import", post(import_wallet))
|
||||||
|
.route("/export/:address", get(export_mnemonic))
|
||||||
|
// Address operations
|
||||||
|
.route("/address", get(get_address))
|
||||||
|
.route("/addresses", get(list_addresses))
|
||||||
|
.route("/stealth-address", post(generate_stealth_address))
|
||||||
|
// Balance queries
|
||||||
|
.route("/balance/:address", get(get_balance))
|
||||||
|
.route("/balances", post(get_balances))
|
||||||
|
.route("/utxos/:address", get(get_utxos))
|
||||||
|
// Transaction operations
|
||||||
|
.route("/sign", post(sign_transaction))
|
||||||
|
.route("/sign-message", post(sign_message))
|
||||||
|
.route("/send", post(send_transaction))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Request/Response Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// Request to create a new wallet.
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
|
||||||
|
pub struct CreateWalletRequest {
|
||||||
|
/// Number of words in mnemonic (12, 15, 18, 21, or 24)
|
||||||
|
#[serde(default = "default_word_count")]
|
||||||
|
pub word_count: u8,
|
||||||
|
|
||||||
|
/// Optional passphrase for additional security
|
||||||
|
#[serde(default)]
|
||||||
|
pub passphrase: Option<String>,
|
||||||
|
|
||||||
|
/// Network to use (mainnet, testnet, devnet)
|
||||||
|
#[serde(default = "default_network")]
|
||||||
|
pub network: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_word_count() -> u8 {
|
||||||
|
24
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_network() -> String {
|
||||||
|
"mainnet".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Response for wallet creation.
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
|
||||||
|
pub struct CreateWalletResponse {
|
||||||
|
/// The generated mnemonic phrase (SAVE THIS SECURELY!)
|
||||||
|
pub mnemonic: String,
|
||||||
|
|
||||||
|
/// Primary address derived from the wallet
|
||||||
|
pub address: String,
|
||||||
|
|
||||||
|
/// Public key (hex encoded)
|
||||||
|
pub public_key: String,
|
||||||
|
|
||||||
|
/// Network the wallet was created for
|
||||||
|
pub network: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Request to import an existing wallet.
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
|
||||||
|
pub struct ImportWalletRequest {
|
||||||
|
/// BIP-39 mnemonic phrase
|
||||||
|
pub mnemonic: String,
|
||||||
|
|
||||||
|
/// Optional passphrase
|
||||||
|
#[serde(default)]
|
||||||
|
pub passphrase: Option<String>,
|
||||||
|
|
||||||
|
/// Network to use
|
||||||
|
#[serde(default = "default_network")]
|
||||||
|
pub network: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Response for wallet import.
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
|
||||||
|
pub struct ImportWalletResponse {
|
||||||
|
/// Primary address derived from the wallet
|
||||||
|
pub address: String,
|
||||||
|
|
||||||
|
/// Public key (hex encoded)
|
||||||
|
pub public_key: String,
|
||||||
|
|
||||||
|
/// Network the wallet is configured for
|
||||||
|
pub network: String,
|
||||||
|
|
||||||
|
/// Validation status
|
||||||
|
pub valid: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Query params for address derivation.
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct AddressQuery {
|
||||||
|
/// Account index (default: 0)
|
||||||
|
#[serde(default)]
|
||||||
|
pub account: u32,
|
||||||
|
|
||||||
|
/// Address index (default: 0)
|
||||||
|
#[serde(default)]
|
||||||
|
pub index: u32,
|
||||||
|
|
||||||
|
/// External (0) or internal/change (1)
|
||||||
|
#[serde(default)]
|
||||||
|
pub change: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Balance response.
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
|
||||||
|
pub struct BalanceResponse {
|
||||||
|
/// Address queried
|
||||||
|
pub address: String,
|
||||||
|
|
||||||
|
/// Total balance (including unconfirmed)
|
||||||
|
pub total: String,
|
||||||
|
|
||||||
|
/// Confirmed balance
|
||||||
|
pub confirmed: String,
|
||||||
|
|
||||||
|
/// Unconfirmed balance
|
||||||
|
pub unconfirmed: String,
|
||||||
|
|
||||||
|
/// Balance in smallest unit
|
||||||
|
pub balance_raw: String,
|
||||||
|
|
||||||
|
/// Human-readable balance
|
||||||
|
pub balance_formatted: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Request for multiple balances.
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct GetBalancesRequest {
|
||||||
|
/// List of addresses to query
|
||||||
|
pub addresses: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// UTXO (Unspent Transaction Output).
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
|
||||||
|
pub struct Utxo {
|
||||||
|
/// Transaction ID
|
||||||
|
pub txid: String,
|
||||||
|
|
||||||
|
/// Output index
|
||||||
|
pub vout: u32,
|
||||||
|
|
||||||
|
/// Value in smallest unit
|
||||||
|
pub value: String,
|
||||||
|
|
||||||
|
/// Block height (None if unconfirmed)
|
||||||
|
pub height: Option<u64>,
|
||||||
|
|
||||||
|
/// Number of confirmations
|
||||||
|
pub confirmations: u64,
|
||||||
|
|
||||||
|
/// Script pubkey (hex)
|
||||||
|
pub script_pubkey: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Request to sign a transaction.
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct SignTransactionRequest {
|
||||||
|
/// Raw transaction to sign (hex or JSON)
|
||||||
|
pub transaction: serde_json::Value,
|
||||||
|
|
||||||
|
/// Derivation path or address to sign with
|
||||||
|
pub signer: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Response from signing.
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct SignTransactionResponse {
|
||||||
|
/// Signed transaction (hex)
|
||||||
|
pub signed_tx: String,
|
||||||
|
|
||||||
|
/// Transaction ID
|
||||||
|
pub txid: String,
|
||||||
|
|
||||||
|
/// Signature (hex)
|
||||||
|
pub signature: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Request to sign a message.
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct SignMessageRequest {
|
||||||
|
/// Message to sign
|
||||||
|
pub message: String,
|
||||||
|
|
||||||
|
/// Address to sign with
|
||||||
|
pub address: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Response from message signing.
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct SignMessageResponse {
|
||||||
|
/// Original message
|
||||||
|
pub message: String,
|
||||||
|
|
||||||
|
/// Signature (hex)
|
||||||
|
pub signature: String,
|
||||||
|
|
||||||
|
/// Public key used
|
||||||
|
pub public_key: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Request to send a transaction.
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct SendTransactionRequest {
|
||||||
|
/// Recipient address
|
||||||
|
pub to: String,
|
||||||
|
|
||||||
|
/// Amount to send
|
||||||
|
pub amount: String,
|
||||||
|
|
||||||
|
/// Optional memo/note
|
||||||
|
#[serde(default)]
|
||||||
|
pub memo: Option<String>,
|
||||||
|
|
||||||
|
/// Fee priority (low, medium, high)
|
||||||
|
#[serde(default = "default_priority")]
|
||||||
|
pub priority: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_priority() -> String {
|
||||||
|
"medium".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Response from sending.
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct SendTransactionResponse {
|
||||||
|
/// Transaction ID
|
||||||
|
pub txid: String,
|
||||||
|
|
||||||
|
/// Amount sent
|
||||||
|
pub amount: String,
|
||||||
|
|
||||||
|
/// Fee paid
|
||||||
|
pub fee: String,
|
||||||
|
|
||||||
|
/// Transaction status
|
||||||
|
pub status: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Route Handlers
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// Create a new wallet with a generated mnemonic.
|
||||||
|
#[cfg_attr(feature = "openapi", utoipa::path(
|
||||||
|
post,
|
||||||
|
path = "/v1/wallet/create",
|
||||||
|
request_body = CreateWalletRequest,
|
||||||
|
responses(
|
||||||
|
(status = 201, description = "Wallet created successfully", body = ApiResponse<CreateWalletResponse>),
|
||||||
|
(status = 400, description = "Invalid request"),
|
||||||
|
(status = 401, description = "Authentication required")
|
||||||
|
),
|
||||||
|
security(("api_key" = [])),
|
||||||
|
tag = "Wallet"
|
||||||
|
))]
|
||||||
|
async fn create_wallet(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Authenticated(auth): Authenticated,
|
||||||
|
Json(req): Json<CreateWalletRequest>,
|
||||||
|
) -> ApiResult<Json<ApiResponse<CreateWalletResponse>>> {
|
||||||
|
require_permission(&auth, "write")?;
|
||||||
|
|
||||||
|
// Validate word count
|
||||||
|
if ![12, 15, 18, 21, 24].contains(&req.word_count) {
|
||||||
|
return Err(ApiError::ValidationError(
|
||||||
|
"word_count must be 12, 15, 18, 21, or 24".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// In production, this would call the crypto service
|
||||||
|
// For now, return a placeholder
|
||||||
|
let response = CreateWalletResponse {
|
||||||
|
mnemonic: "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about".to_string(),
|
||||||
|
address: "synor1abc123...".to_string(),
|
||||||
|
public_key: "0x04...".to_string(),
|
||||||
|
network: req.network,
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Json(ApiResponse::success(response)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Import a wallet from mnemonic.
|
||||||
|
#[cfg_attr(feature = "openapi", utoipa::path(
|
||||||
|
post,
|
||||||
|
path = "/v1/wallet/import",
|
||||||
|
request_body = ImportWalletRequest,
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "Wallet imported successfully", body = ApiResponse<ImportWalletResponse>),
|
||||||
|
(status = 400, description = "Invalid mnemonic"),
|
||||||
|
(status = 401, description = "Authentication required")
|
||||||
|
),
|
||||||
|
security(("api_key" = [])),
|
||||||
|
tag = "Wallet"
|
||||||
|
))]
|
||||||
|
async fn import_wallet(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Authenticated(auth): Authenticated,
|
||||||
|
Json(req): Json<ImportWalletRequest>,
|
||||||
|
) -> ApiResult<Json<ApiResponse<ImportWalletResponse>>> {
|
||||||
|
require_permission(&auth, "write")?;
|
||||||
|
|
||||||
|
// Validate mnemonic word count
|
||||||
|
let word_count = req.mnemonic.split_whitespace().count();
|
||||||
|
if ![12, 15, 18, 21, 24].contains(&word_count) {
|
||||||
|
return Err(ApiError::ValidationError(
|
||||||
|
"Invalid mnemonic: must be 12, 15, 18, 21, or 24 words".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// In production, validate mnemonic checksum and derive keys
|
||||||
|
let response = ImportWalletResponse {
|
||||||
|
address: "synor1xyz789...".to_string(),
|
||||||
|
public_key: "0x04...".to_string(),
|
||||||
|
network: req.network,
|
||||||
|
valid: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Json(ApiResponse::success(response)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Export mnemonic for an address (requires admin permission).
|
||||||
|
async fn export_mnemonic(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Authenticated(auth): Authenticated,
|
||||||
|
Path(address): Path<String>,
|
||||||
|
) -> ApiResult<Json<ApiResponse<serde_json::Value>>> {
|
||||||
|
require_permission(&auth, "admin")?;
|
||||||
|
|
||||||
|
// This is a sensitive operation that requires additional security
|
||||||
|
Err(ApiError::Forbidden(
|
||||||
|
"Mnemonic export requires additional verification".to_string(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get address at derivation path.
|
||||||
|
async fn get_address(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Authenticated(auth): Authenticated,
|
||||||
|
Query(query): Query<AddressQuery>,
|
||||||
|
) -> ApiResult<Json<ApiResponse<serde_json::Value>>> {
|
||||||
|
require_permission(&auth, "read")?;
|
||||||
|
|
||||||
|
let address = serde_json::json!({
|
||||||
|
"address": "synor1derived...",
|
||||||
|
"path": format!("m/44'/21337'/{}'/{}'/{}",
|
||||||
|
query.account, query.change, query.index),
|
||||||
|
"public_key": "0x04..."
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(Json(ApiResponse::success(address)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List all derived addresses.
|
||||||
|
async fn list_addresses(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Authenticated(auth): Authenticated,
|
||||||
|
Query(pagination): Query<PaginationParams>,
|
||||||
|
) -> ApiResult<Json<ApiResponse<Vec<serde_json::Value>>>> {
|
||||||
|
require_permission(&auth, "read")?;
|
||||||
|
|
||||||
|
let addresses = vec![
|
||||||
|
serde_json::json!({
|
||||||
|
"address": "synor1addr1...",
|
||||||
|
"index": 0,
|
||||||
|
"balance": "100.00"
|
||||||
|
}),
|
||||||
|
serde_json::json!({
|
||||||
|
"address": "synor1addr2...",
|
||||||
|
"index": 1,
|
||||||
|
"balance": "50.00"
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
let pagination_meta = pagination.to_meta(addresses.len() as u64);
|
||||||
|
Ok(Json(ApiResponse::success_paginated(addresses, pagination_meta)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generate a stealth address.
|
||||||
|
async fn generate_stealth_address(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Authenticated(auth): Authenticated,
|
||||||
|
) -> ApiResult<Json<ApiResponse<serde_json::Value>>> {
|
||||||
|
require_permission(&auth, "write")?;
|
||||||
|
|
||||||
|
let stealth = serde_json::json!({
|
||||||
|
"stealth_address": "synor1stealth...",
|
||||||
|
"scan_key": "0x...",
|
||||||
|
"spend_key": "0x..."
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(Json(ApiResponse::success(stealth)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get balance for an address.
|
||||||
|
#[cfg_attr(feature = "openapi", utoipa::path(
|
||||||
|
get,
|
||||||
|
path = "/v1/wallet/balance/{address}",
|
||||||
|
params(
|
||||||
|
("address" = String, Path, description = "Wallet address")
|
||||||
|
),
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "Balance retrieved", body = ApiResponse<BalanceResponse>),
|
||||||
|
(status = 400, description = "Invalid address"),
|
||||||
|
(status = 404, description = "Address not found")
|
||||||
|
),
|
||||||
|
security(("api_key" = [])),
|
||||||
|
tag = "Wallet"
|
||||||
|
))]
|
||||||
|
async fn get_balance(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Authenticated(auth): Authenticated,
|
||||||
|
Path(address): Path<String>,
|
||||||
|
) -> ApiResult<Json<ApiResponse<BalanceResponse>>> {
|
||||||
|
require_permission(&auth, "read")?;
|
||||||
|
|
||||||
|
// Validate address format
|
||||||
|
if !address.starts_with("synor1") {
|
||||||
|
return Err(ApiError::InvalidAddress(address));
|
||||||
|
}
|
||||||
|
|
||||||
|
// In production, query the RPC service
|
||||||
|
let balance = BalanceResponse {
|
||||||
|
address,
|
||||||
|
total: "150.50".to_string(),
|
||||||
|
confirmed: "150.00".to_string(),
|
||||||
|
unconfirmed: "0.50".to_string(),
|
||||||
|
balance_raw: "150500000000".to_string(),
|
||||||
|
balance_formatted: "150.50 SYNOR".to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Json(ApiResponse::success(balance)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get balances for multiple addresses.
|
||||||
|
async fn get_balances(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Authenticated(auth): Authenticated,
|
||||||
|
Json(req): Json<GetBalancesRequest>,
|
||||||
|
) -> ApiResult<Json<ApiResponse<Vec<BalanceResponse>>>> {
|
||||||
|
require_permission(&auth, "read")?;
|
||||||
|
|
||||||
|
if req.addresses.is_empty() {
|
||||||
|
return Err(ApiError::ValidationError("addresses cannot be empty".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.addresses.len() > 100 {
|
||||||
|
return Err(ApiError::ValidationError(
|
||||||
|
"Maximum 100 addresses per request".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// In production, batch query the RPC service
|
||||||
|
let balances: Vec<BalanceResponse> = req
|
||||||
|
.addresses
|
||||||
|
.iter()
|
||||||
|
.map(|addr| BalanceResponse {
|
||||||
|
address: addr.clone(),
|
||||||
|
total: "100.00".to_string(),
|
||||||
|
confirmed: "100.00".to_string(),
|
||||||
|
unconfirmed: "0.00".to_string(),
|
||||||
|
balance_raw: "100000000000".to_string(),
|
||||||
|
balance_formatted: "100.00 SYNOR".to_string(),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(Json(ApiResponse::success(balances)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get UTXOs for an address.
|
||||||
|
async fn get_utxos(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Authenticated(auth): Authenticated,
|
||||||
|
Path(address): Path<String>,
|
||||||
|
Query(pagination): Query<PaginationParams>,
|
||||||
|
) -> ApiResult<Json<ApiResponse<Vec<Utxo>>>> {
|
||||||
|
require_permission(&auth, "read")?;
|
||||||
|
|
||||||
|
// In production, query the RPC service
|
||||||
|
let utxos = vec![
|
||||||
|
Utxo {
|
||||||
|
txid: "abc123...".to_string(),
|
||||||
|
vout: 0,
|
||||||
|
value: "50000000000".to_string(),
|
||||||
|
height: Some(100000),
|
||||||
|
confirmations: 100,
|
||||||
|
script_pubkey: "76a914...".to_string(),
|
||||||
|
},
|
||||||
|
Utxo {
|
||||||
|
txid: "def456...".to_string(),
|
||||||
|
vout: 1,
|
||||||
|
value: "100500000000".to_string(),
|
||||||
|
height: Some(100050),
|
||||||
|
confirmations: 50,
|
||||||
|
script_pubkey: "76a914...".to_string(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
let pagination_meta = pagination.to_meta(utxos.len() as u64);
|
||||||
|
Ok(Json(ApiResponse::success_paginated(utxos, pagination_meta)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sign a transaction.
|
||||||
|
async fn sign_transaction(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Authenticated(auth): Authenticated,
|
||||||
|
Json(req): Json<SignTransactionRequest>,
|
||||||
|
) -> ApiResult<Json<ApiResponse<SignTransactionResponse>>> {
|
||||||
|
require_permission(&auth, "write")?;
|
||||||
|
|
||||||
|
// In production, this would use the crypto service
|
||||||
|
let response = SignTransactionResponse {
|
||||||
|
signed_tx: "0x...signed...".to_string(),
|
||||||
|
txid: "txid123...".to_string(),
|
||||||
|
signature: "0x...signature...".to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Json(ApiResponse::success(response)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sign a message.
|
||||||
|
async fn sign_message(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Authenticated(auth): Authenticated,
|
||||||
|
Json(req): Json<SignMessageRequest>,
|
||||||
|
) -> ApiResult<Json<ApiResponse<SignMessageResponse>>> {
|
||||||
|
require_permission(&auth, "write")?;
|
||||||
|
|
||||||
|
let response = SignMessageResponse {
|
||||||
|
message: req.message,
|
||||||
|
signature: "0x...message_signature...".to_string(),
|
||||||
|
public_key: "0x04...".to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Json(ApiResponse::success(response)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send a transaction.
|
||||||
|
async fn send_transaction(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Authenticated(auth): Authenticated,
|
||||||
|
Json(req): Json<SendTransactionRequest>,
|
||||||
|
) -> ApiResult<Json<ApiResponse<SendTransactionResponse>>> {
|
||||||
|
require_permission(&auth, "write")?;
|
||||||
|
|
||||||
|
// Validate recipient address
|
||||||
|
if !req.to.starts_with("synor1") {
|
||||||
|
return Err(ApiError::InvalidAddress(req.to));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate amount
|
||||||
|
let amount: f64 = req.amount.parse().map_err(|_| {
|
||||||
|
ApiError::ValidationError("Invalid amount format".to_string())
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if amount <= 0.0 {
|
||||||
|
return Err(ApiError::ValidationError("Amount must be positive".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// In production, build, sign, and broadcast the transaction
|
||||||
|
let response = SendTransactionResponse {
|
||||||
|
txid: "newtxid789...".to_string(),
|
||||||
|
amount: req.amount,
|
||||||
|
fee: "0.001".to_string(),
|
||||||
|
status: "pending".to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Json(ApiResponse::success(response)))
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue