From 528c8cd72e0bed27725aa6139ca7e197a2c213b5 Mon Sep 17 00:00:00 2001 From: Miguel Ramos Date: Mon, 15 Sep 2025 16:54:41 +0100 Subject: [PATCH] feat(ECH-TSK-3): implement HTTP DELETE method support - Add DELETE route handler in server router configuration - Implement delete_handler function with comprehensive JSON manipulation - Add support for deleting object keys and array elements via query parameters - Include robust error handling for invalid paths and operations - Add comprehensive documentation for deletion functionality - Update CORS configuration to include DELETE method - Add test data entries in json-echo.json for DELETE operations --- crates/cli/src/server.rs | 176 +++++++++++++++++++++++++++- crates/core/src/database.rs | 221 ++++++++++++++++++++++++++++++++++++ json-echo.json | 25 ++++ 3 files changed, 421 insertions(+), 1 deletion(-) diff --git a/crates/cli/src/server.rs b/crates/cli/src/server.rs index ca186d8..8d1b369 100644 --- a/crates/cli/src/server.rs +++ b/crates/cli/src/server.rs @@ -45,7 +45,7 @@ use axum::{ extract::{Json, MatchedPath, Path, State}, http::{HeaderMap, HeaderName, HeaderValue, Method, StatusCode, Uri}, response::{IntoResponse, Response}, - routing::{get, patch, post, put}, + routing::{delete, get, patch, post, put}, }; use json_echo_core::{ConfigManager, Database}; use serde_json::{Value, json}; @@ -204,6 +204,10 @@ pub fn create_router(db: Database, config_manager: &ConfigManager) -> Router { info!("[PATCH] route defined: {}", route_path); router.route(route_path, patch(add_update_handler)) } + Some("DELETE") => { + info!("[DELETE] route defined: {}", route_path); + router.route(route_path, delete(delete_handler)) + } _ => router, } }); @@ -216,6 +220,7 @@ pub fn create_router(db: Database, config_manager: &ConfigManager) -> Router { Method::PATCH, Method::DELETE, Method::OPTIONS, + Method::HEAD, ]) .allow_headers(Any) .allow_origin(Any) @@ -569,6 +574,175 @@ async fn add_update_handler( ) } +/// HTTP DELETE request handler that removes data based on route configuration. +/// +/// This handler processes DELETE requests by locating specific entries in the +/// database using path parameters and removing them. It supports both individual +/// entry deletion and bulk operations based on the route configuration. +/// +/// # Parameters +/// +/// * `Path(params)` - Path parameters extracted from the URL for identifying entries +/// * `State(state)` - Shared application state containing the database +/// * `uri_path` - The full URI of the request for logging +/// * `path` - The matched route path for database lookups +/// +/// # Returns +/// +/// An HTTP response containing: +/// - 204 No Content if deletion was successful +/// - 200 OK with confirmation JSON if configured to return content +/// - 404 Not Found if the specified entry or route doesn't exist +/// - 500 Internal Server Error if database operations fail +/// +/// # Behavior +/// +/// The handler follows this logic: +/// 1. Extracts the matched route path from the request +/// 2. Looks up the corresponding model in the database +/// 3. If path parameters are provided, searches for and deletes the specific entry +/// 4. If no parameters, potentially clears all data (based on configuration) +/// 5. Returns appropriate status codes and headers based on the operation result +/// +/// # Examples +/// +/// ``` +/// DELETE /users/123 -> Removes user with ID 123, returns 204 No Content +/// DELETE /api/data -> May clear all data or return 404 based on configuration +/// ``` +#[allow(clippy::manual_let_else)] +#[allow(clippy::ignored_unit_patterns)] +#[allow(clippy::too_many_lines)] +async fn delete_handler( + State(state): State>, + Path(params): Path>, + uri_path: Uri, + path: MatchedPath, +) -> Response { + info!("[DELETE] request called: {}", uri_path.path()); + + let route_path = path.as_str(); + let route_identifier = format!("[DELETE] {route_path}"); + + // First, get the route configuration and model info without holding the lock + let (model_exists, route_headers, model_status) = { + let state_reader = match state.db.read() { + Ok(reader) => reader, + Err(_) => { + return response( + HeaderMap::new(), + StatusCode::EXPECTATION_FAILED, + &json!({"error": "Unable to read database"}), + ); + } + }; + + let model = state_reader + .get_model(&format!("[GET] {route_path}")) + .or_else(|| state_reader.get_model(&route_identifier)); + let route_config = state_reader + .get_route(route_path, Some(String::from("GET"))) + .or_else(|| state_reader.get_route(&route_identifier, None)); + + debug!("Route Config: {:?}", route_config); + + let model_exists = model.is_some(); + let route_headers = route_config.and_then(|rc| rc.headers.clone()); + let model_status = model.map(|m| m.get_status().unwrap_or(StatusCode::OK.as_u16())); + + (model_exists, route_headers, model_status) + }; // Read lock drop + + if !model_exists { + return response( + HeaderMap::new(), + StatusCode::NOT_FOUND, + &json!({"error": "Model not found"}), + ); + } + + // Configure headers + let mut headers = HeaderMap::new(); + headers.insert("Content-Type", HeaderValue::from_static("application/json")); + + if let Some(route_headers) = route_headers { + for (key, value) in route_headers { + if let Ok(header_name) = key.parse::() { + if let Ok(header_value) = value.parse() { + headers.insert(header_name, header_value); + } + } + } + } + + debug!("Headers Config: {:?}", headers); + + let http_status = model_status.unwrap_or(StatusCode::NO_CONTENT.as_u16()); + let status = StatusCode::from_u16(http_status).unwrap_or(StatusCode::NO_CONTENT); + + // Phase 2: Perform deletion operation (write lock) + { + let mut state_writer = match state.db.write() { + Ok(writer) => writer, + Err(_) => { + return response( + HeaderMap::new(), + StatusCode::EXPECTATION_FAILED, + &json!({"error": "Unable to write to database"}), + ); + } + }; + + match state_writer.delete_model_entry(&route_identifier, ¶ms) { + Ok(_) => { + info!("✔︎ Model data deleted: {route_identifier}"); + + // Sync with GET model + let get_identifier = format!("[GET] {route_path}"); + if state_writer + .delete_model_entry(&route_identifier, ¶ms) + .is_ok() + { + info!("✔︎ GET Model data deleted: {get_identifier}"); + } + } + Err(e) => { + info!("⚠︎ Failed to delete model data: {route_identifier}"); + debug!("Delete model error: {:?}", e); + } + } + } // Write lock dropped + + // Phase 3: Get response data (new read lock) + let state_reader = match state.db.read() { + Ok(reader) => reader, + Err(_) => { + return response( + headers, + StatusCode::INTERNAL_SERVER_ERROR, + &json!({"error": "Unable to read deleted data"}), + ); + } + }; + + if let Some(model) = state_reader.get_model(&route_identifier) { + if !params.is_empty() { + if let Some(data) = model.find_entry_by_hashmap(params) { + return response(headers, status, &data); + } + } + + let response_body = model.get_data(); + return response(headers, status, &response_body.as_value()); + } + + response( + headers, + StatusCode::NOT_FOUND, + &json!({"error": "Model not found"}), + ) +} + /// Creates an HTTP response with the appropriate content type and format. /// /// This function generates HTTP responses by examining the provided headers diff --git a/crates/core/src/database.rs b/crates/core/src/database.rs index fee187e..2cf3164 100644 --- a/crates/core/src/database.rs +++ b/crates/core/src/database.rs @@ -49,6 +49,18 @@ use std::collections::HashMap; use serde_json::{Map, Value, json}; +/// Internal enum for tracking what type of deletion operation to perform. +/// +/// This enum helps distinguish between deleting keys from an object structure +/// versus removing elements from an array structure, avoiding type conflicts +/// during the deletion planning phase. +enum DeletionTarget { + /// Delete specific keys from an object + ObjectKeys(Vec), + /// Remove elements at specific indices from an array + ArrayIndices(Vec), +} + use crate::{ConfigRoute, ConfigRouteResponse, config::BodyResponse}; /// An in-memory database that manages route configurations and their associated models. @@ -486,6 +498,70 @@ impl Database { self.models[model_position].update_data(new_data) } + + /// Deletes a specific entry from a model's data based on the provided parameters. + /// + /// This method locates a model by its identifier and removes an entry that matches + /// the provided parameter map. The deletion operation works with both object and + /// array-based model data structures. + /// + /// # Parameters + /// + /// * `identifier` - The string identifier of the model to modify + /// * `params` - A HashMap containing key-value pairs to match against entries + /// + /// # Returns + /// + /// * `Ok(true)` - If an entry was found and successfully deleted + /// * `Ok(false)` - If no matching entry was found to delete + /// * `Err(String)` - If the model was not found or the operation failed + /// + /// # Behavior + /// + /// - Searches for the model with the specified identifier + /// - Calls the model's delete_entry method to perform the deletion + /// - Works with both array and object data structures + /// - Matches entries based on ID field or exact value matches + /// + /// # Examples + /// + /// ```rust + /// use json_echo_core::Database; + /// use std::collections::HashMap; + /// + /// let mut db = Database::new(); + /// // Assuming database has been populated with a "users" model + /// + /// let mut params = HashMap::new(); + /// params.insert("id".to_string(), "123".to_string()); + /// + /// match db.delete_model_entry("users", ¶ms) { + /// Ok(true) => println!("Entry deleted successfully"), + /// Ok(false) => println!("No matching entry found"), + /// Err(e) => println!("Error: {}", e), + /// } + /// ``` + /// + /// # Errors + /// + /// Returns an error if: + /// - No model exists with the specified identifier + /// - The underlying delete operation fails + /// - The model's data format is incompatible with deletion operations + pub fn delete_model_entry( + &mut self, + identifier: &str, + params: &HashMap, + ) -> Result { + // Find the model and delete the entry + let model_position = self + .models + .iter() + .position(|model| model.identifier == identifier) + .ok_or_else(|| format!("Model with identifier '{identifier}' not found"))?; + + self.models[model_position].delete_entry(params) + } } impl Model { @@ -986,4 +1062,149 @@ impl Model { None } + + /// Deletes a specific entry from the model's data based on the provided parameters. + /// + /// This method removes an entry that matches the provided parameter map from the + /// model's data structure. It supports both object and array-based data and uses + /// the same matching logic as `find_entry_by_hashmap` to locate entries. + /// + /// # Parameters + /// + /// * `params` - A HashMap containing key-value pairs to match against entries + /// + /// # Returns + /// + /// * `Ok(true)` - If an entry was found and successfully deleted + /// * `Ok(false)` - If no matching entry was found to delete + /// * `Err(String)` - If the deletion operation failed + /// + /// # Behavior + /// + /// The method follows this logic: + /// 1. Checks if the model data is an array or object structure + /// 2. For arrays: searches for matching entries and removes them + /// 3. For objects: searches for matching key-value pairs and removes them + /// 4. Uses ID field matching and exact value matching like find operations + /// 5. Modifies the model's data in place + /// + /// # Examples + /// + /// ```rust + /// use json_echo_core::Model; + /// use std::collections::HashMap; + /// + /// let mut model = Model::new("users".to_string(), "id".to_string(), None, None, response_data); + /// + /// let mut params = HashMap::new(); + /// params.insert("id".to_string(), "123".to_string()); + /// + /// match model.delete_entry(¶ms) { + /// Ok(true) => println!("Entry deleted successfully"), + /// Ok(false) => println!("No matching entry found"), + /// Err(e) => println!("Error: {}", e), + /// } + /// ``` + /// + /// # Errors + /// + /// Returns an error if: + /// - The model data structure is corrupted or incompatible + /// - The deletion operation encounters unexpected data types + /// - Memory allocation fails during the operation + #[allow(clippy::needless_pass_by_value)] + pub fn delete_entry(&mut self, params: &HashMap) -> Result { + let id_field = self.get_id_field(); + + // First, identify what needs to be deleted without borrowing mutably + let deletion_info = match &self.data.body { + BodyResponse::Value(Value::Object(obj)) => { + let mut keys_to_remove = Vec::new(); + + for (param_key, param_value) in params { + let clean_key = param_key.replace(':', ""); + + if let Some(val) = obj.get(&clean_key) { + if (param_key.contains(id_field) && val.to_string().as_str() == param_value) + || *val == json!(param_value) + { + keys_to_remove.push(clean_key); + } + } + } + + if keys_to_remove.is_empty() { + None + } else { + Some(DeletionTarget::ObjectKeys(keys_to_remove)) + } + } + BodyResponse::Value(Value::Array(arr)) => { + let mut indices_to_remove = Vec::new(); + + for (index, item) in arr.iter().enumerate() { + if let Value::Object(obj_item) = item { + let mut matches = false; + + for (param_key, param_value) in params { + let clean_key = param_key.replace(':', ""); + + if let Some(val) = obj_item.get(&clean_key) { + if (param_key.contains(id_field) + && val.to_string().as_str() == param_value) + || *val == json!(param_value) + { + matches = true; + break; + } + } + } + + if matches { + indices_to_remove.push(index); + } + } + } + + if indices_to_remove.is_empty() { + None + } else { + Some(DeletionTarget::ArrayIndices(indices_to_remove)) + } + } + BodyResponse::Value(_) => { + return Err( + "Model data is not in a deletable format (must be object or array)".to_string(), + ); + } + _ => { + return Err("Model data is not in a compatible format for deletion".to_string()); + } + }; + + // Now perform the deletion with mutable access + if let Some(target) = deletion_info { + match (&mut self.data.body, target) { + (BodyResponse::Value(Value::Object(obj)), DeletionTarget::ObjectKeys(keys)) => { + for key in &keys { + obj.remove(key); + } + Ok(true) + } + ( + BodyResponse::Value(Value::Array(arr)), + DeletionTarget::ArrayIndices(mut indices), + ) => { + indices.reverse(); // Remove in reverse order to maintain valid indices + for index in indices { + arr.remove(index); + } + Ok(true) + } + _ => Err("Mismatched data structure and deletion target".to_string()), + } + } else { + Ok(false) + } + } } diff --git a/json-echo.json b/json-echo.json index 13f5de2..c9d19a8 100644 --- a/json-echo.json +++ b/json-echo.json @@ -58,6 +58,31 @@ "Content-Type": "application/json" }, "response": "products-response.json" + }, + "[DELETE] /api/v1/posts/{id}": { + "method": "DELETE", + "description": "Delete post by ID", + "headers": { + "Content-Type": "application/json" + }, + "response": { + "status": 204, + "body": {} + } + }, + "[DELETE] /api/v1/products/{code}": { + "method": "DELETE", + "id_field": "code", + "description": "Delete product by code", + "headers": { + "Content-Type": "application/json" + }, + "response": { + "status": 200, + "body": { + "message": "Product deleted successfully" + } + } } } }