use axum::{
extract::State,
http::StatusCode,
response::{IntoResponse, Response},
Json,
};
use chrono::Utc;
use serde::{Deserialize, Serialize};
use tracing::{info, warn};
#[cfg(feature = "openapi")]
use utoipa::ToSchema;
use crate::server::api_error::{ApiError, ErrorCode};
use crate::server::database::{BindingAction, PerformedBy};
use crate::server::handlers::AppState;
use crate::server::logging::{log_license_binding_event, log_license_event, LicenseEvent};
use crate::tiers::get_tier_config;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum ClientErrorCode {
LicenseNotFound,
AlreadyBound,
NotBound,
HardwareMismatch,
LicenseExpired,
LicenseRevoked,
LicenseSuspended,
LicenseBlacklisted,
LicenseInactive,
FeatureNotIncluded,
QuotaExceeded,
InvalidRequest,
InternalError,
}
#[derive(Debug, Serialize)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct ClientError {
pub success: bool,
pub error: ClientErrorCode,
pub message: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub bound_device: Option<String>,
}
impl ClientError {
pub fn new(code: ClientErrorCode, message: impl Into<String>) -> Self {
Self {
success: false,
error: code,
message: message.into(),
bound_device: None,
}
}
pub fn with_bound_device(mut self, device: Option<String>) -> Self {
self.bound_device = device;
self
}
pub fn status_code(&self) -> StatusCode {
match self.error {
ClientErrorCode::LicenseNotFound => StatusCode::NOT_FOUND,
ClientErrorCode::AlreadyBound => StatusCode::CONFLICT,
ClientErrorCode::NotBound => StatusCode::CONFLICT,
ClientErrorCode::HardwareMismatch => StatusCode::FORBIDDEN,
ClientErrorCode::LicenseExpired => StatusCode::FORBIDDEN,
ClientErrorCode::LicenseRevoked => StatusCode::FORBIDDEN,
ClientErrorCode::LicenseSuspended => StatusCode::FORBIDDEN,
ClientErrorCode::LicenseBlacklisted => StatusCode::FORBIDDEN,
ClientErrorCode::LicenseInactive => StatusCode::FORBIDDEN,
ClientErrorCode::FeatureNotIncluded => StatusCode::FORBIDDEN,
ClientErrorCode::QuotaExceeded => StatusCode::FORBIDDEN,
ClientErrorCode::InvalidRequest => StatusCode::BAD_REQUEST,
ClientErrorCode::InternalError => StatusCode::INTERNAL_SERVER_ERROR,
}
}
}
impl IntoResponse for ClientError {
fn into_response(self) -> Response {
let api_error: ApiError = self.into();
api_error.into_response()
}
}
impl From<ClientErrorCode> for ErrorCode {
fn from(code: ClientErrorCode) -> Self {
match code {
ClientErrorCode::LicenseNotFound => ErrorCode::LicenseNotFound,
ClientErrorCode::AlreadyBound => ErrorCode::AlreadyBound,
ClientErrorCode::NotBound => ErrorCode::NotBound,
ClientErrorCode::HardwareMismatch => ErrorCode::HardwareMismatch,
ClientErrorCode::LicenseExpired => ErrorCode::LicenseExpired,
ClientErrorCode::LicenseRevoked => ErrorCode::LicenseRevoked,
ClientErrorCode::LicenseSuspended => ErrorCode::LicenseSuspended,
ClientErrorCode::LicenseBlacklisted => ErrorCode::LicenseBlacklisted,
ClientErrorCode::LicenseInactive => ErrorCode::LicenseInactive,
ClientErrorCode::FeatureNotIncluded => ErrorCode::FeatureNotIncluded,
ClientErrorCode::QuotaExceeded => ErrorCode::QuotaExceeded,
ClientErrorCode::InvalidRequest => ErrorCode::InvalidRequest,
ClientErrorCode::InternalError => ErrorCode::InternalError,
}
}
}
impl From<ClientError> for ApiError {
fn from(err: ClientError) -> Self {
let code: ErrorCode = err.error.into();
if let Some(device) = err.bound_device {
ApiError::with_details(
code,
err.message,
serde_json::json!({ "bound_device": device }),
)
} else {
ApiError::with_message(code, err.message)
}
}
}
#[derive(Debug, Deserialize)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct BindRequest {
pub license_key: String,
pub hardware_id: String,
#[serde(default)]
pub device_name: Option<String>,
#[serde(default)]
pub device_info: Option<String>,
}
#[derive(Debug, Serialize)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct BindResponse {
pub success: bool,
pub license_id: String,
pub features: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tier: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub expires_at: Option<String>,
}
#[derive(Debug, Deserialize)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct ReleaseRequest {
pub license_key: String,
pub hardware_id: String,
}
#[derive(Debug, Serialize)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct ReleaseResponse {
pub success: bool,
pub message: String,
}
#[derive(Debug, Deserialize)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct ValidateRequest {
pub license_key: String,
pub hardware_id: String,
}
#[derive(Debug, Serialize)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct ValidateResponse {
pub valid: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub license_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub features: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tier: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub expires_at: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub grace_period_ends_at: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub warning: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub org_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub org_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub bandwidth_used_bytes: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub bandwidth_limit_bytes: Option<i64>,
}
#[derive(Debug, Deserialize)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct ValidateOrBindRequest {
pub license_key: String,
pub hardware_id: String,
#[serde(default)]
pub device_name: Option<String>,
#[serde(default)]
pub device_info: Option<String>,
}
#[derive(Debug, Deserialize)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct ClientHeartbeatRequest {
pub license_key: String,
pub hardware_id: String,
}
#[derive(Debug, Serialize)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct ClientHeartbeatResponse {
pub success: bool,
pub server_time: String,
}
#[derive(Debug, Deserialize)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct ValidateFeatureRequest {
pub license_key: String,
pub hardware_id: String,
pub feature: String,
}
#[derive(Debug, Serialize)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct ValidateFeatureResponse {
pub allowed: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub message: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tier: Option<String>,
}
#[cfg_attr(feature = "openapi", utoipa::path(
post,
path = "/api/v1/client/bind",
tag = "client",
request_body = BindRequest,
responses(
(status = 200, description = "License bound successfully", body = BindResponse),
(status = 400, description = "Invalid request", body = ClientError),
(status = 404, description = "License not found", body = ClientError),
(status = 409, description = "License already bound to different device", body = ClientError),
)
))]
pub async fn bind_handler(
State(state): State<AppState>,
Json(req): Json<BindRequest>,
) -> Result<Json<BindResponse>, ClientError> {
info!("Bind request for license_key={}", req.license_key);
let license = state
.db
.get_license_by_key(&req.license_key)
.await
.map_err(|e| {
warn!("Database error: {}", e);
ClientError::new(ClientErrorCode::InternalError, "Database error")
})?
.ok_or_else(|| {
warn!("License not found: {}", req.license_key);
ClientError::new(ClientErrorCode::LicenseNotFound, "License key not found")
})?;
if license.is_blacklisted == Some(true) {
return Err(ClientError::new(
ClientErrorCode::LicenseBlacklisted,
"License is blacklisted",
));
}
if license.status == "revoked" {
return Err(ClientError::new(
ClientErrorCode::LicenseRevoked,
"License has been revoked",
));
}
if license.status == "suspended" && !license.is_in_grace_period() {
return Err(ClientError::new(
ClientErrorCode::LicenseSuspended,
"License is suspended",
));
}
if license.status != "active" && license.status != "suspended" {
return Err(ClientError::new(
ClientErrorCode::LicenseInactive,
format!("License status is '{}'", license.status),
));
}
if license.is_expired() {
return Err(ClientError::new(
ClientErrorCode::LicenseExpired,
"License has expired",
));
}
if license.is_bound() {
if license.hardware_id.as_deref() == Some(&req.hardware_id) {
info!("License {} already bound to this hardware", req.license_key);
return Ok(Json(BindResponse {
success: true,
license_id: license.license_id,
features: parse_features(&license.features),
tier: license.tier,
expires_at: license.expires_at.map(|d| d.to_string()),
}));
} else {
return Err(ClientError::new(
ClientErrorCode::AlreadyBound,
"License is already bound to a different device",
)
.with_bound_device(license.device_name));
}
}
state
.db
.bind_license(
&license.license_id,
&req.hardware_id,
req.device_name.as_deref(),
req.device_info.as_deref(),
)
.await
.map_err(|e| {
warn!("Failed to bind license: {}", e);
ClientError::new(ClientErrorCode::InternalError, "Failed to bind license")
})?;
let _ = state
.db
.record_binding_history(
&license.license_id,
BindingAction::Bind,
Some(&req.hardware_id),
req.device_name.as_deref(),
req.device_info.as_deref(),
PerformedBy::Client,
None,
)
.await;
log_license_binding_event(
LicenseEvent::Bound,
&req.license_key,
&req.hardware_id,
req.device_name.as_deref(),
);
Ok(Json(BindResponse {
success: true,
license_id: license.license_id,
features: parse_features(&license.features),
tier: license.tier,
expires_at: license.expires_at.map(|d| d.to_string()),
}))
}
#[cfg_attr(feature = "openapi", utoipa::path(
post,
path = "/api/v1/client/release",
tag = "client",
request_body = ReleaseRequest,
responses(
(status = 200, description = "License released successfully", body = ReleaseResponse),
(status = 403, description = "Hardware mismatch", body = ClientError),
(status = 404, description = "License not found", body = ClientError),
(status = 409, description = "License not bound", body = ClientError),
)
))]
pub async fn release_handler(
State(state): State<AppState>,
Json(req): Json<ReleaseRequest>,
) -> Result<Json<ReleaseResponse>, ClientError> {
info!("Release request for license_key={}", req.license_key);
let license = state
.db
.get_license_by_key(&req.license_key)
.await
.map_err(|e| {
warn!("Database error: {}", e);
ClientError::new(ClientErrorCode::InternalError, "Database error")
})?
.ok_or_else(|| {
warn!("License not found: {}", req.license_key);
ClientError::new(ClientErrorCode::LicenseNotFound, "License key not found")
})?;
if !license.is_bound() {
return Err(ClientError::new(
ClientErrorCode::NotBound,
"License is not currently bound",
));
}
if license.hardware_id.as_deref() != Some(&req.hardware_id) {
return Err(ClientError::new(
ClientErrorCode::HardwareMismatch,
"Hardware ID does not match the bound device",
));
}
state
.db
.release_license(&license.license_id)
.await
.map_err(|e| {
warn!("Failed to release license: {}", e);
ClientError::new(ClientErrorCode::InternalError, "Failed to release license")
})?;
let _ = state
.db
.record_binding_history(
&license.license_id,
BindingAction::Release,
Some(&req.hardware_id),
license.device_name.as_deref(),
license.device_info.as_deref(),
PerformedBy::Client,
None,
)
.await;
log_license_binding_event(
LicenseEvent::Released,
&req.license_key,
&req.hardware_id,
license.device_name.as_deref(),
);
Ok(Json(ReleaseResponse {
success: true,
message: "License released successfully".to_string(),
}))
}
#[cfg_attr(feature = "openapi", utoipa::path(
post,
path = "/api/v1/client/validate",
tag = "client",
request_body = ValidateRequest,
responses(
(status = 200, description = "License validated successfully", body = ValidateResponse),
(status = 403, description = "License expired, revoked, or hardware mismatch", body = ClientError),
(status = 404, description = "License not found", body = ClientError),
(status = 409, description = "License not bound", body = ClientError),
)
))]
pub async fn validate_handler(
State(state): State<AppState>,
Json(req): Json<ValidateRequest>,
) -> Result<Json<ValidateResponse>, ClientError> {
info!("Validate request for license_key={}", req.license_key);
let license = state
.db
.get_license_by_key(&req.license_key)
.await
.map_err(|e| {
warn!("Database error: {}", e);
ClientError::new(ClientErrorCode::InternalError, "Database error")
})?
.ok_or_else(|| {
warn!("License not found: {}", req.license_key);
ClientError::new(ClientErrorCode::LicenseNotFound, "License key not found")
})?;
if license.is_blacklisted == Some(true) {
return Err(ClientError::new(
ClientErrorCode::LicenseBlacklisted,
"License is blacklisted",
));
}
if license.status == "revoked" {
return Err(ClientError::new(
ClientErrorCode::LicenseRevoked,
"License has been revoked",
));
}
if license.is_expired() {
return Err(ClientError::new(
ClientErrorCode::LicenseExpired,
"License has expired",
));
}
if !license.is_bound() {
return Err(ClientError::new(
ClientErrorCode::NotBound,
"License is not bound to any device",
));
}
if license.hardware_id.as_deref() != Some(&req.hardware_id) {
return Err(ClientError::new(
ClientErrorCode::HardwareMismatch,
"Hardware ID does not match the bound device",
));
}
let _ = state.db.update_last_seen(&license.license_id).await;
let (grace_period_ends, warning_msg) = if license.status == "suspended" {
if license.is_in_grace_period() {
(
license
.grace_period_ends_at
.map(|d| d.and_utc().to_rfc3339()),
Some(
license
.suspension_message
.clone()
.unwrap_or_else(|| "License is in grace period".to_string()),
),
)
} else {
return Err(ClientError::new(
ClientErrorCode::LicenseSuspended,
"License is suspended and grace period has ended",
));
}
} else {
(None, None)
};
if license.status != "active" && license.status != "suspended" {
return Err(ClientError::new(
ClientErrorCode::LicenseInactive,
format!("License status is '{}'", license.status),
));
}
let effective_org_id = license
.org_id
.clone()
.unwrap_or_else(|| license.license_id.clone());
let effective_org_name = license
.org_name
.clone()
.unwrap_or_else(|| effective_org_id.clone());
let response = ValidateResponse {
valid: true,
license_id: Some(license.license_id),
features: Some(parse_features(&license.features)),
tier: license.tier,
expires_at: license.expires_at.map(|d| d.and_utc().to_rfc3339()),
grace_period_ends_at: grace_period_ends,
warning: warning_msg,
org_id: Some(effective_org_id),
org_name: Some(effective_org_name),
bandwidth_used_bytes: license.bandwidth_used_bytes,
bandwidth_limit_bytes: license.bandwidth_limit_bytes,
};
log_license_event(LicenseEvent::Validated, &req.license_key, None);
Ok(Json(response))
}
#[cfg_attr(feature = "openapi", utoipa::path(
post,
path = "/api/v1/client/validate-or-bind",
tag = "client",
request_body = ValidateOrBindRequest,
responses(
(status = 200, description = "License validated (and bound if needed)", body = ValidateResponse),
(status = 403, description = "License expired, revoked, or invalid", body = ClientError),
(status = 404, description = "License not found", body = ClientError),
(status = 409, description = "License already bound to different device", body = ClientError),
)
))]
pub async fn validate_or_bind_handler(
State(state): State<AppState>,
Json(req): Json<ValidateOrBindRequest>,
) -> Result<Json<ValidateResponse>, ClientError> {
info!(
"Validate-or-bind request for license_key={}",
req.license_key
);
let license = state
.db
.get_license_by_key(&req.license_key)
.await
.map_err(|e| {
warn!("Database error: {}", e);
ClientError::new(ClientErrorCode::InternalError, "Database error")
})?
.ok_or_else(|| {
warn!("License not found: {}", req.license_key);
ClientError::new(ClientErrorCode::LicenseNotFound, "License key not found")
})?;
if license.is_blacklisted == Some(true) {
return Err(ClientError::new(
ClientErrorCode::LicenseBlacklisted,
"License is blacklisted",
));
}
if license.status == "revoked" {
return Err(ClientError::new(
ClientErrorCode::LicenseRevoked,
"License has been revoked",
));
}
if license.is_expired() {
return Err(ClientError::new(
ClientErrorCode::LicenseExpired,
"License has expired",
));
}
if license.status == "suspended" && !license.is_in_grace_period() {
return Err(ClientError::new(
ClientErrorCode::LicenseSuspended,
"License is suspended",
));
}
if license.status != "active" && license.status != "suspended" {
return Err(ClientError::new(
ClientErrorCode::LicenseInactive,
format!("License status is '{}'", license.status),
));
}
if license.is_bound() {
if license.hardware_id.as_deref() != Some(&req.hardware_id) {
return Err(ClientError::new(
ClientErrorCode::AlreadyBound,
"License is already bound to a different device",
)
.with_bound_device(license.device_name));
}
} else {
state
.db
.bind_license(
&license.license_id,
&req.hardware_id,
req.device_name.as_deref(),
req.device_info.as_deref(),
)
.await
.map_err(|e| {
warn!("Failed to bind license: {}", e);
ClientError::new(ClientErrorCode::InternalError, "Failed to bind license")
})?;
let _ = state
.db
.record_binding_history(
&license.license_id,
BindingAction::Bind,
Some(&req.hardware_id),
req.device_name.as_deref(),
req.device_info.as_deref(),
PerformedBy::Client,
None,
)
.await;
log_license_binding_event(
LicenseEvent::Bound,
&req.license_key,
&req.hardware_id,
req.device_name.as_deref(),
);
}
let _ = state.db.update_last_seen(&license.license_id).await;
let (grace_period_ends, warning_msg) =
if license.status == "suspended" && license.is_in_grace_period() {
(
license
.grace_period_ends_at
.map(|d| d.and_utc().to_rfc3339()),
Some(
license
.suspension_message
.clone()
.unwrap_or_else(|| "License is in grace period".to_string()),
),
)
} else {
(None, None)
};
let effective_org_id = license
.org_id
.clone()
.unwrap_or_else(|| license.license_id.clone());
let effective_org_name = license
.org_name
.clone()
.unwrap_or_else(|| effective_org_id.clone());
let response = ValidateResponse {
valid: true,
license_id: Some(license.license_id),
features: Some(parse_features(&license.features)),
tier: license.tier,
expires_at: license.expires_at.map(|d| d.and_utc().to_rfc3339()),
grace_period_ends_at: grace_period_ends,
warning: warning_msg,
org_id: Some(effective_org_id),
org_name: Some(effective_org_name),
bandwidth_used_bytes: license.bandwidth_used_bytes,
bandwidth_limit_bytes: license.bandwidth_limit_bytes,
};
log_license_event(LicenseEvent::Validated, &req.license_key, None);
Ok(Json(response))
}
#[cfg_attr(feature = "openapi", utoipa::path(
post,
path = "/api/v1/client/heartbeat",
tag = "client",
request_body = ClientHeartbeatRequest,
responses(
(status = 200, description = "Heartbeat recorded", body = ClientHeartbeatResponse),
(status = 403, description = "Hardware mismatch", body = ClientError),
(status = 404, description = "License not found", body = ClientError),
(status = 409, description = "License not bound", body = ClientError),
)
))]
pub async fn client_heartbeat_handler(
State(state): State<AppState>,
Json(req): Json<ClientHeartbeatRequest>,
) -> Result<Json<ClientHeartbeatResponse>, ClientError> {
info!("Heartbeat for license_key={}", req.license_key);
let license = state
.db
.get_license_by_key(&req.license_key)
.await
.map_err(|e| {
warn!("Database error: {}", e);
ClientError::new(ClientErrorCode::InternalError, "Database error")
})?
.ok_or_else(|| {
warn!("License not found: {}", req.license_key);
ClientError::new(ClientErrorCode::LicenseNotFound, "License key not found")
})?;
if !license.is_bound() {
return Err(ClientError::new(
ClientErrorCode::NotBound,
"License is not bound to any device",
));
}
if license.hardware_id.as_deref() != Some(&req.hardware_id) {
return Err(ClientError::new(
ClientErrorCode::HardwareMismatch,
"Hardware ID does not match the bound device",
));
}
state
.db
.update_last_seen(&license.license_id)
.await
.map_err(|e| {
warn!("Failed to update last_seen: {}", e);
ClientError::new(ClientErrorCode::InternalError, "Failed to update heartbeat")
})?;
log_license_event(LicenseEvent::Heartbeat, &req.license_key, None);
Ok(Json(ClientHeartbeatResponse {
success: true,
server_time: Utc::now().to_rfc3339(),
}))
}
#[cfg_attr(feature = "openapi", utoipa::path(
post,
path = "/api/v1/client/validate-feature",
tag = "client",
request_body = ValidateFeatureRequest,
responses(
(status = 200, description = "Feature validation result", body = ValidateFeatureResponse),
(status = 403, description = "Feature not included or quota exceeded", body = ClientError),
(status = 404, description = "License not found", body = ClientError),
)
))]
pub async fn validate_feature_handler(
State(state): State<AppState>,
Json(req): Json<ValidateFeatureRequest>,
) -> Result<Json<ValidateFeatureResponse>, ClientError> {
info!(
"Validate feature '{}' for license_key={}",
req.feature, req.license_key
);
let license = state
.db
.get_license_by_key(&req.license_key)
.await
.map_err(|e| {
warn!("Database error: {}", e);
ClientError::new(ClientErrorCode::InternalError, "Database error")
})?
.ok_or_else(|| {
warn!("License not found: {}", req.license_key);
ClientError::new(ClientErrorCode::LicenseNotFound, "License key not found")
})?;
if license.is_blacklisted == Some(true) {
return Err(ClientError::new(
ClientErrorCode::LicenseBlacklisted,
"License is blacklisted",
));
}
if license.status == "revoked" {
return Err(ClientError::new(
ClientErrorCode::LicenseRevoked,
"License has been revoked",
));
}
if license.is_expired() {
return Err(ClientError::new(
ClientErrorCode::LicenseExpired,
"License has expired",
));
}
if license.status == "suspended" && !license.is_in_grace_period() {
return Err(ClientError::new(
ClientErrorCode::LicenseSuspended,
"License is suspended and grace period has ended",
));
}
if !license.is_bound() {
return Err(ClientError::new(
ClientErrorCode::NotBound,
"License is not bound to any device",
));
}
if license.hardware_id.as_deref() != Some(&req.hardware_id) {
return Err(ClientError::new(
ClientErrorCode::HardwareMismatch,
"Hardware ID does not match the bound device",
));
}
if license.status != "active" && license.status != "suspended" {
return Err(ClientError::new(
ClientErrorCode::LicenseInactive,
format!("License status is '{}'", license.status),
));
}
let _ = state.db.update_last_seen(&license.license_id).await;
let license_features = parse_features(&license.features);
let tier_features: Vec<String> = license
.tier
.as_ref()
.and_then(|t| get_tier_config(t))
.map(|tier| tier.config.features)
.unwrap_or_default();
let feature_in_license = license_features.iter().any(|f| f == &req.feature);
let feature_in_tier = tier_features.iter().any(|f| f == &req.feature);
if !feature_in_license && !feature_in_tier {
info!(
"Feature '{}' not included for license {}",
req.feature, req.license_key
);
return Err(ClientError::new(
ClientErrorCode::FeatureNotIncluded,
format!(
"Feature '{}' is not included in your license or tier",
req.feature
),
));
}
info!(
"Feature '{}' allowed for license {}",
req.feature, req.license_key
);
Ok(Json(ValidateFeatureResponse {
allowed: true,
message: Some(format!("Feature '{}' is enabled", req.feature)),
tier: license.tier,
}))
}
fn parse_features(features: &Option<String>) -> Vec<String> {
features
.as_ref()
.and_then(|f| serde_json::from_str::<Vec<String>>(f).ok())
.unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn error_code_serialization() {
let err = ClientError::new(ClientErrorCode::LicenseNotFound, "Not found");
let json = serde_json::to_string(&err).unwrap();
assert!(json.contains("LICENSE_NOT_FOUND"));
}
#[test]
fn error_status_codes() {
assert_eq!(
ClientError::new(ClientErrorCode::LicenseNotFound, "").status_code(),
StatusCode::NOT_FOUND
);
assert_eq!(
ClientError::new(ClientErrorCode::AlreadyBound, "").status_code(),
StatusCode::CONFLICT
);
assert_eq!(
ClientError::new(ClientErrorCode::HardwareMismatch, "").status_code(),
StatusCode::FORBIDDEN
);
assert_eq!(
ClientError::new(ClientErrorCode::InvalidRequest, "").status_code(),
StatusCode::BAD_REQUEST
);
}
#[test]
fn parse_features_empty() {
assert_eq!(parse_features(&None), Vec::<String>::new());
assert_eq!(parse_features(&Some("".to_string())), Vec::<String>::new());
}
#[test]
fn parse_features_valid() {
let features = Some(r#"["feature_a", "feature_b"]"#.to_string());
assert_eq!(
parse_features(&features),
vec!["feature_a".to_string(), "feature_b".to_string()]
);
}
#[test]
fn feature_error_codes_serialization() {
let err = ClientError::new(ClientErrorCode::FeatureNotIncluded, "Feature not included");
let json = serde_json::to_string(&err).unwrap();
assert!(json.contains("FEATURE_NOT_INCLUDED"));
let err = ClientError::new(ClientErrorCode::QuotaExceeded, "Quota exceeded");
let json = serde_json::to_string(&err).unwrap();
assert!(json.contains("QUOTA_EXCEEDED"));
}
#[test]
fn feature_error_status_codes() {
assert_eq!(
ClientError::new(ClientErrorCode::FeatureNotIncluded, "").status_code(),
StatusCode::FORBIDDEN
);
assert_eq!(
ClientError::new(ClientErrorCode::QuotaExceeded, "").status_code(),
StatusCode::FORBIDDEN
);
}
}