diff --git a/aiscript-runtime/src/ast/mod.rs b/aiscript-runtime/src/ast/mod.rs index 94fee49..d727fa6 100644 --- a/aiscript-runtime/src/ast/mod.rs +++ b/aiscript-runtime/src/ast/mod.rs @@ -81,6 +81,7 @@ pub struct Endpoint { pub path_specs: Vec, #[allow(unused)] pub return_type: Option, + pub path: Vec, pub query: Vec, pub body: RequestBody, pub statements: String, diff --git a/aiscript-runtime/src/endpoint.rs b/aiscript-runtime/src/endpoint.rs index 6aaf628..2770861 100644 --- a/aiscript-runtime/src/endpoint.rs +++ b/aiscript-runtime/src/endpoint.rs @@ -3,7 +3,7 @@ use aiscript_vm::{ReturnValue, Vm, VmError}; use axum::{ Form, Json, RequestExt, body::Body, - extract::{self, FromRequest, Request}, + extract::{self, FromRequest, RawPathParams, Request}, http::{HeaderName, HeaderValue}, response::{IntoResponse, Response}, }; @@ -57,6 +57,7 @@ pub struct Field { #[derive(Clone)] pub struct Endpoint { pub annotation: RouteAnnotation, + pub path_params: Vec, pub query_params: Vec, pub body_type: BodyKind, pub body_fields: Vec, @@ -70,6 +71,7 @@ pub struct Endpoint { enum ProcessingState { ValidatingAuth, + ValidatingPath, ValidatingQuery, ValidatingBody, Executing(JoinHandle>), @@ -79,6 +81,7 @@ pub struct RequestProcessor { endpoint: Endpoint, request: Request, jwt_claim: Option, + path_data: HashMap, query_data: HashMap, body_data: HashMap, state: ProcessingState, @@ -89,12 +92,13 @@ impl RequestProcessor { let state = if endpoint.annotation.is_auth_required() { ProcessingState::ValidatingAuth } else { - ProcessingState::ValidatingQuery + ProcessingState::ValidatingPath }; Self { endpoint, request, jwt_claim: None, + path_data: HashMap::new(), query_data: HashMap::new(), body_data: HashMap::new(), state, @@ -307,6 +311,99 @@ impl Future for RequestProcessor { } } } + self.state = ProcessingState::ValidatingPath; + } + ProcessingState::ValidatingPath => { + let raw_path_params = { + // Extract path parameters using Axum's RawPathParams extractor + let future = self.request.extract_parts::(); + + tokio::pin!(future); + match future.poll(cx) { + Poll::Pending => return Poll::Pending, + Poll::Ready(Ok(params)) => params, + Poll::Ready(Err(e)) => { + return Poll::Ready(Ok(format!( + "Failed to extract path parameters: {}", + e + ) + .into_response())); + } + } + }; + + // Process and validate each path parameter + for (param_name, param_value) in &raw_path_params { + // Find the corresponding path parameter field + if let Some(field) = self + .endpoint + .path_params + .iter() + .find(|f| f.name == param_name) + { + // Convert the value to the appropriate type based on the field definition + let value = match field.field_type { + FieldType::Str => Value::String(param_value.to_string()), + FieldType::Number => { + if let Ok(num) = param_value.parse::() { + Value::Number(num.into()) + } else if let Ok(float) = param_value.parse::() { + match serde_json::Number::from_f64(float) { + Some(n) => Value::Number(n), + None => { + return Poll::Ready(Ok( + format!("Invalid path parameter type for {}: could not convert to number", param_name) + .into_response() + )); + } + } + } else { + return Poll::Ready(Ok(format!( + "Invalid path parameter type for {}: expected a number", + param_name + ) + .into_response())); + } + } + FieldType::Bool => match param_value.to_lowercase().as_str() { + "true" => Value::Bool(true), + "false" => Value::Bool(false), + _ => { + return Poll::Ready(Ok( + format!("Invalid path parameter type for {}: expected a boolean", param_name) + .into_response() + )); + } + }, + _ => { + return Poll::Ready(Ok(format!( + "Unsupported path parameter type for {}", + param_name + ) + .into_response())); + } + }; + + // Validate the value using our existing validation infrastructure + if let Err(e) = Self::validate_field(field, &value) { + return Poll::Ready(Ok(e.into_response())); + } + + // Store the validated parameter + self.path_data.insert(param_name.to_string(), value); + } + } + + // Check for missing required parameters + for field in &self.endpoint.path_params { + if !self.path_data.contains_key(&field.name) && field.required { + return Poll::Ready(Ok( + ServerError::MissingField(field.name.clone()).into_response() + )); + } + } + + // Move to the next state self.state = ProcessingState::ValidatingQuery; } ProcessingState::ValidatingQuery => { @@ -400,6 +497,7 @@ impl Future for RequestProcessor { } else { None }; + let path_data = mem::take(&mut self.path_data); let query_data = mem::take(&mut self.query_data); let body_data = mem::take(&mut self.body_data); let pg_connection = self.endpoint.pg_connection.clone(); @@ -417,6 +515,7 @@ impl Future for RequestProcessor { vm.eval_function( 0, &[ + Value::Object(path_data.into_iter().collect()), Value::Object(query_data.into_iter().collect()), Value::Object(body_data.into_iter().collect()), Value::Object( diff --git a/aiscript-runtime/src/lib.rs b/aiscript-runtime/src/lib.rs index fd7291d..c763b58 100644 --- a/aiscript-runtime/src/lib.rs +++ b/aiscript-runtime/src/lib.rs @@ -210,6 +210,7 @@ async fn run_server( for endpoint_spec in route.endpoints { let endpoint = Endpoint { annotation: endpoint_spec.annotation.or(&route.annotation), + path_params: endpoint_spec.path.into_iter().map(convert_field).collect(), query_params: endpoint_spec.query.into_iter().map(convert_field).collect(), body_type: endpoint_spec.body.kind, body_fields: endpoint_spec diff --git a/aiscript-runtime/src/parser.rs b/aiscript-runtime/src/parser.rs index 87ab9c9..e9b0bcb 100644 --- a/aiscript-runtime/src/parser.rs +++ b/aiscript-runtime/src/parser.rs @@ -87,6 +87,7 @@ impl<'a> Parser<'a> { let docs = self.parse_docs(); // Parse structured parts (query and body) + let mut path = Vec::new(); let mut query = Vec::new(); let mut body = RequestBody::default(); @@ -98,6 +99,9 @@ impl<'a> Parser<'a> { } else if self.scanner.check_identifier("body") { self.advance(); body.fields = self.parse_fields()?; + } else if self.scanner.check_identifier("path") { + self.advance(); + path = self.parse_fields()?; } else if self.scanner.check(TokenType::At) { let directives = DirectiveParser::new(&mut self.scanner).parse_directives(); for directive in directives { @@ -126,12 +130,16 @@ impl<'a> Parser<'a> { } // Parse the handler function body let script = self.read_raw_script()?; - let statements = format!("ai fn handler(query, body, request, header){{{}}}", script); + let statements = format!( + "ai fn handler(path, query, body, request, header){{{}}}", + script + ); self.consume(TokenType::CloseBrace, "Expect '}' after endpoint")?; Ok(Endpoint { annotation, path_specs, return_type: None, + path, query, body, statements, @@ -304,30 +312,26 @@ impl<'a> Parser<'a> { path.push('/'); self.advance(); } - TokenType::Less => { - self.advance(); // Consume < + TokenType::Colon => { + self.advance(); // Consume : // Parse parameter name if !self.check(TokenType::Identifier) { - return Err("Expected parameter name".to_string()); + return Err("Expected parameter name after ':'".to_string()); } let name = self.current.lexeme.to_string(); self.advance(); - self.consume(TokenType::Colon, "Expected ':' after parameter name")?; - - // Parse parameter type - if !self.check(TokenType::Identifier) { - return Err("Expected parameter type".to_string()); - } - let param_type = self.current.lexeme.to_string(); - self.advance(); - - self.consume(TokenType::Greater, "Expected '>' after parameter type")?; - - path.push(':'); + // Add parameter to path in the format Axum expects: {id} + path.push('{'); path.push_str(&name); - params.push(PathParameter { name, param_type }); + path.push('}'); + + // Add parameter to our list + params.push(PathParameter { + name, + param_type: "str".to_string(), // Default type, will be overridden by path block + }); } TokenType::Identifier => { path.push_str(self.current.lexeme); diff --git a/examples/routes/path.ai b/examples/routes/path.ai new file mode 100644 index 0000000..144b12c --- /dev/null +++ b/examples/routes/path.ai @@ -0,0 +1,16 @@ +get /users/:id/posts/:postId { + path { + @string(min_len=3) + id: str, + postId: int, + } + + query { + refresh: bool = true + } + + print(path); + let userId = path.id; + let postId = path.postId; + return f"Accessing post {postId} for user {userId}"; +} \ No newline at end of file