use crate::error::RariError;
use crate::runtime::JsExecutionRuntime;
use crate::runtime::bridge::RequestBridge;
use crate::server::routing::types::RouteSegment;
use axum::body::Body;
use axum::http::{HeaderMap, Request, Response};
use cow_utils::CowUtils;
use dashmap::DashMap;
use rustc_hash::FxHashMap;
use serde::{Deserialize, Serialize};
use serde_json::Value as JsonValue;
use std::path::Path;
use std::sync::Arc;
use std::time::SystemTime;
use tracing::error;
use urlencoding::decode;
fn parse_decoded_path_segments(path: &str) -> Vec<String> {
path.split('/')
.filter(|s| !s.is_empty())
.map(|s| decode(s).unwrap_or_else(|_| s.to_string().into()).into_owned())
.collect()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ApiRouteEntry {
pub path: String,
#[serde(rename = "filePath")]
pub file_path: String,
pub segments: Vec<RouteSegment>,
pub params: Vec<String>,
#[serde(rename = "isDynamic")]
pub is_dynamic: bool,
pub methods: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ApiRouteManifest {
#[serde(rename = "apiRoutes", default)]
pub api_routes: Vec<ApiRouteEntry>,
}
#[derive(Debug, Clone)]
pub struct ApiRouteMatch {
pub route: ApiRouteEntry,
pub params: FxHashMap<String, String>,
pub method: String,
}
#[derive(Debug, Clone)]
pub(crate) struct CompiledHandler {
module_id: String,
code: String,
last_modified: SystemTime,
}
pub struct ApiRouteHandler {
runtime: Arc<JsExecutionRuntime>,
manifest: Arc<ApiRouteManifest>,
handler_cache: Arc<DashMap<String, CompiledHandler>>,
}
impl ApiRouteHandler {
pub fn new(runtime: Arc<JsExecutionRuntime>, manifest: ApiRouteManifest) -> Self {
Self { runtime, manifest: Arc::new(manifest), handler_cache: Arc::new(DashMap::new()) }
}
pub async fn from_file(
runtime: Arc<JsExecutionRuntime>,
manifest_path: &str,
) -> Result<Self, RariError> {
let content = tokio::fs::read_to_string(manifest_path)
.await
.map_err(|e| RariError::io(format!("Failed to read API route manifest: {e}")))?;
let manifest: ApiRouteManifest = serde_json::from_str(&content).map_err(|e| {
RariError::configuration(format!("Failed to parse API route manifest: {e}"))
})?;
Ok(Self::new(runtime, manifest))
}
pub fn manifest(&self) -> &ApiRouteManifest {
&self.manifest
}
pub fn runtime(&self) -> &Arc<JsExecutionRuntime> {
&self.runtime
}
pub fn clear_cache(&self) {
self.handler_cache.clear();
}
pub fn invalidate_handler(&self, file_path: &str) {
self.handler_cache.remove(file_path);
}
pub fn get_supported_methods(&self, path: &str) -> Option<Vec<String>> {
let normalized_path = Self::normalize_path(path);
for route in &self.manifest.api_routes {
if self.match_route_pattern(route, &normalized_path).is_some() {
return Some(route.methods.clone());
}
}
None
}
pub fn match_route(&self, path: &str, method: &str) -> Result<ApiRouteMatch, RariError> {
let normalized_path = Self::normalize_path(path);
for route in &self.manifest.api_routes {
if let Some(params) = self.match_route_pattern(route, &normalized_path) {
if !route.methods.iter().any(|m| m.eq_ignore_ascii_case(method)) {
return Err(RariError::bad_request(format!(
"Method {} not allowed for route {}. Supported methods: {}",
method,
route.path,
route.methods.join(", ")
))
.with_property("error_type", "method_not_allowed")
.with_property("allowed_methods", &route.methods.join(",")));
}
return Ok(ApiRouteMatch {
route: route.clone(),
params,
method: method.to_string(),
});
}
}
Err(RariError::not_found(format!("No API route found for path: {}", path)))
}
fn match_route_pattern(
&self,
route: &ApiRouteEntry,
path: &str,
) -> Option<FxHashMap<String, String>> {
let route_segments = route.path.split('/').filter(|s| !s.is_empty()).collect::<Vec<_>>();
let path_segments = parse_decoded_path_segments(path);
let mut params = FxHashMap::default();
let mut route_idx = 0;
let mut path_idx = 0;
while route_idx < route_segments.len() {
let route_seg = route_segments[route_idx];
if route_seg.starts_with("[[...") && route_seg.ends_with("]]") {
let param_name = &route_seg[5..route_seg.len() - 2];
if path_idx < path_segments.len() {
let remaining: Vec<String> = path_segments[path_idx..].to_vec();
params.insert(param_name.to_string(), remaining.join("/"));
}
return Some(params);
}
if route_seg.starts_with("[...") && route_seg.ends_with(']') {
let param_name = &route_seg[4..route_seg.len() - 1];
if path_idx >= path_segments.len() {
return None;
}
let remaining: Vec<String> = path_segments[path_idx..].to_vec();
params.insert(param_name.to_string(), remaining.join("/"));
return Some(params);
}
if route_seg.starts_with('[') && route_seg.ends_with(']') {
if path_idx >= path_segments.len() {
return None;
}
let param_name = &route_seg[1..route_seg.len() - 1];
params.insert(param_name.to_string(), path_segments[path_idx].clone());
path_idx += 1;
route_idx += 1;
continue;
}
if path_idx >= path_segments.len() || route_seg != path_segments[path_idx] {
return None;
}
path_idx += 1;
route_idx += 1;
}
if path_idx == path_segments.len() { Some(params) } else { None }
}
fn normalize_path(path: &str) -> String {
let path = path.trim();
let path = path.split('?').next().unwrap_or(path);
let path = path.split('#').next().unwrap_or(path);
if path.is_empty() || !path.starts_with('/') {
format!("/{}", path)
} else {
path.to_string()
}
}
pub(crate) async fn load_handler(
&self,
route: &ApiRouteEntry,
is_development: bool,
) -> Result<CompiledHandler, RariError> {
let file_path = &route.file_path;
if let Some(cached) = self.handler_cache.get(file_path) {
if is_development {
let dist_path = Self::resolve_dist_path(file_path)?;
if let Ok(metadata) = tokio::fs::metadata(&dist_path).await
&& let Ok(modified) = metadata.modified()
&& modified <= cached.last_modified
{
return Ok(cached.clone());
}
} else {
return Ok(cached.clone());
}
}
let dist_path = Self::resolve_dist_path(file_path)?;
if !dist_path.exists() {
error!(
file_path = %file_path,
dist_path = %dist_path.display(),
route_path = %route.path,
"Handler file not found"
);
return Err(RariError::not_found(format!(
"Handler file not found: {}",
dist_path.display()
)));
}
let code = tokio::fs::read_to_string(&dist_path).await.map_err(|e| {
error!(
file_path = %file_path,
dist_path = %dist_path.display(),
error = %e,
"Failed to read handler file"
);
RariError::io(format!("Failed to read handler file: {e}"))
})?;
let last_modified = tokio::fs::metadata(&dist_path)
.await
.and_then(|m| m.modified())
.unwrap_or_else(|_| SystemTime::now());
let module_id = file_path
.trim_start_matches("api/")
.trim_end_matches(".ts")
.trim_end_matches(".tsx")
.trim_end_matches(".js")
.trim_end_matches(".jsx")
.cow_replace('/', "_");
let compiled = CompiledHandler {
module_id: module_id.into_owned(),
code: code.clone(),
last_modified,
};
self.handler_cache.insert(file_path.clone(), compiled.clone());
Ok(compiled)
}
fn resolve_dist_path(file_path: &str) -> Result<std::path::PathBuf, RariError> {
let mut normalized_path = String::new();
let mut chars = file_path.chars().peekable();
while let Some(ch) = chars.next() {
if ch == '[' {
if chars.peek() == Some(&'[') {
chars.next();
if chars.peek() == Some(&'.') {
chars.next();
if chars.peek() == Some(&'.') {
chars.next();
if chars.peek() == Some(&'.') {
chars.next();
normalized_path.push_str("_____");
while let Some(ch) = chars.next() {
if ch == ']' && chars.peek() == Some(&']') {
chars.next();
normalized_path.push_str("__");
break;
} else {
normalized_path.push(ch);
}
}
}
}
}
} else if chars.peek() == Some(&'.') {
chars.next();
if chars.peek() == Some(&'.') {
chars.next();
if chars.peek() == Some(&'.') {
chars.next();
normalized_path.push_str("____");
for ch in chars.by_ref() {
if ch == ']' {
normalized_path.push('_');
break;
} else {
normalized_path.push(ch);
}
}
}
}
} else {
normalized_path.push('_');
for ch in chars.by_ref() {
if ch == ']' {
normalized_path.push('_');
break;
} else {
normalized_path.push(ch);
}
}
}
} else {
normalized_path.push(ch);
}
}
let dist_path =
Path::new("dist").join("server").join("app").join(normalized_path).with_extension("js");
Ok(dist_path)
}
pub async fn execute_handler(
&self,
route_match: &ApiRouteMatch,
request: Request<Body>,
is_development: bool,
) -> Result<Response<Body>, RariError> {
const MAX_API_BODY_SIZE: usize = 10 * 1024 * 1024;
let handler = self.load_handler(&route_match.route, is_development).await.map_err(|e| {
error!(
route_path = %route_match.route.path,
method = %route_match.method,
error = %e,
"Failed to load handler"
);
e
})?;
let (parts, body) = request.into_parts();
let body_bytes = axum::body::to_bytes(body, MAX_API_BODY_SIZE).await.map_err(|e| {
error!(
route_path = %route_match.route.path,
method = %route_match.method,
error = %e,
"Failed to read request body (may exceed size limit)"
);
RariError::bad_request(format!("Failed to read request body: {e}"))
})?;
let body_string = String::from_utf8_lossy(&body_bytes).to_string();
let request_obj = self.create_request_object(
parts.method.as_ref(),
&parts.uri.to_string(),
&parts.headers,
&body_string,
&route_match.params,
)?;
let request_context =
std::sync::Arc::new(crate::server::middleware::request_context::RequestContext::new(
route_match.route.path.clone(),
));
self.runtime
.execute_with_request_context(request_context, async {
let dist_path = Self::resolve_dist_path(&route_match.route.file_path)?;
let canonical_path = dist_path.canonicalize().map_err(|e| {
RariError::io(format!(
"Failed to canonicalize API route path {}: {e}",
dist_path.display()
))
})?;
let module_specifier = url::Url::from_file_path(&canonical_path)
.map_err(|_| {
RariError::configuration(format!(
"Failed to create file URL from path: {}",
canonical_path.display()
))
})?
.to_string();
if let Err(e) = self
.runtime
.add_module_to_loader_only(&module_specifier, handler.code.clone())
.await
{
error!(
route_path = %route_match.route.path,
method = %route_match.method,
module_id = %handler.module_id,
error = %e,
"Failed to add API route module to loader"
);
return Err(RariError::js_execution(format!(
"Failed to add module to loader: {e}"
)));
}
let component_id = dist_path
.strip_prefix(Path::new("dist").join("server"))
.map_err(|_| {
RariError::configuration(format!(
"Failed to derive component_id from dist path: {}",
dist_path.display()
))
})?
.with_extension("")
.to_string_lossy()
.cow_replace('\\', "/")
.into_owned();
let module_id = self.runtime.load_es_module(&component_id).await.map_err(|e| {
error!(
route_path = %route_match.route.path,
method = %route_match.method,
component_id = %component_id,
error = %e,
"Failed to load API route as ES module"
);
RariError::js_execution(format!("Failed to load ES module: {e}"))
})?;
if let Err(e) = self.runtime.evaluate_module(module_id).await {
error!(
route_path = %route_match.route.path,
method = %route_match.method,
module_id = module_id,
error = %e,
"Failed to evaluate API route module"
);
return Err(RariError::js_execution(format!("Failed to evaluate module: {e}")));
}
let result = self
.execute_handler_from_namespace(
&route_match.method,
&request_obj,
&module_specifier,
)
.await
.map_err(|e| {
error!(
route_path = %route_match.route.path,
method = %route_match.method,
module_id = %handler.module_id,
error = %e,
"Handler execution failed"
);
RariError::js_execution(format!("Handler execution failed: {e}"))
})?;
let response = self.create_response(result).await.map_err(|e| {
error!(
route_path = %route_match.route.path,
method = %route_match.method,
error = %e,
"Failed to create response from handler result"
);
e
})?;
Ok(response)
})
.await
}
fn create_request_object(
&self,
method: &str,
uri: &str,
headers: &HeaderMap,
body: &str,
params: &FxHashMap<String, String>,
) -> Result<JsonValue, RariError> {
RequestBridge::to_json(method, uri, headers, body, params)
}
async fn execute_handler_from_namespace(
&self,
method: &str,
request_obj: &JsonValue,
module_specifier: &str,
) -> Result<JsonValue, RariError> {
let request_json = serde_json::to_string(request_obj)
.map_err(|e| RariError::serialization(format!("Failed to serialize request: {e}")))?;
let script = format!(
r#"globalThis['~rari'].apiHandler.callHandler({}, "{}", "{}")"#,
request_json,
module_specifier.cow_replace('\\', "\\\\").cow_replace('"', "\\\""),
method.cow_replace('\\', "\\\\").cow_replace('"', "\\\"")
);
let trimmed = module_specifier.trim_start_matches("file://");
let with_underscores = trimmed.cow_replace('/', "_");
let script_name = with_underscores.cow_replace(':', "_");
self.runtime
.execute_script(format!("api_route_call_{}", script_name), script)
.await
.map_err(|e| RariError::js_execution(format!("Failed to execute handler: {e}")))
}
async fn create_response(&self, result: JsonValue) -> Result<Response<Body>, RariError> {
RequestBridge::from_json(result)
}
}