Skip to main content

fastskill_core/http/
server.rs

1//! Axum HTTP server implementation
2
3use crate::core::registry::{StagingManager, ValidationWorker, ValidationWorkerConfig};
4use crate::core::service::{FastSkillService, ServiceConfig};
5use crate::http::handlers::{
6    claude_api, manifest, registry, registry_publish, reindex, resolve, search, skills, status,
7    AppState,
8};
9use axum::{
10    body::Body,
11    extract::Request,
12    http::{header, HeaderName, HeaderValue, Method, StatusCode},
13    response::Response,
14    routing::{delete, get, post, put},
15    Router,
16};
17use include_dir::{include_dir, Dir};
18use std::env;
19use std::net::SocketAddr;
20use std::path::PathBuf;
21use std::str::FromStr;
22use std::sync::Arc;
23use tower::ServiceBuilder;
24use tower_http::cors::{AllowOrigin, CorsLayer};
25use tower_http::{compression::CompressionLayer, trace::TraceLayer};
26use tracing::info;
27
28/// Static assets embedded at compile time
29static EMBEDDED_STATIC: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/src/http/static");
30
31/// Serves embedded static files for the registry UI.
32async fn serve_embedded_static(req: Request) -> Result<Response, StatusCode> {
33    let path = req.uri().path().trim_start_matches('/');
34    let name = match path {
35        "" | "index.html" => "index.html",
36        "app.js" => "app.js",
37        "styles.css" => "styles.css",
38        _ => return Err(StatusCode::NOT_FOUND),
39    };
40    let file = EMBEDDED_STATIC
41        .get_file(name)
42        .ok_or(StatusCode::NOT_FOUND)?;
43    let body = file.contents();
44    let content_type: HeaderValue = match name {
45        "index.html" => HeaderValue::from_static("text/html; charset=utf-8"),
46        "app.js" => HeaderValue::from_static("application/javascript; charset=utf-8"),
47        "styles.css" => HeaderValue::from_static("text/css; charset=utf-8"),
48        _ => HeaderValue::from_static("application/octet-stream"),
49    };
50    Response::builder()
51        .status(StatusCode::OK)
52        .header(header::CONTENT_TYPE, content_type)
53        .body(Body::from(body))
54        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
55}
56
57/// Validate blob storage configuration fields
58fn validate_blob_storage_fields(blob_config: &crate::core::BlobStorageConfig) -> Vec<&'static str> {
59    let mut missing = Vec::new();
60
61    if blob_config.storage_type.is_empty() {
62        missing.push("storage_type");
63    }
64    if blob_config.bucket.is_empty() {
65        missing.push("bucket");
66    }
67    if blob_config.region.is_empty() {
68        missing.push("region");
69    }
70    if blob_config.access_key.is_empty() {
71        missing.push("access_key");
72    }
73    if blob_config.secret_key.is_empty() {
74        missing.push("secret_key");
75    }
76
77    missing
78}
79
80/// Build error message for validation failures
81fn build_validation_error_message(
82    missing: &[&str],
83    incomplete_fields: &[(&str, Vec<&str>)],
84) -> String {
85    let mut error_msg =
86        String::from("Registry enabled but required configuration is missing or incomplete:\n");
87
88    if !missing.is_empty() {
89        error_msg.push_str("  Missing configuration:\n");
90        for item in missing {
91            error_msg.push_str(&format!("    - {}\n", item));
92        }
93    }
94
95    if !incomplete_fields.is_empty() {
96        error_msg.push_str("  Incomplete configuration:\n");
97        for (config_name, fields) in incomplete_fields {
98            error_msg.push_str(&format!("    {} is missing fields:\n", config_name));
99            for field in fields {
100                error_msg.push_str(&format!("      - {}\n", field));
101            }
102        }
103    }
104
105    error_msg.push_str("\nS3 configuration is required for operational registry publishing.");
106    error_msg
107}
108
109/// Validate registry configuration and return detailed error message if invalid
110fn validate_registry_config(config: &ServiceConfig) -> Result<(), String> {
111    let mut missing = Vec::new();
112    let mut incomplete_fields = Vec::new();
113
114    // Check registry_blob_storage
115    match &config.registry_blob_storage {
116        None => {
117            missing.push("registry_blob_storage");
118        }
119        Some(blob_config) => {
120            let blob_missing = validate_blob_storage_fields(blob_config);
121            if !blob_missing.is_empty() {
122                incomplete_fields.push(("registry_blob_storage", blob_missing));
123            }
124        }
125    }
126
127    if missing.is_empty() && incomplete_fields.is_empty() {
128        return Ok(());
129    }
130
131    Err(build_validation_error_message(&missing, &incomplete_fields))
132}
133
134/// Display startup banner with ASCII art
135fn display_startup_banner() {
136    // TODO: Implement startup banner display
137    let _version = crate::VERSION;
138}
139
140/// Get allowed methods for CORS
141fn get_allowed_methods() -> [Method; 5] {
142    [
143        Method::GET,
144        Method::POST,
145        Method::PUT,
146        Method::DELETE,
147        Method::OPTIONS,
148    ]
149}
150
151/// Build default CORS layer (deny all origins)
152fn build_default_cors_layer() -> CorsLayer {
153    CorsLayer::new()
154        .allow_methods(get_allowed_methods())
155        .allow_origin(AllowOrigin::exact(HeaderValue::from_static("")))
156}
157
158/// Parse origin strings to HeaderValues
159fn parse_origins(origins: &[String]) -> Result<Vec<HeaderValue>, String> {
160    origins
161        .iter()
162        .map(|origin| {
163            HeaderValue::from_str(origin)
164                .map_err(|e| format!("Invalid origin header value '{}': {}", origin, e))
165        })
166        .collect()
167}
168
169/// Parse header strings to HeaderNames
170fn parse_headers(headers: &[String]) -> Result<Vec<HeaderName>, String> {
171    headers
172        .iter()
173        .map(|header| {
174            HeaderName::from_str(header)
175                .map_err(|e| format!("Invalid header name '{}': {}", header, e))
176        })
177        .collect()
178}
179
180/// Build CORS layer with configured origins and headers
181fn build_configured_cors_layer(
182    origin_header_values: Vec<HeaderValue>,
183    allowed_origins: &[String],
184    allowed_headers: &[String],
185) -> CorsLayer {
186    info!(
187        "Configuring CORS for {} origins: {}",
188        origin_header_values.len(),
189        allowed_origins.join(", ")
190    );
191
192    let header_names = match parse_headers(allowed_headers) {
193        Ok(values) => values,
194        Err(e) => {
195            tracing::error!("Failed to build CORS headers: {}", e);
196            return CorsLayer::new()
197                .allow_methods(get_allowed_methods())
198                .allow_origin(AllowOrigin::list(origin_header_values))
199                .allow_headers([header::CONTENT_TYPE, header::AUTHORIZATION]);
200        }
201    };
202
203    CorsLayer::new()
204        .allow_methods(get_allowed_methods())
205        .allow_origin(AllowOrigin::list(origin_header_values))
206        .allow_headers(header_names)
207        .allow_credentials(true)
208}
209
210/// Build CORS layer from service configuration
211pub fn build_cors_layer(config: &crate::core::service::ServiceConfig) -> CorsLayer {
212    let http_config = match config.http_server.as_ref() {
213        None => {
214            info!("No CORS configuration found - denying all origins");
215            return build_default_cors_layer();
216        }
217        Some(cfg) => cfg,
218    };
219
220    if http_config.allowed_origins.is_empty() {
221        info!("Empty CORS allowed_origins - denying all origins");
222        return build_default_cors_layer();
223    }
224
225    let origin_header_values = match parse_origins(&http_config.allowed_origins) {
226        Ok(values) => values,
227        Err(e) => {
228            tracing::error!("Failed to build CORS origins: {}", e);
229            return build_default_cors_layer();
230        }
231    };
232
233    build_configured_cors_layer(
234        origin_header_values,
235        &http_config.allowed_origins,
236        &http_config.allowed_headers,
237    )
238}
239
240/// FastSkill HTTP server
241pub struct FastSkillServer {
242    service: Arc<FastSkillService>,
243    addr: SocketAddr,
244}
245
246impl FastSkillServer {
247    /// Create a new server instance
248    pub fn new(service: Arc<FastSkillService>, host: &str, port: u16) -> Self {
249        let addr = match Self::parse_address(host, port) {
250            Ok(addr) => addr,
251            Err(e) => {
252                eprintln!("Invalid address: {}:{} - {}", host, port, e);
253                std::process::exit(1);
254            }
255        };
256
257        Self { service, addr }
258    }
259
260    /// Parse and normalize host:port into a SocketAddr
261    fn parse_address(host: &str, port: u16) -> Result<SocketAddr, String> {
262        // Normalize common hostnames for SocketAddr compatibility
263        let normalized_host = Self::normalize_host(host);
264
265        // Format the address string - IPv6 addresses need brackets
266        let addr_str = if normalized_host.contains(':') {
267            // IPv6 address - wrap in brackets
268            format!("[{}]:{}", normalized_host, port)
269        } else {
270            // IPv4 address or hostname
271            format!("{}:{}", normalized_host, port)
272        };
273
274        addr_str.parse().map_err(|_| {
275            format!(
276                "Unable to parse address '{}'. Use IP addresses like '127.0.0.1', '0.0.0.0', '::1', or hostnames that resolve to IP addresses",
277                addr_str
278            )
279        })
280    }
281
282    /// Normalize hostnames for SocketAddr compatibility
283    fn normalize_host(host: &str) -> String {
284        match host {
285            // Convert localhost to 127.0.0.1 for dev machine compatibility
286            "localhost" => "127.0.0.1".to_string(),
287            // IPv6 localhost variants
288            "::1" | "[::1]" => "::1".to_string(),
289            // All IPv6 interfaces
290            "::" | "[::]" => "::".to_string(),
291            // Keep other values as-is (IP addresses, other hostnames)
292            _ => host.to_string(),
293        }
294    }
295
296    /// Create a new server instance from an Arc-wrapped service reference
297    pub fn from_ref(service: &Arc<FastSkillService>, host: &str, port: u16) -> Self {
298        let service_arc = Arc::clone(service);
299
300        let addr = match Self::parse_address(host, port) {
301            Ok(addr) => addr,
302            Err(e) => {
303                eprintln!("Invalid address: {}:{} - {}", host, port, e);
304                std::process::exit(1);
305            }
306        };
307
308        Self {
309            service: service_arc,
310            addr,
311        }
312    }
313
314    /// Create skill CRUD routes
315    fn create_skill_routes() -> Router<AppState> {
316        Router::new()
317            .route("/api/skills", get(skills::list_skills))
318            .route("/api/skills", post(skills::create_skill))
319            .route("/api/skills/:id", get(skills::get_skill))
320            .route("/api/skills/:id", put(skills::update_skill))
321            .route("/api/skills/:id", delete(skills::delete_skill))
322            .route("/api/skills/upgrade", post(skills::upgrade_skills))
323            .route("/api/project", get(manifest::get_project))
324    }
325
326    /// Create search and reindex routes
327    fn create_search_routes() -> Router<AppState> {
328        Router::new()
329            .route("/api/search", post(search::search_skills))
330            .route("/api/resolve", post(resolve::resolve_context))
331            .route("/api/reindex", post(reindex::reindex_all))
332            .route("/api/reindex/:id", post(reindex::reindex_skill))
333    }
334
335    /// Create status routes
336    fn create_status_routes() -> Router<AppState> {
337        Router::new().route("/api/status", get(status::status))
338    }
339
340    /// Create Claude Code v1 API routes
341    fn create_claude_api_routes() -> Router<AppState> {
342        Router::new()
343            .route("/v1/skills", post(claude_api::create_skill))
344            .route("/v1/skills", get(claude_api::list_skills))
345            .route("/v1/skills/:skill_id", get(claude_api::get_skill))
346            .route("/v1/skills/:skill_id", delete(claude_api::delete_skill))
347            .route(
348                "/v1/skills/:skill_id/versions",
349                post(claude_api::create_skill_version),
350            )
351            .route(
352                "/v1/skills/:skill_id/versions",
353                get(claude_api::list_skill_versions),
354            )
355            .route(
356                "/v1/skills/:skill_id/versions/:version",
357                get(claude_api::get_skill_version),
358            )
359            .route(
360                "/v1/skills/:skill_id/versions/:version",
361                delete(claude_api::delete_skill_version),
362            )
363    }
364
365    /// Create UI routes
366    fn create_ui_routes() -> Router<AppState> {
367        info!("Serving UI at /");
368        Router::new()
369            .route("/dashboard", get(status::root))
370            .route("/", get(serve_embedded_static))
371            .route("/index.html", get(serve_embedded_static))
372            .route("/app.js", get(serve_embedded_static))
373            .route("/styles.css", get(serve_embedded_static))
374    }
375
376    /// Create registry routes
377    fn create_registry_routes() -> Router<AppState> {
378        Router::new()
379            .route("/index/*skill_id", get(registry::serve_index_file))
380            .route(
381                "/api/registry/index/skills",
382                get(registry::list_index_skills),
383            )
384            .route("/api/registry/sources", get(registry::list_sources))
385            .route("/api/registry/skills", get(registry::list_all_skills))
386            .route(
387                "/api/registry/sources/:name/skills",
388                get(registry::list_source_skills),
389            )
390            .route(
391                "/api/registry/sources/:name/marketplace",
392                get(registry::get_marketplace),
393            )
394            .route("/api/registry/refresh", post(registry::refresh_sources))
395            .route(
396                "/api/registry/publish",
397                post(registry_publish::publish_package),
398            )
399            .route(
400                "/api/registry/publish/status/:job_id",
401                get(registry_publish::get_publish_status),
402            )
403    }
404
405    /// Create manifest routes
406    fn create_manifest_routes() -> Router<AppState> {
407        Router::new()
408            .route("/api/manifest/skills", get(manifest::list_manifest_skills))
409            .route(
410                "/api/manifest/skills",
411                post(manifest::add_skill_to_manifest),
412            )
413            .route(
414                "/api/manifest/skills/:id",
415                put(manifest::update_skill_in_manifest),
416            )
417            .route(
418                "/api/manifest/skills/:id",
419                delete(manifest::remove_skill_from_manifest),
420            )
421    }
422
423    /// Create the Axum router with all routes
424    fn create_router(&self) -> Result<Router, Box<dyn std::error::Error>> {
425        // Load project configuration
426        let current_dir = env::current_dir()?;
427        let config = crate::core::load_project_config(&current_dir)
428            .map_err(|e| format!("Failed to load project config: {}", e))?;
429
430        let state = AppState::new(self.service.clone())?.with_project_config(
431            config.project_root,
432            config.project_file_path,
433            config.skills_directory,
434        );
435
436        // Merge all route modules
437        let router = Router::new()
438            .merge(Self::create_skill_routes())
439            .merge(Self::create_search_routes())
440            .merge(Self::create_status_routes())
441            .merge(Self::create_claude_api_routes())
442            .merge(Self::create_ui_routes())
443            .merge(Self::create_registry_routes())
444            .merge(Self::create_manifest_routes());
445
446        // Add middleware and state
447        Ok(router
448            .layer(
449                ServiceBuilder::new()
450                    .layer(TraceLayer::new_for_http())
451                    .layer(CompressionLayer::new())
452                    .layer(build_cors_layer(self.service.config())),
453            )
454            .with_state(state))
455    }
456
457    /// Start the server
458    pub async fn serve(self) -> Result<(), Box<dyn std::error::Error>> {
459        // Display startup banner
460        display_startup_banner();
461
462        let app = self.create_router()?;
463
464        // Start validation worker only when registry config is valid
465        let config = self.service.config();
466        if validate_registry_config(config).is_ok() {
467            let staging_dir = config
468                .staging_dir
469                .clone()
470                .unwrap_or_else(|| PathBuf::from(".staging"));
471
472            let staging_manager = StagingManager::new(staging_dir);
473            if staging_manager.initialize().is_ok() {
474                let worker_config = ValidationWorkerConfig {
475                    poll_interval_secs: 5,
476                    blob_storage_config: config.registry_blob_storage.clone(),
477                    registry_index_path: config.registry_index_path.clone(),
478                    blob_base_url: config.registry_blob_base_url.clone(),
479                };
480                let worker = ValidationWorker::new(staging_manager, worker_config);
481                worker.start();
482                info!("Validation worker started");
483            }
484        }
485
486        info!("Starting FastSkill HTTP server on {}", self.addr);
487
488        let listener = tokio::net::TcpListener::bind(self.addr).await?;
489        let actual_addr = listener.local_addr()?;
490        info!("Server bound to {}", actual_addr);
491
492        axum::serve(listener, app).await?;
493
494        Ok(())
495    }
496
497    /// Get server address
498    pub fn addr(&self) -> SocketAddr {
499        self.addr
500    }
501}
502
503/// Convenience function to create and start a server
504pub async fn serve(
505    service: Arc<FastSkillService>,
506    host: &str,
507    port: u16,
508) -> Result<(), Box<dyn std::error::Error>> {
509    let server = FastSkillServer::new(service, host, port);
510    server.serve().await
511}