Skip to main content

harbor_sdk/
lib.rs

1//! harbor-sdk — Rust SDK for Harbor API monetization
2//!
3//! Add API key auth and billing to your Rust API in one line.
4//! See [harbor-black.vercel.app](https://harbor-black.vercel.app) for docs.
5//!
6//! # Quick Start (Axum)
7//!
8//! ```rust,no_run
9//! use axum::{Router, routing::get, Extension};
10//! use harbor_sdk::{HarborLayer, KeyInfo};
11//!
12//! async fn data(Extension(key): Extension<KeyInfo>) -> String {
13//!     format!("Plan: {}", key.plan)
14//! }
15//!
16//! #[tokio::main]
17//! async fn main() {
18//!     let app = Router::new()
19//!         .route("/data", get(data))
20//!         .layer(HarborLayer::new("proj_harbor_xyz"));
21//!     axum::Server::bind(&"0.0.0.0:3000".parse().unwrap())
22//!         .serve(app.into_make_service())
23//!         .await
24//!         .unwrap();
25//! }
26//! ```
27
28use reqwest::Client;
29use serde::{Deserialize, Serialize};
30use std::sync::Arc;
31use thiserror::Error;
32
33pub const DEFAULT_VALIDATE_URL: &str = "https://harbor-black.vercel.app/api/validate";
34
35#[derive(Debug, Error)]
36pub enum HarborError {
37    #[error("Invalid or revoked API key")]
38    InvalidKey,
39    #[error("Missing API key")]
40    MissingKey,
41    #[error("HTTP error: {0}")]
42    Http(#[from] reqwest::Error),
43    #[error("Harbor validation service unavailable")]
44    ServiceUnavailable,
45}
46
47/// Validated API key metadata returned after successful validation.
48#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct KeyInfo {
50    #[serde(rename = "keyId")]
51    pub key_id: String,
52    #[serde(rename = "projectId")]
53    pub project_id: String,
54    pub plan: String,
55    #[serde(rename = "callsThisMonth", default)]
56    pub calls_this_month: u64,
57    pub name: String,
58    pub country: Option<String>,
59}
60
61#[derive(Deserialize)]
62struct ValidateResponse {
63    valid: bool,
64    #[serde(rename = "keyId")]
65    key_id: Option<String>,
66    #[serde(rename = "projectId")]
67    project_id: Option<String>,
68    plan: Option<String>,
69    #[serde(rename = "callsThisMonth")]
70    calls_this_month: Option<u64>,
71    name: Option<String>,
72    country: Option<String>,
73    error: Option<String>,
74}
75
76/// Validate a Harbor API key asynchronously.
77///
78/// # Example
79/// ```rust,no_run
80/// use harbor_sdk::validate;
81///
82/// #[tokio::main]
83/// async fn main() {
84///     match validate("hbr_live_your_key").await {
85///         Ok(info) => println!("Plan: {}", info.plan),
86///         Err(e) => println!("Error: {}", e),
87///     }
88/// }
89/// ```
90pub async fn validate(api_key: &str) -> Result<KeyInfo, HarborError> {
91    validate_with_url(api_key, DEFAULT_VALIDATE_URL).await
92}
93
94/// Validate with a custom URL (for local dev emulator).
95pub async fn validate_with_url(api_key: &str, url: &str) -> Result<KeyInfo, HarborError> {
96    let client = Client::builder()
97        .timeout(std::time::Duration::from_secs(5))
98        .user_agent(format!("harbor-sdk-rust/{}", env!("CARGO_PKG_VERSION")))
99        .build()
100        .map_err(|_| HarborError::ServiceUnavailable)?;
101
102    let response = client
103        .get(url)
104        .query(&[("key", api_key)])
105        .send()
106        .await?;
107
108    let result: ValidateResponse = response.json().await?;
109
110    if !result.valid {
111        return Err(HarborError::InvalidKey);
112    }
113
114    Ok(KeyInfo {
115        key_id: result.key_id.unwrap_or_default(),
116        project_id: result.project_id.unwrap_or_default(),
117        plan: result.plan.unwrap_or_else(|| "free".to_string()),
118        calls_this_month: result.calls_this_month.unwrap_or(0),
119        name: result.name.unwrap_or_default(),
120        country: result.country,
121    })
122}
123
124// ─── Axum Middleware ─────────────────────────────────────────────────────────
125
126#[cfg(feature = "axum")]
127pub mod axum_middleware {
128    use super::*;
129    use axum::{
130        body::Body,
131        extract::Request,
132        http::StatusCode,
133        middleware::Next,
134        response::{IntoResponse, Response},
135        Extension,
136    };
137
138    /// Axum middleware layer for Harbor authentication.
139    ///
140    /// # Example
141    /// ```rust,no_run
142    /// use axum::{Router, routing::get, middleware, Extension};
143    /// use harbor_sdk::{KeyInfo, axum_middleware::harbor_auth};
144    ///
145    /// let app = Router::new()
146    ///     .route("/data", get(handler))
147    ///     .layer(middleware::from_fn_with_state(
148    ///         "proj_harbor_xyz".to_string(),
149    ///         harbor_auth,
150    ///     ));
151    /// ```
152    pub async fn harbor_auth(
153        axum::extract::State(project_id): axum::extract::State<String>,
154        mut req: Request,
155        next: Next,
156    ) -> Response {
157        let api_key = req
158            .headers()
159            .get("x-harbor-key")
160            .and_then(|v| v.to_str().ok())
161            .map(|s| s.to_string());
162
163        let api_key = match api_key {
164            Some(k) => k,
165            None => {
166                return (
167                    StatusCode::UNAUTHORIZED,
168                    r#"{"error":"Missing API key"}"#,
169                )
170                    .into_response()
171            }
172        };
173
174        match validate(&api_key).await {
175            Ok(info) if info.project_id == project_id || project_id.is_empty() => {
176                req.extensions_mut().insert(info);
177                next.run(req).await
178            }
179            Ok(_) => (
180                StatusCode::FORBIDDEN,
181                r#"{"error":"Key does not belong to this project"}"#,
182            )
183                .into_response(),
184            Err(_) => (
185                StatusCode::UNAUTHORIZED,
186                r#"{"error":"Invalid or revoked API key"}"#,
187            )
188                .into_response(),
189        }
190    }
191}
192
193/// Tower middleware layer for easy integration.
194#[derive(Clone)]
195pub struct HarborLayer {
196    project_id: Arc<String>,
197    validate_url: Arc<String>,
198}
199
200impl HarborLayer {
201    pub fn new(project_id: impl Into<String>) -> Self {
202        Self {
203            project_id: Arc::new(project_id.into()),
204            validate_url: Arc::new(DEFAULT_VALIDATE_URL.to_string()),
205        }
206    }
207
208    pub fn with_url(mut self, url: impl Into<String>) -> Self {
209        self.validate_url = Arc::new(url.into());
210        self
211    }
212}