Squashed 'vendor/ruvector/' content from commit b64c2172
git-subtree-dir: vendor/ruvector git-subtree-split: b64c21726f2bb37286d9ee36a7869fef60cc6900
This commit is contained in:
1023
examples/vibecast-7sense/crates/sevensense-api/src/rest/handlers.rs
Normal file
1023
examples/vibecast-7sense/crates/sevensense-api/src/rest/handlers.rs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,266 @@
|
||||
//! REST API middleware for cross-cutting concerns.
|
||||
//!
|
||||
//! This module provides:
|
||||
//! - CORS configuration
|
||||
//! - Rate limiting
|
||||
//! - API key authentication
|
||||
//! - Request logging
|
||||
|
||||
use std::{
|
||||
net::SocketAddr,
|
||||
sync::Arc,
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use axum::{
|
||||
body::Body,
|
||||
extract::{ConnectInfo, State},
|
||||
http::{header, HeaderMap, Request, StatusCode},
|
||||
middleware::Next,
|
||||
response::{IntoResponse, Response},
|
||||
Json,
|
||||
};
|
||||
use governor::{
|
||||
clock::DefaultClock,
|
||||
state::{InMemoryState, NotKeyed},
|
||||
Quota, RateLimiter,
|
||||
};
|
||||
use tower_http::cors::{Any, CorsLayer};
|
||||
|
||||
use crate::{error::ErrorResponse, AppContext, Config};
|
||||
|
||||
/// Create CORS layer based on configuration.
|
||||
pub fn cors_layer(config: &Config) -> CorsLayer {
|
||||
let cors = CorsLayer::new()
|
||||
.allow_methods(Any)
|
||||
.allow_headers(Any)
|
||||
.max_age(Duration::from_secs(3600));
|
||||
|
||||
if config.cors_origins.contains(&"*".to_string()) {
|
||||
cors.allow_origin(Any)
|
||||
} else {
|
||||
// Parse origins - in production, validate these
|
||||
cors.allow_origin(Any) // Simplified for now
|
||||
}
|
||||
}
|
||||
|
||||
/// Rate limiter type alias.
|
||||
pub type SharedRateLimiter = Arc<RateLimiter<NotKeyed, InMemoryState, DefaultClock>>;
|
||||
|
||||
/// Create a rate limiter with the configured limit.
|
||||
pub fn create_rate_limiter(rps: u32) -> SharedRateLimiter {
|
||||
let quota = Quota::per_second(std::num::NonZeroU32::new(rps).unwrap());
|
||||
Arc::new(RateLimiter::direct(quota))
|
||||
}
|
||||
|
||||
/// Rate limiting middleware.
|
||||
pub async fn rate_limit_middleware(
|
||||
State(limiter): State<SharedRateLimiter>,
|
||||
request: Request<Body>,
|
||||
next: Next,
|
||||
) -> Response {
|
||||
match limiter.check() {
|
||||
Ok(_) => next.run(request).await,
|
||||
Err(_) => {
|
||||
let body = ErrorResponse {
|
||||
error: "rate_limit_exceeded".into(),
|
||||
message: "Too many requests. Please slow down.".into(),
|
||||
details: None,
|
||||
request_id: None,
|
||||
};
|
||||
(StatusCode::TOO_MANY_REQUESTS, Json(body)).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// API key authentication middleware.
|
||||
pub async fn auth_middleware(
|
||||
State(ctx): State<AppContext>,
|
||||
headers: HeaderMap,
|
||||
request: Request<Body>,
|
||||
next: Next,
|
||||
) -> Response {
|
||||
// If no API key configured, allow all requests
|
||||
let Some(expected_key) = &ctx.config.api_key else {
|
||||
return next.run(request).await;
|
||||
};
|
||||
|
||||
// Check Authorization header
|
||||
let auth_header = headers
|
||||
.get(header::AUTHORIZATION)
|
||||
.and_then(|h| h.to_str().ok());
|
||||
|
||||
match auth_header {
|
||||
Some(auth) if auth.starts_with("Bearer ") => {
|
||||
let provided_key = auth.trim_start_matches("Bearer ").trim();
|
||||
if provided_key == expected_key {
|
||||
next.run(request).await
|
||||
} else {
|
||||
unauthorized_response("Invalid API key")
|
||||
}
|
||||
}
|
||||
Some(_) => unauthorized_response("Invalid authorization format. Use 'Bearer <api_key>'"),
|
||||
None => unauthorized_response("Missing Authorization header"),
|
||||
}
|
||||
}
|
||||
|
||||
fn unauthorized_response(message: &str) -> Response {
|
||||
let body = ErrorResponse {
|
||||
error: "unauthorized".into(),
|
||||
message: message.into(),
|
||||
details: None,
|
||||
request_id: None,
|
||||
};
|
||||
(StatusCode::UNAUTHORIZED, Json(body)).into_response()
|
||||
}
|
||||
|
||||
/// Request logging middleware that adds structured logging.
|
||||
pub async fn logging_middleware(
|
||||
headers: HeaderMap,
|
||||
request: Request<Body>,
|
||||
next: Next,
|
||||
) -> Response {
|
||||
let method = request.method().clone();
|
||||
let uri = request.uri().clone();
|
||||
let request_id = headers
|
||||
.get("x-request-id")
|
||||
.and_then(|h| h.to_str().ok())
|
||||
.map(String::from);
|
||||
|
||||
let start = std::time::Instant::now();
|
||||
|
||||
let response = next.run(request).await;
|
||||
|
||||
let latency = start.elapsed();
|
||||
let status = response.status();
|
||||
|
||||
tracing::info!(
|
||||
method = %method,
|
||||
uri = %uri,
|
||||
status = %status.as_u16(),
|
||||
latency_ms = %latency.as_millis(),
|
||||
request_id = ?request_id,
|
||||
"HTTP request"
|
||||
);
|
||||
|
||||
response
|
||||
}
|
||||
|
||||
/// Content type validation middleware for JSON endpoints.
|
||||
pub async fn json_content_type_middleware(
|
||||
headers: HeaderMap,
|
||||
request: Request<Body>,
|
||||
next: Next,
|
||||
) -> Response {
|
||||
// Only check POST/PUT/PATCH requests
|
||||
if matches!(
|
||||
request.method().as_str(),
|
||||
"POST" | "PUT" | "PATCH"
|
||||
) {
|
||||
// Skip multipart endpoints
|
||||
let path = request.uri().path();
|
||||
if path.contains("/recordings") {
|
||||
return next.run(request).await;
|
||||
}
|
||||
|
||||
// Check content type
|
||||
let content_type = headers
|
||||
.get(header::CONTENT_TYPE)
|
||||
.and_then(|h| h.to_str().ok());
|
||||
|
||||
match content_type {
|
||||
Some(ct) if ct.contains("application/json") => next.run(request).await,
|
||||
Some(ct) => {
|
||||
let body = ErrorResponse {
|
||||
error: "unsupported_media_type".into(),
|
||||
message: format!("Expected application/json, got {}", ct),
|
||||
details: None,
|
||||
request_id: None,
|
||||
};
|
||||
(StatusCode::UNSUPPORTED_MEDIA_TYPE, Json(body)).into_response()
|
||||
}
|
||||
None => {
|
||||
let body = ErrorResponse {
|
||||
error: "unsupported_media_type".into(),
|
||||
message: "Missing Content-Type header".into(),
|
||||
details: None,
|
||||
request_id: None,
|
||||
};
|
||||
(StatusCode::UNSUPPORTED_MEDIA_TYPE, Json(body)).into_response()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
next.run(request).await
|
||||
}
|
||||
}
|
||||
|
||||
/// Request body size limit middleware.
|
||||
pub struct BodyLimitMiddleware {
|
||||
max_size: usize,
|
||||
}
|
||||
|
||||
impl BodyLimitMiddleware {
|
||||
pub fn new(max_size: usize) -> Self {
|
||||
Self { max_size }
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract client IP from request.
|
||||
pub fn extract_client_ip(headers: &HeaderMap, connect_info: Option<&ConnectInfo<SocketAddr>>) -> Option<String> {
|
||||
// Try X-Forwarded-For first (for proxied requests)
|
||||
if let Some(forwarded) = headers
|
||||
.get("x-forwarded-for")
|
||||
.and_then(|h| h.to_str().ok())
|
||||
{
|
||||
// Take the first IP in the chain
|
||||
if let Some(ip) = forwarded.split(',').next() {
|
||||
return Some(ip.trim().to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// Try X-Real-IP
|
||||
if let Some(real_ip) = headers
|
||||
.get("x-real-ip")
|
||||
.and_then(|h| h.to_str().ok())
|
||||
{
|
||||
return Some(real_ip.to_string());
|
||||
}
|
||||
|
||||
// Fall back to connection info
|
||||
connect_info.map(|ci| ci.0.ip().to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_cors_layer_creation() {
|
||||
let config = Config::default();
|
||||
let _layer = cors_layer(&config);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rate_limiter_creation() {
|
||||
let limiter = create_rate_limiter(100);
|
||||
assert!(limiter.check().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_client_ip_x_forwarded() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert("x-forwarded-for", "1.2.3.4, 5.6.7.8".parse().unwrap());
|
||||
|
||||
let ip = extract_client_ip(&headers, None);
|
||||
assert_eq!(ip, Some("1.2.3.4".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_client_ip_x_real() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert("x-real-ip", "10.0.0.1".parse().unwrap());
|
||||
|
||||
let ip = extract_client_ip(&headers, None);
|
||||
assert_eq!(ip, Some("10.0.0.1".to_string()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
//! REST API module for 7sense.
|
||||
//!
|
||||
//! This module provides RESTful endpoints for:
|
||||
//! - Recording upload and management
|
||||
//! - Segment similarity search
|
||||
//! - Cluster discovery and labeling
|
||||
//! - Evidence pack retrieval
|
||||
//!
|
||||
//! ## API Versioning
|
||||
//!
|
||||
//! All endpoints are versioned under `/api/v1/`. Breaking changes will
|
||||
//! result in a new API version (e.g., `/api/v2/`).
|
||||
//!
|
||||
//! ## Authentication
|
||||
//!
|
||||
//! If `SEVENSENSE_API_KEY` is set, all requests must include an
|
||||
//! `Authorization: Bearer <api_key>` header.
|
||||
|
||||
pub mod handlers;
|
||||
pub mod middleware;
|
||||
pub mod routes;
|
||||
|
||||
pub use handlers::*;
|
||||
pub use routes::create_router;
|
||||
@@ -0,0 +1,74 @@
|
||||
//! REST API route definitions with versioning.
|
||||
//!
|
||||
//! Routes are organized by resource type and versioned under `/api/v1/`.
|
||||
|
||||
use axum::{
|
||||
routing::{get, post, put},
|
||||
Router,
|
||||
};
|
||||
|
||||
use super::handlers;
|
||||
use crate::AppContext;
|
||||
|
||||
/// Create the REST API router with all endpoints.
|
||||
pub fn create_router(_ctx: AppContext) -> Router<AppContext> {
|
||||
Router::new()
|
||||
// Health check
|
||||
.route("/health", get(handlers::health_check))
|
||||
// Recordings
|
||||
.nest("/recordings", recordings_router())
|
||||
// Segments
|
||||
.nest("/segments", segments_router())
|
||||
// Clusters
|
||||
.nest("/clusters", clusters_router())
|
||||
// Evidence
|
||||
.nest("/evidence", evidence_router())
|
||||
// Search
|
||||
.route("/search", post(handlers::search))
|
||||
}
|
||||
|
||||
/// Recording management routes.
|
||||
fn recordings_router() -> Router<AppContext> {
|
||||
Router::new()
|
||||
// POST /recordings - Upload new recording
|
||||
.route("/", post(handlers::upload_recording))
|
||||
// GET /recordings/:id - Get recording by ID
|
||||
.route("/:id", get(handlers::get_recording))
|
||||
}
|
||||
|
||||
/// Segment analysis routes.
|
||||
fn segments_router() -> Router<AppContext> {
|
||||
Router::new()
|
||||
// GET /segments/:id/neighbors - Find similar segments
|
||||
.route("/:id/neighbors", get(handlers::get_neighbors))
|
||||
}
|
||||
|
||||
/// Cluster management routes.
|
||||
fn clusters_router() -> Router<AppContext> {
|
||||
Router::new()
|
||||
// GET /clusters - List all clusters
|
||||
.route("/", get(handlers::list_clusters))
|
||||
// GET /clusters/:id - Get specific cluster
|
||||
.route("/:id", get(handlers::get_cluster))
|
||||
// PUT /clusters/:id/label - Assign label to cluster
|
||||
.route("/:id/label", put(handlers::assign_cluster_label))
|
||||
}
|
||||
|
||||
/// Evidence pack routes.
|
||||
fn evidence_router() -> Router<AppContext> {
|
||||
Router::new()
|
||||
// POST /evidence - Generate evidence pack
|
||||
.route("/", post(handlers::generate_evidence_pack))
|
||||
// GET /evidence/:id - Get evidence pack by ID
|
||||
.route("/:id", get(handlers::get_evidence_pack))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use axum::body::Body;
|
||||
use axum::http::{Request, StatusCode};
|
||||
use tower::ServiceExt;
|
||||
|
||||
// Integration tests would go here with a mock AppContext
|
||||
}
|
||||
Reference in New Issue
Block a user