diff --git a/README.md b/README.md index eb832eb..d81c270 100644 --- a/README.md +++ b/README.md @@ -141,7 +141,7 @@ Hyperware processes can handle three types of requests, specified by attributes: |-----------|-------------| | `#[local]` | Handles local (same-node) requests | | `#[remote]` | Handles remote (cross-node) requests | -| `#[http]` | Handles HTTP requests to your process endpoints | +| `#[http]` | Handles ALL HTTP requests (GET, POST, PUT, DELETE, etc.) | These attributes can be combined to make a handler respond to multiple request types: @@ -153,6 +153,12 @@ async fn increment_counter(&mut self, value: i32) -> i32 { self.counter } +#[http] +fn handle_any_http(&mut self) -> String { + // This handles ALL HTTP methods (GET, POST, PUT, DELETE, etc.) + format!("Handled request to path: {}", get_path().unwrap_or_default()) +} + #[remote] fn get_status(&mut self) -> String { format!("Status: {}", self.counter) @@ -161,6 +167,213 @@ fn get_status(&mut self) -> String { The function arguments and the return values _have_ to be serializable with `Serde`. +#### HTTP Method Support and Smart Routing + +The `#[http]` attribute supports intelligent routing with priority-based matching: + +```rust +// Specific path handlers (highest priority) +#[http(method = "GET", path = "/api/users")] +fn list_users(&mut self) -> Vec { + // Matches ONLY GET /api/users - no request body needed + self.users.clone() +} + +#[http(method = "POST", path = "/api/users")] +async fn create_user(&mut self, user: CreateUser) -> Result { + // Matches POST /api/users with {"CreateUser": {...}} body + let new_user = User::from(user); + self.users.push(new_user.clone()); + Ok(new_user) +} + +// Dynamic method-only handlers (medium priority) +#[http(method = "GET")] +fn handle_get_fallback(&mut self) -> ApiResponse { + let path = get_path().unwrap_or_default(); + match path.as_str() { + p if p.starts_with("/api/") => ApiResponse::new(&format!("API GET for {}", p)), + _ => ApiResponse::new("General GET handler") + } +} + +#[http(method = "POST")] +async fn handle_post_with_data(&mut self, data: PostData) -> Result { + let path = get_path().unwrap_or_default(); + // This handles POST to any path (except those with specific handlers) + // Expects {"HandlePostWithData": {...}} in request body + Ok(format!("Processed POST to {} with data", path)) +} + +// Ultimate fallback (lowest priority) +#[http] +fn handle_any_method(&mut self) -> Response { + let method = get_http_method().unwrap_or_default(); + let path = get_path().unwrap_or_default(); + Response::new(&format!("Catch-all: {} {}", method, path)) +} +``` + +**Supported Methods**: `GET`, `POST`, `PUT`, `DELETE`, `PATCH`, `HEAD`, `OPTIONS` + +### Smart Routing System + +The framework uses intelligent priority-based routing that automatically chooses the best handler based on the request: + +#### **Priority Logic:** + +1. **Has Request Body** → Tries parameterized handlers first + - Deserializes body to determine the correct handler + - Falls back to parameter-less handlers if deserialization fails + +2. **No Request Body** → Tries parameter-less handlers first + - Routes based on path and method matching + - Never attempts body deserialization for performance + +#### **Two-Phase Matching:** + +**Phase 1: Direct Path/Method Matching** +```rust +// These are matched instantly without body parsing +#[http(method = "GET", path = "/health")] +fn health_check(&mut self) -> &'static str { "OK" } + +#[http(method = "DELETE", path = "/api/users")] +fn delete_all_users(&mut self) -> Result { + // DELETE requests typically have no body + self.users.clear(); + Ok("All users deleted".to_string()) +} +``` + +**Phase 2: Body-Based Handler Discovery** +```rust +// These are matched by deserializing the request body +#[http(method = "POST")] +async fn create_item(&mut self, item: NewItem) -> ItemResponse { + // Expects: {"CreateItem": {"name": "...", "price": 123}} + // Body deserialization determines this is the right handler + self.items.push(item.into()); + ItemResponse { success: true } +} + +#[http(method = "POST")] +async fn update_settings(&mut self, settings: UpdateSettings) -> Result { + // Expects: {"UpdateSettings": {...}} + // Different POST handler - body content determines routing + self.apply_settings(settings)?; + Ok("Settings updated".to_string()) +} +``` + +#### **Context Preservation in Async Handlers:** + +The framework automatically preserves request context in async handlers: + +```rust +#[http(method = "POST")] +async fn async_handler(&mut self, data: MyData) -> Result { + // get_path() and get_http_method() work correctly in async handlers! + let path = get_path().unwrap_or_default(); + let method = get_http_method().unwrap_or_default(); + + // Make async calls to other services + let result = external_api_call(data).await?; + + Ok(format!("Processed {} {} with result: {}", method, path, result)) +} +``` + +**Routing Priority Examples:** + +```rust +// Request: POST /api/upload (with body) +// 1. ✅ Tries: create_item handler if body matches {"CreateItem": ...} +// 2. ✅ Tries: update_settings handler if body matches {"UpdateSettings": ...} +// 3. ✅ Falls back to: handle_post_with_data for unmatched bodies +// 4. ✅ Ultimate fallback: handle_any_method + +// Request: GET /api/users (no body) +// 1. ✅ Tries: list_users (exact path match) +// 2. ✅ Falls back to: handle_get_fallback (method-only match) +// 3. ✅ Ultimate fallback: handle_any_method +``` + +#### Path-Specific Routing + +You can bind HTTP handlers to specific paths using the `path` parameter: + +```rust +#[hyperprocess( + endpoints = vec![ + Binding::Http { path: "/api", config: HttpBindingConfig::default() }, + Binding::Http { path: "/admin", config: HttpBindingConfig::default() } + ], + // ... other params +)] +impl MyApp { + // Parameter-less handler (path REQUIRED) + #[http(method = "GET", path = "/api/users")] + fn list_users(&mut self) -> Vec { + // This handler ONLY responds to GET /api/users + self.users.clone() + } + + // Handler with parameters (path optional but recommended) + #[http(method = "POST", path = "/api/users")] + fn create_user(&mut self, user: NewUser) -> User { + // This handler ONLY responds to POST /api/users + let user = User::from(user); + self.users.push(user.clone()); + user + } + + // Parameter-less handler accepting all methods (path optional) + #[http(path = "/api/status")] + fn api_status(&mut self) -> Status { + // This handles ALL methods to /api/status + Status { healthy: true } + } + + // Parameter-less handler for any path - uses get_path() for routing + #[http] + fn dynamic_handler(&mut self) -> Response { + match get_path().as_deref() { + Some("/api/health") => Response::ok("Healthy"), + Some("/api/version") => Response::ok("1.0.0"), + _ => Response::not_found("Unknown endpoint") + } + } + + // Handler with parameters without specific path + #[http(method = "POST")] + fn generic_post_handler(&mut self, data: GenericData) -> Response { + // This handles POST requests with matching body to any path + Response::ok() + } +} +``` + +**Path Binding**: When you specify a path, the handler will ONLY respond to requests for that exact path. + +**Routing Priority**: +1. Parameter-less handlers with exact path and method match +2. Parameter-less handlers with exact path (any method) +3. Handlers with parameters matched by request body deserialization +4. Error responses (404 Not Found or 405 Method Not Allowed) + +**Compile-Time Validations**: +- All handler names must be unique when converted to CamelCase (e.g., `get_user` and `get_user` conflict) +- Init methods must be async and take only `&mut self` +- WebSocket methods must have exactly 3 parameters: `channel_id: u32`, `message_type: WsMessageType`, `blob: LazyLoadBlob` +- At least one handler must be defined (`#[http]`, `#[local]`, or `#[remote]`) +- The macro provides comprehensive error messages with debugging tips for all validation failures + +**Current Limitations**: +- All requests with parameters expect JSON request bodies in the format `{"HandlerName": parameter_value}` +- No automatic query parameter binding (use `get_query_params()` to access them manually) +- WebSocket path routing requires manual checking with `get_path()` in the WebSocket handler + ### Special Methods #### Init Method @@ -287,6 +500,460 @@ async fn my_function() { } ``` +## Query Parameters + +For GET requests, query parameters can be accessed using the `get_query_params()` helper function from `hyperware_app_common`: + +```rust +use hyperware_app_common::get_query_params; + +#[http(method = "GET")] +fn search(&mut self) -> SearchResults { + if let Some(params) = get_query_params() { + let query = params.get("q").unwrap_or(&"".to_string()); + // Process search query + } + SearchResults::default() +} +``` + +### Example: Query Parameter Parsing + +For a request to `/api/search?q=rust&limit=20&sort=date`: + +```rust +#[http(method = "GET")] +fn search(&mut self) -> Vec { + if let Some(params) = get_query_params() { + // params is a HashMap with: + // {"q" => "rust", "limit" => "20", "sort" => "date"} + + // Get search query (with default) + let query = params.get("q") + .map(|s| s.to_string()) + .unwrap_or_else(|| "".to_string()); + + // Parse numeric parameters + let limit = params.get("limit") + .and_then(|s| s.parse::().ok()) + .unwrap_or(10); + + // Get optional parameters + let sort_by = params.get("sort") + .map(|s| s.as_str()) + .unwrap_or("relevance"); + + // Use the parameters + self.perform_search(&query, limit, sort_by) + } else { + // No query parameters - return empty results + vec![] + } +} +``` + +**Note**: All query parameter values are strings, so you need to parse them to other types as needed. + +## Error Handling and Debugging + +The framework provides comprehensive error handling and debugging capabilities: + +### Comprehensive Logging + +The macro generates detailed logging for all operations: + +```rust +// Automatically generated logs help track request flow: +// Phase 1: Checking parameter-less handlers for path: '/api/users', method: 'GET' +// Successfully parsed HTTP path: '/api/users' +// Set current_path to: Some("/api/users") +// Set current_http_method to: Some("GET") +``` + +#### **Request Body Parsing Errors:** + +``` +// Wrong handler name +Invalid request format. Expected one of the parameterized handler formats, +but got: {"WrongHandler":{"message":"test"}} + +// Invalid JSON syntax +Invalid JSON in request body. Expected: {"CreateUser":[ ...parameters... ]}. +Parse error: expected value at line 1 column 1 + +// Empty body for parameterized handler +Request body is empty. This handler expects a JSON object with the handler name and parameters. +``` + +#### **Handler-Specific Errors:** + +```rust +// If a parameterized handler expects a body but doesn't get one: +POST /api/users → "Handler CreateUser requires a request body" + +// If no handler matches the method + path combination: +PUT /nonexistent → "No handler found for PUT /nonexistent" +``` + +#### **Development Tips:** + +``` +Failed to deserialize HTTP request into HPMRequest enum. +Error: missing field `CreateUser` at line 1 column 15 +Path: /api/users +Method: POST +Body received: +{ + "createuser": "john_doe" // ❌ Wrong case! Should be "CreateUser" +} + +Debugging tips: +- Handler names are converted to CamelCase: create_user → CreateUser +- JSON keys are case-sensitive: use exact CamelCase handler names +- For handlers with parameters, use format {"HandlerName": parameter_value} +- For parameter-less handlers, use get_path() and get_http_method() for routing +``` + +### Compile-Time Validation + +The macro catches configuration errors at compile time: + +```rust +// This will fail to compile: +#[hyperprocess( + name = "test-app", + // Missing required 'endpoints' parameter + save_config = SaveOptions::Never, + wit_world = "my-world" +)] +impl MyApp { + #[init] + fn init_app(&mut self) -> String { // ERROR: Init should not return value + "initialized".to_string() + } +} +``` + +### Context Access Helpers + +Use these functions to access request context within handlers: + +| Function | Returns | Description | +|----------|---------|-------------| +| `get_path()` | `Option` | Current HTTP path | +| `get_http_method()` | `Option` | Current HTTP method | +| `get_query_params()` | `Option>` | Query parameters | +| `source()` | `Address` | Address of the message sender | + +### ⚠️ Known Limitations and Gotchas + +#### **Request Body Format Requirements:** + +```rust +// ❌ This won't work - body expects specific JSON format +POST /api/users +Content-Type: application/json +{ + "name": "John Doe", + "email": "john@example.com" +} + +// ✅ This works - body must wrap parameters in handler name +POST /api/users +Content-Type: application/json +{ + "CreateUser": { + "name": "John Doe", + "email": "john@example.com" + } +} +``` + +**Why:** The framework uses the outer JSON key to determine which handler to invoke. This enables multiple handlers to respond to the same HTTP method + path combination. + +#### **Handler Name Case Sensitivity:** + +```rust +#[http(method = "POST")] +async fn create_user(&mut self, user: User) -> Result { ... } + +// ❌ Won't match - wrong case +{"create_user": {...}} +{"createUser": {...}} + +// ✅ Will match - exact CamelCase +{"CreateUser": {...}} +``` + +**Solution:** Always use exact CamelCase conversion: `snake_case` → `CamelCase` + +#### **Async Context Limitations:** + +```rust +#[http(method = "POST")] +async fn async_handler(&mut self, data: MyData) -> String { + // ✅ Works - context is preserved by the framework + let path = get_path().unwrap_or_default(); + + // ⚠️ Potential issue - long-running tasks might lose context + tokio::time::sleep(Duration::from_secs(30)).await; + let path2 = get_path(); // May be None if context expires + + format!("Path: {}", path) +} +``` + +**Why:** The framework preserves context by capturing it before async execution, but very long-running tasks might exceed context lifetime. + +#### **Query Parameter Encoding:** + +```rust +// URL: /api/search?q=hello%20world&tags=rust,web +let params = get_query_params().unwrap(); + +// ❌ Raw values - URL encoding not automatically decoded +assert_eq!(params.get("q"), Some("hello%20world")); // Still encoded + +// ✅ Manual decoding needed for special characters +let query = params.get("q") + .map(|s| urlencoding::decode(s).unwrap().into_owned()) + .unwrap_or_default(); // "hello world" +``` + +**Solution:** Use a URL decoding library for complex query parameters. + +#### **No Built-in Content Negotiation:** + +```rust +#[http(method = "POST")] +fn upload_file(&mut self, data: FileData) -> String { + // ❌ Only handles JSON - no multipart/form-data, XML, etc. + // Framework always expects JSON body format +} +``` + +**Workaround:** Use parameter-less handlers and manually parse request bodies: + +```rust +#[http(method = "POST", path = "/upload")] +fn handle_file_upload(&mut self) -> Result { + // Get raw request body and parse manually + // Implementation depends on your specific needs +} +``` + +#### **Single Handler Per Variant:** + +```rust +// ❌ This won't compile - duplicate handler names become same variant +#[http(method = "POST")] +fn create_user(&mut self, user: User) -> User { ... } + +#[http(method = "PUT")] +fn create_user(&mut self, user: User) -> User { ... } // ERROR: Duplicate CreateUser variant +``` + +**Solution:** Use different method names or combine logic: + +```rust +#[http(method = "POST")] +fn create_user(&mut self, user: User) -> User { ... } + +#[http(method = "PUT")] +fn update_user(&mut self, user: User) -> User { ... } + +// Or combine with method checking: +#[http] +fn user_handler(&mut self, user: User) -> User { + match get_http_method().as_deref() { + Some("POST") => self.create_user_impl(user), + Some("PUT") => self.update_user_impl(user), + _ => panic!("Unsupported method") + } +} +``` + + +#### **Performance Considerations:** + +- **Body parsing overhead:** Every parameterized request requires JSON deserialization +- **Context preservation:** Async handlers have slight overhead from context capture/restore +- **Priority matching:** Requests with bodies try parameterized handlers first, which may be slower + +**Optimization tips:** +- Use parameter-less handlers for high-frequency endpoints (health checks, metrics) +- Use specific paths instead of catch-all handlers when possible +- Batch multiple operations into single handlers when possible +- Consider caching for expensive operations within handlers + +### Two-Phase HTTP Routing (Updated!) + +The routing system now uses **intelligent request-body-aware routing**: + +#### **Smart Phase Selection:** + +**For requests WITH body data:** +1. **Phase 1: Body Deserialization** - Tries to deserialize body to find matching parameterized handler +2. **Phase 2: Parameter-less Fallback** - Falls back to parameter-less handlers if deserialization fails + +**For requests WITHOUT body data:** +1. **Phase 1: Direct Matching** - Matches parameter-less handlers by path and HTTP method +2. **Phase 2: Not Used** - No body parsing attempted (performance optimization) + +#### **Routing Flow Examples:** + +```rust +// High-priority specific handlers +#[http(method = "GET", path = "/health")] +fn health_check(&mut self) -> &'static str { "OK" } + +#[http(method = "POST", path = "/api/users")] +async fn create_specific_user(&mut self, user: NewUser) -> User { ... } + +// Medium-priority dynamic handlers +#[http(method = "POST")] +async fn create_general(&mut self, data: CreateData) -> Response { ... } + +// Low-priority catch-all +#[http] +fn catch_all(&mut self) -> Response { ... } +``` + +**Request: `GET /health` (no body)** +1. ✅ Matches `health_check` directly (exact path + method) +2. ❌ No body parsing attempted + +**Request: `POST /api/users` with body `{"CreateSpecificUser": {...}}`** +1. ✅ Matches `create_specific_user` (path + method + body deserialization) +2. ❌ No fallback needed + +**Request: `POST /api/items` with body `{"CreateGeneral": {...}}`** +1. ❌ No exact path match for `/api/items` +2. ✅ Deserializes body → matches `create_general` (method + body) + +**Request: `PUT /anything` (no body)** +1. ❌ No parameter-less handler for `PUT /anything` +2. ✅ Matches `catch_all` handler + +This intelligent routing ensures optimal performance by avoiding unnecessary body parsing for requests without bodies, while providing maximum flexibility for complex routing scenarios. + +## Common Patterns and Best Practices + +### Parameter-less vs Parameter-based Handlers + +Choose the right handler type for your use case: + +```rust +// ✅ Good: Parameter-less handler for simple endpoints +#[http(method = "GET", path = "/health")] +fn health_check(&mut self) -> &'static str { + "OK" +} + +// ✅ Good: Parameter-less handler with dynamic routing +#[http] +fn api_router(&mut self) -> Response { + match (get_http_method().as_deref(), get_path().as_deref()) { + (Some("GET"), Some("/api/users")) => self.list_users(), + (Some("GET"), Some("/api/stats")) => self.get_stats(), + _ => Response::not_found("Endpoint not found") + } +} + +// ✅ Good: Parameter-based handler for complex data +#[http(method = "POST")] +async fn create_user(&mut self, user: CreateUserRequest) -> Result { + // Validates and deserializes complex request automatically + self.users.push(user.into()); + Ok(user) +} +``` + +### Error Handling Patterns + +```rust +// ✅ Good: Use Result types for handlers that can fail +#[http] +fn risky_operation(&mut self, input: String) -> Result { + if input.is_empty() { + Err("Input cannot be empty".to_string()) + } else { + Ok(format!("Processed: {}", input)) + } +} + +// ✅ Good: Use custom error types for better error handling +#[derive(Serialize, Deserialize)] +pub enum ApiError { + ValidationError(String), + NotFound(String), + InternalError, +} + +#[http] +fn validated_handler(&mut self, data: InputData) -> Result { + data.validate() + .map_err(|e| ApiError::ValidationError(e))?; + + self.process(data) + .map_err(|_| ApiError::InternalError) +} +``` + +### State Management Best Practices + +```rust +#[derive(Default, Serialize, Deserialize)] +struct MyAppState { + // ✅ Use reasonable defaults + pub counter: u64, + pub users: Vec, + + // ✅ Use Options for optional state + pub last_sync: Option, + + // ✅ Group related data + pub config: AppConfig, +} + +impl MyAppState { + // ✅ Provide helper methods for common operations + fn increment_counter(&mut self) -> u64 { + self.counter += 1; + self.counter + } + + fn add_user(&mut self, user: User) -> Result<(), String> { + if self.users.iter().any(|u| u.id == user.id) { + return Err("User already exists".to_string()); + } + self.users.push(user); + Ok(()) + } +} +``` + +### Async Handler Patterns + +```rust +#[http] +async fn fetch_external_data(&mut self, query: String) -> Result { + // ✅ Good: Use the built-in send function for RPC calls + let result = send::( + ExternalApiRequest { query }, + &external_service_address(), + 10 // timeout in seconds + ).await; + + match result { + SendResult::Success(response) => Ok(response.data), + SendResult::Timeout => Err("Request timed out".to_string()), + SendResult::Offline => Err("External service unavailable".to_string()), + SendResult::DeserializationError(e) => Err(format!("Invalid response: {}", e)), + } +} +``` + ## Part 2: Technical Implementation ### Architecture Overview @@ -406,11 +1073,41 @@ fn generate_component_impl(...) -> proc_macro2::TokenStream { The flow of a request through the system: 1. Message arrives (HTTP, local, or remote) -2. Main event loop deserializes it into a Request enum -3. Appropriate handler is dispatched based on message type -4. For async handlers, the future is spawned on the executor -5. When handler completes, response is serialized and sent back -6. For async handlers, awaiting futures are resumed with the response +2. For HTTP requests: + - First attempts to match parameter-less handlers by path and method + - If no match, attempts to deserialize body and match handlers with parameters +3. For local/remote requests: + - Deserializes body into Request enum +4. Appropriate handler is dispatched based on message type +5. For async handlers, the future is spawned on the executor +6. When handler completes, response is serialized and sent back +7. For async handlers, awaiting futures are resumed with the response + +#### HTTP Request Routing Details + +The HTTP routing system uses a two-phase approach: + +**Phase 1: Parameter-less Handler Matching** +- Checks incoming path and HTTP method against parameter-less handlers +- These handlers are matched directly without body deserialization +- Useful for GET endpoints, health checks, and other body-less requests + +**Phase 2: Body-Based Handler Matching** +- Only triggered if no parameter-less handler matches +- Deserializes request body to determine which handler to invoke +- Matches handlers that expect parameters + +This design allows clean APIs for simple endpoints while maintaining flexibility for complex requests: + +```rust +// Phase 1: Matched by path/method +#[http(method = "GET", path = "/health")] +fn health_check(&mut self) -> &'static str { "OK" } + +// Phase 2: Matched by body deserialization +#[http(method = "POST")] +fn process_data(&mut self, data: MyData) -> Result { ... } +``` ### Async Runtime diff --git a/hyperprocess_macro/Cargo.toml b/hyperprocess_macro/Cargo.toml index 74f4b76..6a86d77 100644 --- a/hyperprocess_macro/Cargo.toml +++ b/hyperprocess_macro/Cargo.toml @@ -9,13 +9,12 @@ proc-macro = true [dependencies] proc-macro2 = "1.0" quote = "1.0" -syn = { version = "2.0", features = ["full"] } +syn = { version = "2.0", features = ["full", "extra-traits"] } anyhow = "1.0" futures-util = "0.3" hyperware_app_common = { path = "../hyperware_app_common" } -#hyperware_process_lib = { version = "1.0.4", features = ["logging"] } -hyperware_process_lib = { git = "https://github.com/hyperware-ai/process_lib", features = ["logging"], rev = "b7c9d27" } +hyperware_process_lib = { git = "https://github.com/hyperware-ai/process_lib", features = ["logging"], rev = "cfd6843" } once_cell = "1.20.2" paste = "1.0" process_macros = "0.1.0" diff --git a/hyperprocess_macro/src/lib.rs b/hyperprocess_macro/src/lib.rs index e346041..f79c917 100644 --- a/hyperprocess_macro/src/lib.rs +++ b/hyperprocess_macro/src/lib.rs @@ -36,6 +36,7 @@ struct HyperProcessArgs { } /// Metadata for a function in the implementation block +#[derive(Clone)] struct FunctionMetadata { name: syn::Ident, // Original function name variant_name: String, // CamelCase variant name @@ -45,6 +46,8 @@ struct FunctionMetadata { is_local: bool, // Has #[local] attribute is_remote: bool, // Has #[remote] attribute is_http: bool, // Has #[http] attribute + http_methods: Vec, // HTTP methods this handler accepts (GET, POST, etc.) + http_path: Option, // Specific path this handler is bound to (optional) } /// Enum for the different handler types @@ -205,6 +208,121 @@ fn has_attribute(method: &syn::ImplItemFn, attr_name: &str) -> bool { .any(|attr| attr.path().is_ident(attr_name)) } +/// Parse HTTP methods and path from the #[http] attribute +/// Supports: #[http], #[http(method = "GET")], #[http(method = "POST", path = "/api")] +fn parse_http_attributes(method: &syn::ImplItemFn) -> (Vec, Option) { + for attr in &method.attrs { + if attr.path().is_ident("http") { + // Handle #[http] with no arguments - defaults to ALL methods + if matches!(&attr.meta, syn::Meta::Path(_)) { + return ( + vec![ + "GET".to_string(), + "POST".to_string(), + "PUT".to_string(), + "DELETE".to_string(), + "PATCH".to_string(), + "HEAD".to_string(), + "OPTIONS".to_string(), + ], + None, + ); + } + + // Handle #[http(method = "GET", path = "/api")] + if let syn::Meta::List(list) = &attr.meta { + let mut methods = None; + let mut path = None; + + // Parse the token stream manually + let tokens: Vec<_> = list.tokens.clone().into_iter().collect(); + let mut i = 0; + + while i < tokens.len() { + // Look for identifier (method or path) + if let proc_macro2::TokenTree::Ident(ident) = &tokens[i] { + let ident_str = ident.to_string(); + + // Check for = sign + if i + 2 < tokens.len() { + if let proc_macro2::TokenTree::Punct(punct) = &tokens[i + 1] { + if punct.as_char() == '=' { + // Get the string literal + if let proc_macro2::TokenTree::Literal(lit) = &tokens[i + 2] { + let lit_str = lit.to_string(); + // Remove quotes from the literal + let value = lit_str.trim_matches('"'); + + if ident_str == "method" { + let method = value.to_uppercase(); + if matches!( + method.as_str(), + "GET" + | "POST" + | "PUT" + | "DELETE" + | "PATCH" + | "HEAD" + | "OPTIONS" + ) { + methods = Some(vec![method]); + } + } else if ident_str == "path" { + path = Some(value.to_string()); + } + } + i += 3; // Skip ident, =, and literal + + // Skip comma if present + if i < tokens.len() { + if let proc_macro2::TokenTree::Punct(punct) = &tokens[i] { + if punct.as_char() == ',' { + i += 1; + } + } + } + continue; + } + } + } + } + i += 1; + } + + // Default to ALL methods if none specified + let final_methods = methods.unwrap_or_else(|| { + vec![ + "GET".to_string(), + "POST".to_string(), + "PUT".to_string(), + "DELETE".to_string(), + "PATCH".to_string(), + "HEAD".to_string(), + "OPTIONS".to_string(), + ] + }); + + return (final_methods, path); + } + + // Default to ALL methods if parsing fails + return ( + vec![ + "GET".to_string(), + "POST".to_string(), + "PUT".to_string(), + "DELETE".to_string(), + "PATCH".to_string(), + "HEAD".to_string(), + "OPTIONS".to_string(), + ], + None, + ); + } + } + (Vec::new(), None) +} + /// Remove our custom attributes from the implementation block fn clean_impl_block(impl_block: &ItemImpl) -> ItemImpl { let mut cleaned_impl_block = impl_block.clone(); @@ -490,9 +608,12 @@ fn analyze_methods( // Handle request-response methods if has_http || has_local || has_remote { validate_request_response_function(method)?; - function_metadata.push(extract_function_metadata( - method, has_local, has_remote, has_http, - )); + let metadata = extract_function_metadata(method, has_local, has_remote, has_http); + + // Parameter-less HTTP handlers can optionally specify a path, but it's not required + // They can use get_path() and get_method() to handle requests dynamically + + function_metadata.push(metadata); } } } @@ -505,6 +626,40 @@ fn analyze_methods( )); } + // Check for duplicate HTTP (method + path) combinations + // Only validate specific paths - allow multiple method-only handlers for dynamic routing + let mut http_routes = std::collections::HashMap::new(); + for func in &function_metadata { + if func.is_http { + // Only validate handlers with specific paths + if let Some(path) = &func.http_path { + for method in &func.http_methods { + let route_key = (method.clone(), path.clone()); + if let Some(existing_handler) = http_routes.get(&route_key) { + return Err(syn::Error::new( + proc_macro2::Span::call_site(), + format!( + "Duplicate HTTP route detected: {} {}\n\ + First handler: {}\n\ + Second handler: {}\n\ + \n\ + Each (method + specific path) combination must map to exactly one handler.\n\ + Consider:\n\ + - Using different paths for different handlers\n\ + - Combining the logic into a single handler\n\ + - Using method-only handlers with get_path() for dynamic routing", + method, path, existing_handler, func.name + ), + )); + } + http_routes.insert(route_key, &func.name); + } + } + // Method-only handlers (no specific path) are allowed to coexist + // They can use get_path() at runtime to implement custom routing logic + } + } + Ok((init_method, ws_method, function_metadata, has_init_logging)) } @@ -541,6 +696,13 @@ fn extract_function_metadata( // Create variant name (snake_case to CamelCase) let variant_name = to_camel_case(&ident.to_string()); + // Parse HTTP attributes if this is an HTTP handler + let (http_methods, http_path) = if is_http { + parse_http_attributes(method) + } else { + (Vec::new(), None) + }; + FunctionMetadata { name: ident, variant_name, @@ -550,6 +712,8 @@ fn extract_function_metadata( is_local, is_remote, is_http, + http_methods, + http_path, } } @@ -598,13 +762,17 @@ fn generate_request_response_enums( return (quote! {}, quote! {}); } - // HPMRequest enum variants - let request_variants = function_metadata.iter().map(|func| { - let variant_name = format_ident!("{}", &func.variant_name); - generate_enum_variant(&variant_name, &func.params) - }); + // HPMRequest enum variants - ONLY include handlers that have parameters + // Parameter-less handlers are dispatched directly in Phase 1, not through enum deserialization + let request_variants = function_metadata + .iter() + //.filter(|func| !func.params.is_empty()) // Only include handlers with parameters + .map(|func| { + let variant_name = format_ident!("{}", &func.variant_name); + generate_enum_variant(&variant_name, &func.params) + }); - // HPMResponse enum variants + // HPMResponse enum variants - include ALL handlers since they all need to return responses let response_variants = function_metadata.iter().map(|func| { let variant_name = format_ident!("{}", &func.variant_name); @@ -648,7 +816,7 @@ fn generate_enum_variant( if params.is_empty() { // Changed to a struct variant with no fields for functions with no parameters // This matches the JSON format {"VariantName": {}} sent by the client - quote! { #variant_name{} } + quote! { #variant_name } } else if params.len() == 1 { // Simple tuple variant for single parameter let param_type = ¶ms[0]; @@ -744,11 +912,26 @@ fn generate_response_handling( quote! { // Instead of wrapping in HPMResponse enum, directly serialize the result let response_bytes = serde_json::to_vec(&result).unwrap(); + + // Get headers from the current HTTP context + let headers_opt = hyperware_app_common::APP_HELPERS.with(|helpers| { + helpers.borrow().current_http_context.as_ref().and_then(|ctx| { + if ctx.response_headers.is_empty() { + None + } else { + Some(ctx.response_headers.clone()) + } + }) + }); + hyperware_process_lib::http::server::send_response( hyperware_process_lib::http::StatusCode::OK, - None, + headers_opt, response_bytes ); + + // Clear HTTP context immediately after sending the response + hyperware_app_common::clear_http_request_context(); } } } @@ -902,135 +1085,506 @@ fn ws_method_opt_to_call(ws_method: &Option) -> proc_macro2::TokenSt } } -/// Generate handler functions for message types -fn generate_message_handlers( - self_ty: &Box, - handler_arms: &HandlerDispatch, - ws_method_call: &proc_macro2::TokenStream, +//------------------------------------------------------------------------------ +// HTTP Helper Functions +//------------------------------------------------------------------------------ + +/// Generate HTTP context setup code +fn generate_http_context_setup() -> proc_macro2::TokenStream { + quote! { + hyperware_app_common::APP_HELPERS.with(|helpers| { + helpers.borrow_mut().current_http_context = Some(hyperware_app_common::HttpRequestContext { + request: http_request, + response_headers: std::collections::HashMap::new(), + }); + }); + hyperware_process_lib::logging::debug!("HTTP context established"); + } +} + +/// Generate HTTP context cleanup code +fn generate_http_context_cleanup() -> proc_macro2::TokenStream { + quote! { + hyperware_app_common::clear_http_request_context(); + } +} + +/// Generate HTTP error response handling +fn generate_http_error_response( + status: &str, + message: proc_macro2::TokenStream, ) -> proc_macro2::TokenStream { - let http_request_match_arms = &handler_arms.http; - let local_request_match_arms = &handler_arms.local; - let remote_request_match_arms = &handler_arms.remote; - // We now use the combined local_and_remote handlers for local messages - let local_and_remote_request_match_arms = &handler_arms.local_and_remote; + let status_ident = format_ident!("{}", status); + let cleanup = generate_http_context_cleanup(); + quote! { + hyperware_process_lib::http::server::send_response( + hyperware_process_lib::http::StatusCode::#status_ident, + None, + #message.into_bytes() + ); + #cleanup + } +} +/// Generate HTTP method and path parsing code +fn generate_http_request_parsing() -> proc_macro2::TokenStream { quote! { - /// Handle messages from the HTTP server - fn handle_http_server_message(state: *mut #self_ty, message: hyperware_process_lib::Message) { - // Parse HTTP server request - match serde_json::from_slice::(message.body()) { - Ok(http_server_request) => { - match http_server_request { - hyperware_process_lib::http::server::HttpServerRequest::Http(http_request) => { - hyperware_app_common::APP_HELPERS.with(|ctx| { - ctx.borrow_mut().current_path = Some(http_request.path().clone().expect("Failed to get path from HTTP request")); - }); + let http_method = hyperware_app_common::get_http_method() + .unwrap_or_else(|| { + hyperware_process_lib::logging::warn!("Failed to get HTTP method from request context"); + "UNKNOWN".to_string() + }); - // Get the blob containing the actual request - let Some(blob) = message.blob() else { - hyperware_process_lib::logging::warn!("Failed to get blob for HTTP, sending BAD_REQUEST"); - hyperware_process_lib::http::server::send_response( - hyperware_process_lib::http::StatusCode::BAD_REQUEST, - None, - vec![] - ); - return; - }; + let current_path = match hyperware_app_common::get_path() { + Some(path) => { + hyperware_process_lib::logging::debug!("Successfully parsed HTTP path: '{}'", path); + path + }, + None => { + hyperware_process_lib::logging::error!("Failed to get HTTP path: no HTTP context available"); + hyperware_process_lib::http::server::send_response( + hyperware_process_lib::http::StatusCode::BAD_REQUEST, + None, + b"Invalid path: no HTTP context available".to_vec(), + ); + hyperware_app_common::clear_http_request_context(); + return; + } + }; + } +} - // Process HTTP request - match serde_json::from_slice::(&blob.bytes) { - Ok(request) => { - // Handle the HTTP request +/// Generate parameterized handler dispatch arms +fn generate_parameterized_handler_dispatch( + parameterized_handlers: &[&&FunctionMetadata], + self_ty: &Box, + http_request_match_arms: &proc_macro2::TokenStream, + specific_paths: &[&String], +) -> proc_macro2::TokenStream { + let mut sorted_handlers = parameterized_handlers.to_vec(); + sorted_handlers.sort_by_key(|handler| handler.http_path.is_none()); + + let dispatch_arms: Vec<_> = sorted_handlers.iter().map(|handler| { + let fn_name = &handler.name; + let variant_name = format_ident!("{}", &handler.variant_name); + let path_check = if let Some(path) = &handler.http_path { + quote! { ¤t_path == #path } + } else { + quote! { ![#(#specific_paths),*].contains(¤t_path.as_str()) } + }; + let methods = &handler.http_methods; + let method_check = quote! { [#(#methods),*].contains(&http_method.as_str()) }; + + quote! { + hyperware_process_lib::logging::debug!("Checking parameterized handler {} for {} {} - path_check: {}, method_check: {}", + stringify!(#fn_name), http_method, current_path, (#path_check), (#method_check)); + if #path_check && #method_check { + hyperware_process_lib::logging::debug!("Matched parameterized handler {} for {} {}", stringify!(#fn_name), http_method, current_path); + + if let Some(ref blob) = blob_opt { + hyperware_process_lib::logging::debug!("Got blob with {} bytes: {}", blob.bytes.len(), String::from_utf8_lossy(&blob.bytes)); + match serde_json::from_slice::(&blob.bytes) { + Ok(request) => { + match request { + HPMRequest::#variant_name(..) => { unsafe { #http_request_match_arms - - // Save state if needed hyperware_app_common::maybe_save_state(&mut *state); } }, - Err(e) => { - hyperware_process_lib::logging::warn!( - "Failed to deserialize HTTP request into HPMRequest enum: {}\n{:?}", - e, - serde_json::from_slice::(&blob.bytes), - ); + _ => { + hyperware_process_lib::logging::error!("Request body contains wrong handler name for {} {}", http_method, current_path); hyperware_process_lib::http::server::send_response( hyperware_process_lib::http::StatusCode::BAD_REQUEST, None, - format!("Invalid request format: {}", e).into_bytes() + format!("Expected handler name '{}' in request body", stringify!(#variant_name)).into_bytes() ); } } - hyperware_app_common::APP_HELPERS.with(|ctx| { - ctx.borrow_mut().current_path = None; - }); }, - hyperware_process_lib::http::server::HttpServerRequest::WebSocketPush { channel_id, message_type } => { - let Some(blob) = message.blob() else { - hyperware_process_lib::logging::warn!("Failed to get blob for WebSocketPush, exiting"); - return; + Err(e) => { + let error_details = if blob.bytes.is_empty() { + "Request body is empty but was expected to contain handler parameters.".to_string() + } else if let Ok(json_value) = serde_json::from_slice::(&blob.bytes) { + format!( + "Invalid request format. Expected one of the parameterized handler formats, but got: {}", + serde_json::to_string(&json_value).unwrap_or_else(|_| "invalid JSON".to_string()) + ) + } else { + format!( + "Invalid JSON in request body. Parse error: {}", + e + ) }; - // Call the websocket handler if it exists - #ws_method_call + hyperware_process_lib::logging::error!("Failed to parse request body for {} {}: {}", http_method, current_path, error_details); - // Save state if needed - unsafe { - hyperware_app_common::maybe_save_state(&mut *state); - } - }, - hyperware_process_lib::http::server::HttpServerRequest::WebSocketOpen { path, channel_id } => { - hyperware_app_common::get_server().unwrap().handle_websocket_open(&path, channel_id); - }, - hyperware_process_lib::http::server::HttpServerRequest::WebSocketClose(channel_id) => { - hyperware_app_common::get_server().unwrap().handle_websocket_close(channel_id); + hyperware_process_lib::http::server::send_response( + hyperware_process_lib::http::StatusCode::BAD_REQUEST, + None, + error_details.into_bytes() + ); + hyperware_app_common::clear_http_request_context(); + return; } } - }, + } else { + hyperware_process_lib::logging::error!("Handler {} requires a request body", stringify!(#fn_name)); + hyperware_process_lib::http::server::send_response( + hyperware_process_lib::http::StatusCode::BAD_REQUEST, + None, + format!("Handler {} requires a request body", stringify!(#fn_name)).into_bytes() + ); + } + return; + } + } + }).collect(); + + quote! { #(#dispatch_arms)* } +} + +/// Generate parameterless handler dispatch arms +fn generate_parameterless_handler_dispatch( + parameterless_handlers: &[&&FunctionMetadata], + self_ty: &Box, + specific_paths: &[&String], +) -> proc_macro2::TokenStream { + let mut sorted_handlers = parameterless_handlers.to_vec(); + sorted_handlers.sort_by_key(|handler| handler.http_path.is_none()); + + let dispatch_arms: Vec<_> = sorted_handlers.iter().map(|handler| { + let fn_name = &handler.name; + let path_check = if let Some(path) = &handler.http_path { + quote! { ¤t_path == #path } + } else { + quote! { ![#(#specific_paths),*].contains(¤t_path.as_str()) } + }; + let methods = &handler.http_methods; + let method_check = quote! { [#(#methods),*].contains(&http_method.as_str()) }; + + let response_handling = quote! { + let response_bytes = match serde_json::to_vec(&result) { + Ok(bytes) => bytes, Err(e) => { - hyperware_process_lib::logging::warn!("Failed to parse HTTP server request: {}", e); + hyperware_process_lib::logging::error!("Failed to serialize response: {}", e); + hyperware_process_lib::http::server::send_response( + hyperware_process_lib::http::StatusCode::INTERNAL_SERVER_ERROR, + None, + "Failed to serialize response".as_bytes().to_vec(), + ); + return; } + }; + + let headers_opt = hyperware_app_common::APP_HELPERS.with(|helpers| { + helpers.borrow().current_http_context.as_ref().and_then(|ctx| { + if ctx.response_headers.is_empty() { + None + } else { + Some(ctx.response_headers.clone()) + } + }) + }); + + hyperware_process_lib::http::server::send_response( + hyperware_process_lib::http::StatusCode::OK, + headers_opt, + response_bytes + ); + + hyperware_app_common::clear_http_request_context(); + }; + + let handler_body = if handler.is_async { + quote! { + let state_ptr: *mut #self_ty = state; + hyperware_app_common::hyper! { + let result = unsafe { (*state_ptr).#fn_name().await }; + #response_handling + } + unsafe { hyperware_app_common::maybe_save_state(&mut *state); } + } + } else { + quote! { + let result = unsafe { (*state).#fn_name() }; + #response_handling + unsafe { hyperware_app_common::maybe_save_state(&mut *state); } + } + }; + + quote! { + hyperware_process_lib::logging::debug!("Checking parameter-less handler {} for {} {} - path_check: {}, method_check: {}", + stringify!(#fn_name), http_method, current_path, (#path_check), (#method_check)); + if #path_check && #method_check { + hyperware_process_lib::logging::debug!("Matched parameter-less handler {} for {} {}", stringify!(#fn_name), http_method, current_path); + #handler_body + return; + } + } + }).collect(); + + quote! { #(#dispatch_arms)* } +} + +/// Generate HTTP handler dispatcher +fn generate_http_handler_dispatcher( + http_handlers: &[&FunctionMetadata], + self_ty: &Box, + http_request_match_arms: &proc_macro2::TokenStream, +) -> proc_macro2::TokenStream { + let specific_paths: Vec<_> = http_handlers + .iter() + .filter_map(|h| h.http_path.as_ref()) + .collect(); + + let parameterized_handlers: Vec<_> = http_handlers + .iter() + .filter(|h| !h.params.is_empty()) + .collect(); + + let parameterless_handlers: Vec<_> = http_handlers + .iter() + .filter(|h| h.params.is_empty()) + .collect(); + + let parameterized_dispatch = generate_parameterized_handler_dispatch( + ¶meterized_handlers, + self_ty, + http_request_match_arms, + &specific_paths, + ); + + let parameterless_dispatch = + generate_parameterless_handler_dispatch(¶meterless_handlers, self_ty, &specific_paths); + + quote! { + hyperware_process_lib::logging::debug!("Starting handler matching for {} {}", http_method, current_path); + + if blob_opt.is_some() && !blob_opt.as_ref().unwrap().bytes.is_empty() { + hyperware_process_lib::logging::debug!("Request has body, using two-phase matching"); + + if let Some(ref blob) = blob_opt { + match serde_json::from_slice::(&blob.bytes) { + Ok(request) => { + hyperware_process_lib::logging::debug!("Successfully parsed request body, dispatching to specific handler"); + unsafe { + #http_request_match_arms + hyperware_app_common::maybe_save_state(&mut *state); + } + return; + }, + Err(e) => { + let error_details = if blob.bytes.is_empty() { + "Request body is empty but was expected to contain handler parameters.".to_string() + } else if let Ok(json_value) = serde_json::from_slice::(&blob.bytes) { + format!( + "Invalid request format. Expected one of the parameterized handler formats, but got: {}", + serde_json::to_string(&json_value).unwrap_or_else(|_| "invalid JSON".to_string()) + ) + } else { + format!( + "Invalid JSON in request body. Parse error: {}", + e + ) + }; + + hyperware_process_lib::logging::error!("Failed to parse request body for {} {}: {}", http_method, current_path, error_details); + + hyperware_process_lib::http::server::send_response( + hyperware_process_lib::http::StatusCode::BAD_REQUEST, + None, + error_details.into_bytes() + ); + hyperware_app_common::clear_http_request_context(); + return; + } + } + } + } else { + hyperware_process_lib::logging::debug!("Request has no body, trying parameter-less handlers first"); + #parameterless_dispatch + } + + hyperware_process_lib::logging::error!("No handler found for {} {} - all handlers checked", http_method, current_path); + hyperware_process_lib::http::server::send_response( + hyperware_process_lib::http::StatusCode::NOT_FOUND, + None, + format!("No handler found for {} {}", http_method, current_path).into_bytes(), + ); + hyperware_app_common::clear_http_request_context(); + } +} + +//------------------------------------------------------------------------------ +// WebSocket Helper Functions +//------------------------------------------------------------------------------ + +/// Generate WebSocket message handler +fn generate_websocket_handler( + ws_method_call: &proc_macro2::TokenStream, + self_ty: &Box, +) -> proc_macro2::TokenStream { + quote! { + hyperware_process_lib::http::server::HttpServerRequest::WebSocketPush { channel_id, message_type } => { + hyperware_process_lib::logging::debug!("Received WebSocket message on channel {}, type: {:?}", channel_id, message_type); + + let Some(blob) = blob_opt else { + hyperware_process_lib::logging::error!( + "Failed to get blob for WebSocketPush on channel {}. This indicates a malformed WebSocket message.", + channel_id + ); + return; + }; + + hyperware_process_lib::logging::debug!("Processing WebSocket message with {} bytes", blob.bytes.len()); + #ws_method_call + + unsafe { + hyperware_app_common::maybe_save_state(&mut *state); + } + }, + hyperware_process_lib::http::server::HttpServerRequest::WebSocketOpen { path, channel_id } => { + hyperware_process_lib::logging::debug!("WebSocket connection opened on path '{}' with channel {}", path, channel_id); + match hyperware_app_common::get_server() { + Some(server) => server.handle_websocket_open(&path, channel_id), + None => hyperware_process_lib::logging::error!("Failed to get server instance for WebSocket open event") + } + }, + hyperware_process_lib::http::server::HttpServerRequest::WebSocketClose(channel_id) => { + hyperware_process_lib::logging::debug!("WebSocket connection closed on channel {}", channel_id); + match hyperware_app_common::get_server() { + Some(server) => server.handle_websocket_close(channel_id), + None => hyperware_process_lib::logging::error!("Failed to get server instance for WebSocket close event") } } + } +} + +//------------------------------------------------------------------------------ +// Local/Remote Message Helper Functions +//------------------------------------------------------------------------------ +/// Generate local message handler +fn generate_local_message_handler( + self_ty: &Box, + match_arms: &proc_macro2::TokenStream, +) -> proc_macro2::TokenStream { + quote! { /// Handle local messages fn handle_local_message(state: *mut #self_ty, message: hyperware_process_lib::Message) { - // Process the local request based on our handlers (now including both local and remote handlers) + hyperware_process_lib::logging::debug!("Processing local message from: {:?}", message.source()); match serde_json::from_slice::(message.body()) { Ok(request) => { unsafe { - // Match on the request variant and call the appropriate handler - // Now using combined local_and_remote handlers - #local_and_remote_request_match_arms - - // Save state if needed + #match_arms hyperware_app_common::maybe_save_state(&mut *state); } }, Err(e) => { - hyperware_process_lib::logging::warn!("Failed to deserialize local request into HPMRequest enum: {}", e); + let raw_body = String::from_utf8_lossy(message.body()); + hyperware_process_lib::logging::error!( + "Failed to deserialize local request into HPMRequest enum.\n\ + Error: {}\n\ + Source: {:?}\n\ + Body: {}\n\ + \n\ + 💡 This usually means the message format doesn't match any of your #[local] or #[remote] handlers.", + e, message.source(), raw_body + ); } } } + } +} +/// Generate remote message handler +fn generate_remote_message_handler( + self_ty: &Box, + match_arms: &proc_macro2::TokenStream, +) -> proc_macro2::TokenStream { + quote! { /// Handle remote messages fn handle_remote_message(state: *mut #self_ty, message: hyperware_process_lib::Message) { - // Process the remote request based on our handlers + hyperware_process_lib::logging::debug!("Processing remote message from: {:?}", message.source()); match serde_json::from_slice::(message.body()) { Ok(request) => { unsafe { - // Match on the request variant and call the appropriate handler - #remote_request_match_arms - - // Save state if needed + #match_arms hyperware_app_common::maybe_save_state(&mut *state); } }, Err(e) => { - hyperware_process_lib::logging::warn!("Failed to deserialize remote request into HPMRequest enum: {}\nRaw request value: {:?}", e, message.body()); + let raw_body = String::from_utf8_lossy(message.body()); + hyperware_process_lib::logging::error!( + "Failed to deserialize remote request into HPMRequest enum.\n\ + Error: {}\n\ + Source: {:?}\n\ + Body: {}\n\ + \n\ + 💡 This usually means the message format doesn't match any of your #[remote] handlers.", + e, message.source(), raw_body + ); + } + } + } + } +} + +/// Generate message handler functions for message types +fn generate_message_handlers( + self_ty: &Box, + handler_arms: &HandlerDispatch, + ws_method_call: &proc_macro2::TokenStream, + http_handlers: &[&FunctionMetadata], +) -> proc_macro2::TokenStream { + let http_request_match_arms = &handler_arms.http; + let local_and_remote_request_match_arms = &handler_arms.local_and_remote; + let remote_request_match_arms = &handler_arms.remote; + + let http_context_setup = generate_http_context_setup(); + let http_request_parsing = generate_http_request_parsing(); + let http_dispatcher = + generate_http_handler_dispatcher(http_handlers, self_ty, http_request_match_arms); + let websocket_handlers = generate_websocket_handler(ws_method_call, self_ty); + let local_message_handler = + generate_local_message_handler(self_ty, local_and_remote_request_match_arms); + let remote_message_handler = + generate_remote_message_handler(self_ty, remote_request_match_arms); + + quote! { + /// Handle messages from the HTTP server + fn handle_http_server_message(state: *mut #self_ty, message: hyperware_process_lib::Message) { + let blob_opt = message.blob(); + + match serde_json::from_slice::(message.body()) { + Ok(http_server_request) => { + match http_server_request { + hyperware_process_lib::http::server::HttpServerRequest::Http(http_request) => { + hyperware_process_lib::logging::debug!("Processing HTTP request, message has blob: {}", blob_opt.is_some()); + if let Some(ref blob) = blob_opt { + hyperware_process_lib::logging::debug!("Blob size: {} bytes, content: {}", blob.bytes.len(), String::from_utf8_lossy(&blob.bytes[..std::cmp::min(200, blob.bytes.len())])); + } + + #http_context_setup + #http_request_parsing + #http_dispatcher + }, + #websocket_handlers + } + }, + Err(e) => { + hyperware_process_lib::logging::error!( + "Failed to parse HTTP server request: {}\n\ + This usually indicates a malformed message to the HTTP server.", + e + ); } } } + + #local_message_handler + #remote_message_handler } } @@ -1055,6 +1609,7 @@ fn generate_component_impl( ws_method_details: &WsMethodDetails, handler_arms: &HandlerDispatch, has_init_logging: bool, + http_handlers: &[&FunctionMetadata], ) -> proc_macro2::TokenStream { // Extract values from args for use in the quote macro let name = &args.name; @@ -1088,7 +1643,8 @@ fn generate_component_impl( let ws_method_call = &ws_method_details.call; // Generate message handler functions - let message_handlers = generate_message_handlers(self_ty, handler_arms, ws_method_call); + let message_handlers = + generate_message_handlers(self_ty, handler_arms, ws_method_call, http_handlers); // Generate the logging initialization conditionally let logging_init = if !has_init_logging { @@ -1175,6 +1731,11 @@ fn generate_component_impl( hyperware_app_common::APP_HELPERS.with(|ctx| { ctx.borrow_mut().current_message = Some(message.clone()); }); + + // Store old state if needed (for OnDiff save option) + // This only stores if old_state is None (first time or after a save) + hyperware_app_common::store_old_state(&state); + match message { hyperware_process_lib::Message::Response { body, context, .. } => { let correlation_id = context @@ -1253,14 +1814,31 @@ pub fn hyperprocess(attr: TokenStream, item: TokenStream) -> TokenStream { // Filter functions by handler type let handlers = HandlerGroups::from_function_metadata(&function_metadata); - // Generate HPMRequest and HPMResponse enums - let (request_enum, response_enum) = generate_request_response_enums(&function_metadata); + // HTTP handlers with parameters will be part of the HPMRequest enum and dispatched via body deserialization. + let http_handlers_with_params: Vec<_> = handlers + .http + .iter() + //.filter(|h| !h.params.is_empty()) + .cloned() + .collect(); + + // Collect all function metadata that will be represented in the HPMRequest enum. + // This includes all local and remote handlers, plus HTTP handlers that have parameters. + let metadata_for_enum: Vec<_> = function_metadata + .iter() + //.filter(|f| !f.is_http || !f.params.is_empty()) + .cloned() + .collect(); + + // Generate HPMRequest and HPMResponse enums from the filtered list of functions + let (request_enum, response_enum) = generate_request_response_enums(&metadata_for_enum); // Generate handler match arms let handler_arms = HandlerDispatch { local: generate_handler_dispatch(&handlers.local, self_ty, HandlerType::Local), remote: generate_handler_dispatch(&handlers.remote, self_ty, HandlerType::Remote), - http: generate_handler_dispatch(&handlers.http, self_ty, HandlerType::Http), + // HTTP dispatch arms are only generated for handlers with parameters. + http: generate_handler_dispatch(&http_handlers_with_params, self_ty, HandlerType::Http), // Generate dispatch for combined local and remote handlers local_and_remote: generate_handler_dispatch( &handlers.local_and_remote, @@ -1295,6 +1873,7 @@ pub fn hyperprocess(attr: TokenStream, item: TokenStream) -> TokenStream { &ws_method_details, &handler_arms, has_init_logging, + &handlers.http, ) .into() } diff --git a/hyperware_app_common/Cargo.toml b/hyperware_app_common/Cargo.toml index b2c2ba4..ecaaa8c 100644 --- a/hyperware_app_common/Cargo.toml +++ b/hyperware_app_common/Cargo.toml @@ -6,8 +6,7 @@ publish = false [dependencies] anyhow = "1.0" -#hyperware_process_lib = { version = "1.0.4", features = ["logging"] } -hyperware_process_lib = { git = "https://github.com/hyperware-ai/process_lib", features = ["logging"], rev = "b7c9d27" } +hyperware_process_lib = { git = "https://github.com/hyperware-ai/process_lib", features = ["logging"], rev = "cfd6843" } futures-util = "0.3" once_cell = "1.20.2" paste = "1.0" diff --git a/hyperware_app_common/src/lib.rs b/hyperware_app_common/src/lib.rs index 91639ab..0af91ef 100644 --- a/hyperware_app_common/src/lib.rs +++ b/hyperware_app_common/src/lib.rs @@ -6,11 +6,12 @@ use std::pin::Pin; use std::task::{Context, Poll}; use futures_util::task::noop_waker_ref; -use hyperware_process_lib::{get_state, http, kiprintln, set_state, BuildError, LazyLoadBlob, Message, Request, SendError}; -use hyperware_process_lib::logging::info; -use hyperware_process_lib::http::server::HttpServer; -use serde::Deserialize; -use serde::Serialize; +use hyperware_process_lib::{ + http::server::{HttpServer, IncomingHttpRequest}, + logging::info, + get_state, http, set_state, timer, BuildError, LazyLoadBlob, Message, Request, SendError, +}; +use serde::{Deserialize, Serialize}; use thiserror::Error; use uuid::Uuid; @@ -31,26 +32,35 @@ thread_local! { pub static RESPONSE_REGISTRY: RefCell>> = RefCell::new(HashMap::new()); pub static APP_HELPERS: RefCell = RefCell::new(AppHelpers { - current_path: None, current_server: None, current_message: None, + current_http_context: None, }); } +#[derive(Clone)] +pub struct HttpRequestContext { + pub request: IncomingHttpRequest, + pub response_headers: HashMap, +} + pub struct AppContext { pub hidden_state: Option, pub executor: Executor, } pub struct AppHelpers { - pub current_path: Option, pub current_server: Option<*mut HttpServer>, pub current_message: Option, + pub current_http_context: Option, } // Access function for the current path pub fn get_path() -> Option { - APP_HELPERS.with(|ctx| ctx.borrow().current_path.clone()) + APP_HELPERS.with(|helpers| { + helpers.borrow().current_http_context.as_ref() + .and_then(|ctx| ctx.request.path().ok()) + }) } // Access function for the current server @@ -58,6 +68,39 @@ pub fn get_server() -> Option<&'static mut HttpServer> { APP_HELPERS.with(|ctx| ctx.borrow().current_server.map(|ptr| unsafe { &mut *ptr })) } +pub fn get_http_method() -> Option { + APP_HELPERS.with(|helpers| { + helpers.borrow().current_http_context.as_ref() + .and_then(|ctx| ctx.request.method().ok()) + .map(|m| m.to_string()) + }) +} + +// Set response headers that will be included in the HTTP response +pub fn set_response_headers(headers: HashMap) { + APP_HELPERS.with(|helpers| { + if let Some(ctx) = &mut helpers.borrow_mut().current_http_context { + ctx.response_headers = headers; + } + }) +} + +// Add a single response header +pub fn add_response_header(key: String, value: String) { + APP_HELPERS.with(|helpers| { + if let Some(ctx) = &mut helpers.borrow_mut().current_http_context { + ctx.response_headers.insert(key, value); + } + }) +} + + +pub fn clear_http_request_context() { + APP_HELPERS.with(|helpers| { + helpers.borrow_mut().current_http_context = None; + }) +} + // Access function for the source address of the current message pub fn source() -> hyperware_process_lib::Address { APP_HELPERS.with(|ctx| { @@ -70,6 +113,25 @@ pub fn source() -> hyperware_process_lib::Address { }) } +/// Get query parameters from the current HTTP request path +/// Returns None if not in an HTTP context or no query parameters present +pub fn get_query_params() -> Option> { + get_path().map(|path| { + let mut params = HashMap::new(); + if let Some(query_start) = path.find('?') { + let query = &path[query_start + 1..]; + for pair in query.split('&') { + if let Some(eq_pos) = pair.find('=') { + let key = pair[..eq_pos].to_string(); + let value = pair[eq_pos + 1..].to_string(); + params.insert(key, value); + } + } + } + params + }) +} + pub struct Executor { tasks: Vec>>>, } @@ -100,11 +162,21 @@ impl Executor { } struct ResponseFuture { correlation_id: String, + // Capture HTTP context at creation time + http_context: Option, } impl ResponseFuture { fn new(correlation_id: String) -> Self { - Self { correlation_id } + // Capture current HTTP context when future is created (at .await point) + let http_context = APP_HELPERS.with(|helpers| { + helpers.borrow().current_http_context.clone() + }); + + Self { + correlation_id, + http_context, + } } } @@ -120,6 +192,13 @@ impl Future for ResponseFuture { }); if let Some(bytes) = maybe_bytes { + // Restore this future's captured context + if let Some(ref context) = self.http_context { + APP_HELPERS.with(|helpers| { + helpers.borrow_mut().current_http_context = Some(context.clone()); + }); + } + Poll::Ready(bytes) } else { Poll::Pending @@ -135,6 +214,21 @@ pub enum AppSendError { BuildError(BuildError), } +pub async fn sleep(sleep_ms: u64) -> Result<(), AppSendError> { + let request = Request::to(("our", "timer", "distro", "sys")) + .body(timer::TimerAction::SetTimer(sleep_ms)) + .expects_response((sleep_ms / 1_000) + 1); + + let correlation_id = Uuid::new_v4().to_string(); + if let Err(e) = request.context(correlation_id.as_bytes().to_vec()).send() { + return Err(AppSendError::BuildError(e)); + } + + let _ = ResponseFuture::new(correlation_id).await; + + return Ok(()); +} + pub async fn send(request: Request) -> Result where R: serde::de::DeserializeOwned, @@ -146,10 +240,7 @@ where }; let correlation_id = Uuid::new_v4().to_string(); - if let Err(e) = request - .context(correlation_id.as_bytes().to_vec()) - .send() - { + if let Err(e) = request.context(correlation_id.as_bytes().to_vec()).send() { return Err(AppSendError::BuildError(e)); } @@ -174,10 +265,7 @@ where }; let correlation_id = Uuid::new_v4().to_string(); - if let Err(e) = request - .context(correlation_id.as_bytes().to_vec()) - .send() - { + if let Err(e) = request.context(correlation_id.as_bytes().to_vec()).send() { return Err(AppSendError::BuildError(e)); } @@ -202,6 +290,7 @@ macro_rules! hyper { }; } + // Enum defining the state persistance behaviour #[derive(Clone)] pub enum SaveOptions { @@ -213,10 +302,13 @@ pub enum SaveOptions { EveryNMessage(u64), // Persist State Every N Seconds EveryNSeconds(u64), + // Persist State Only If Changed + OnDiff, } pub struct HiddenState { save_config: SaveOptions, message_count: u64, + old_state: Option>, // Stores the serialized state from before message processing } impl HiddenState { @@ -224,6 +316,7 @@ impl HiddenState { Self { save_config, message_count: 0, + old_state: None, } } @@ -241,12 +334,32 @@ impl HiddenState { } } SaveOptions::EveryNSeconds(_) => false, // Handled by timer instead + SaveOptions::OnDiff => false, // Will be handled separately with state comparison } } } // TODO: We need a timer macro again. +/// Store a snapshot of the current state before processing a message +/// This is used for OnDiff save option to compare state before and after +/// Only stores if old_state is None (i.e., first time or after a save) +pub fn store_old_state(state: &S) +where + S: serde::Serialize, +{ + APP_CONTEXT.with(|ctx| { + let mut ctx_mut = ctx.borrow_mut(); + if let Some(ref mut hidden_state) = ctx_mut.hidden_state { + if matches!(hidden_state.save_config, SaveOptions::OnDiff) && hidden_state.old_state.is_none() { + if let Ok(s_bytes) = rmp_serde::to_vec(state) { + hidden_state.old_state = Some(s_bytes); + } + } + } + }); +} + /// Trait that must be implemented by application state types pub trait State { /// Creates a new instance of the state. @@ -331,7 +444,7 @@ pub fn pretty_print_send_error(error: &SendError) { .as_ref() .map(|bytes| String::from_utf8_lossy(bytes).into_owned()); - kiprintln!( + hyperware_process_lib::logging::error!( "SendError {{ kind: {:?}, target: {}, @@ -367,19 +480,11 @@ pub fn no_http_api_call(_state: &mut S, _req: ()) { // does nothing } -pub fn no_local_request( - _msg: &Message, - _state: &mut S, - _req: (), -) { +pub fn no_local_request(_msg: &Message, _state: &mut S, _req: ()) { // does nothing } -pub fn no_remote_request( - _msg: &Message, - _state: &mut S, - _req: (), -) { +pub fn no_remote_request(_msg: &Message, _state: &mut S, _req: ()) { // does nothing } @@ -402,9 +507,34 @@ where APP_CONTEXT.with(|ctx| { let mut ctx_mut = ctx.borrow_mut(); if let Some(ref mut hidden_state) = ctx_mut.hidden_state { - if hidden_state.should_save_state() { + let should_save = if matches!(hidden_state.save_config, SaveOptions::OnDiff) { + // For OnDiff, compare current state with old state + if let Ok(current_bytes) = rmp_serde::to_vec(state) { + let state_changed = match &hidden_state.old_state { + Some(old_bytes) => old_bytes != ¤t_bytes, + None => true, // If no old state, consider it changed + }; + + if state_changed { + true + } else { + false + } + } else { + false + } + } else { + hidden_state.should_save_state() + }; + + if should_save { if let Ok(s_bytes) = rmp_serde::to_vec(state) { let _ = set_state(&s_bytes); + + // Clear old_state after saving so it can be set again on next message + if matches!(hidden_state.save_config, SaveOptions::OnDiff) { + hidden_state.old_state = None; + } } } }