Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] }
async-trait = "0.1"

# HTTP and APIs
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "multipart"] }
url = "2.5"
base64 = "0.22"
chrono = { version = "0.4", features = ["serde"] }
Expand Down
28 changes: 28 additions & 0 deletions crates/redis-enterprise/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,34 @@ impl EnterpriseClient {
}
}

/// POST request with multipart/form-data for file uploads
pub async fn post_multipart<T: DeserializeOwned>(
&self,
path: &str,
file_data: Vec<u8>,
field_name: &str,
file_name: &str,
) -> Result<T> {
let url = format!("{}{}", self.base_url, path);
debug!("POST {} (multipart)", url);

let part = reqwest::multipart::Part::bytes(file_data).file_name(file_name.to_string());

let form = reqwest::multipart::Form::new().part(field_name.to_string(), part);

let response = self
.client
.post(&url)
.basic_auth(&self.username, Some(&self.password))
.multipart(form)
.send()
.await
.map_err(|e| self.map_reqwest_error(e, &url))?;

trace!("Response status: {}", response.status());
self.handle_response(response).await
}

/// Get a reference to self for handler construction
pub fn rest_client(&self) -> Self {
self.clone()
Expand Down
10 changes: 5 additions & 5 deletions crates/redis-enterprise/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -167,11 +167,11 @@
//! let v1_actions = actions.v1().list().await?; // GET /v1/actions
//! let v2_actions = actions.v2().list().await?; // GET /v2/actions
//!
//! // Modules: v1 and v2
//! // Modules
//! let modules = ModuleHandler::new(client.clone());
//! let all = modules.v1().list().await?; // GET /v1/modules
//! // v2 upload body can be an opaque Value or multipart in a higher-level helper
//! let uploaded = modules.v2().upload(serde_json::json!({"name": "search", "data": "..."})).await?;
//! let all = modules.list().await?; // GET /v1/modules
//! // Upload uses v2 endpoint with fallback to v1
//! let uploaded = modules.upload(b"module_data".to_vec(), "module.zip").await?;
//! # Ok(())
//! # }
//! ```
Expand Down Expand Up @@ -387,7 +387,7 @@ pub use nodes::{Node, NodeActionRequest, NodeHandler, NodeStats};
pub use users::{CreateUserRequest, Role, RoleHandler, UpdateUserRequest, User, UserHandler};

// Module management
pub use modules::{Module, ModuleHandler, UploadModuleRequest};
pub use modules::{Module, ModuleHandler};

// Action tracking
pub use actions::{Action, ActionHandler};
Expand Down
129 changes: 22 additions & 107 deletions crates/redis-enterprise/src/modules.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@ use serde_json::Value;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Module {
pub uid: String,
pub name: String,
pub version: String,
pub module_name: Option<String>,
pub version: Option<u32>,
pub semantic_version: Option<String>,
pub author: Option<String>,
pub description: Option<String>,
Expand All @@ -24,18 +24,13 @@ pub struct Module {
pub command_line_args: Option<String>,
pub capabilities: Option<Vec<String>>,
pub min_redis_version: Option<String>,
pub min_redis_pack_version: Option<String>,

pub compatible_redis_version: Option<String>,
pub display_name: Option<String>,
pub is_bundled: Option<bool>,
#[serde(flatten)]
pub extra: Value,
}

/// Module upload request
#[derive(Debug, Serialize)]
pub struct UploadModuleRequest {
pub module: Vec<u8>, // Binary module data
}

/// Module handler for managing Redis modules
pub struct ModuleHandler {
client: RestClient,
Expand All @@ -59,14 +54,23 @@ impl ModuleHandler {
self.client.get(&format!("/v1/modules/{}", uid)).await
}

/// Upload new module
pub async fn upload(&self, module_data: Vec<u8>) -> Result<Module> {
// Note: This endpoint typically requires multipart/form-data
// The actual implementation would need to handle file upload
let request = UploadModuleRequest {
module: module_data,
};
self.client.post("/v1/modules", &request).await
/// Upload new module (tries v2 first, falls back to v1)
pub async fn upload(&self, module_data: Vec<u8>, file_name: &str) -> Result<Value> {
// Try v2 first (returns action_uid for async tracking)
match self
.client
.post_multipart("/v2/modules", module_data.clone(), "module", file_name)
.await
{
Ok(response) => Ok(response),
Err(crate::error::RestError::NotFound) => {
// v2 endpoint doesn't exist, try v1
self.client
.post_multipart("/v1/modules", module_data, "module", file_name)
.await
}
Err(e) => Err(e),
}
}

/// Delete module
Expand All @@ -87,93 +91,4 @@ impl ModuleHandler {
.post(&format!("/v1/modules/config/bdb/{}", bdb_uid), &config)
.await
}

/// Upgrade modules for a specific database - POST /v1/modules/upgrade/bdb/{uid}
pub async fn upgrade_bdb(&self, bdb_uid: u32, body: Value) -> Result<Module> {
self.client
.post(&format!("/v1/modules/upgrade/bdb/{}", bdb_uid), &body)
.await
}

/// Upload module via v2 API - POST /v2/modules
pub async fn upload_v2(&self, body: Value) -> Result<Module> {
self.client.post("/v2/modules", &body).await
}

/// Delete module via v2 API - DELETE /v2/modules/{uid}
pub async fn delete_v2(&self, uid: &str) -> Result<()> {
self.client.delete(&format!("/v2/modules/{}", uid)).await
}

// Versioned accessors
pub fn v1(&self) -> v1::ModulesV1 {
v1::ModulesV1::new(self.client.clone())
}

pub fn v2(&self) -> v2::ModulesV2 {
v2::ModulesV2::new(self.client.clone())
}
}

pub mod v1 {
use super::{Module, RestClient};
use crate::error::Result;
use serde_json::Value;

pub struct ModulesV1 {
client: RestClient,
}

impl ModulesV1 {
pub(crate) fn new(client: RestClient) -> Self {
Self { client }
}

pub async fn list(&self) -> Result<Vec<Module>> {
self.client.get("/v1/modules").await
}

pub async fn get(&self, uid: &str) -> Result<Module> {
self.client.get(&format!("/v1/modules/{}", uid)).await
}

pub async fn upload(&self, data: Vec<u8>) -> Result<Module> {
let body = serde_json::json!({ "module": data });
self.client.post("/v1/modules", &body).await
}

pub async fn delete(&self, uid: &str) -> Result<()> {
self.client.delete(&format!("/v1/modules/{}", uid)).await
}

pub async fn update(&self, uid: &str, updates: Value) -> Result<Module> {
self.client
.put(&format!("/v1/modules/{}", uid), &updates)
.await
}
}
}

pub mod v2 {
use super::{Module, RestClient};
use crate::error::Result;
use serde_json::Value;

pub struct ModulesV2 {
client: RestClient,
}

impl ModulesV2 {
pub(crate) fn new(client: RestClient) -> Self {
Self { client }
}

pub async fn upload(&self, body: Value) -> Result<Module> {
self.client.post("/v2/modules", &body).await
}

pub async fn delete(&self, uid: &str) -> Result<()> {
self.client.delete(&format!("/v2/modules/{}", uid)).await
}
}
}
32 changes: 21 additions & 11 deletions crates/redis-enterprise/tests/module_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,9 @@ fn no_content_response() -> ResponseTemplate {
fn test_module() -> serde_json::Value {
json!({
"uid": "1",
"name": "RedisSearch",
"version": "2.6.1",
"status": "loaded",
"module_name": "RedisSearch",
"version": 20601,
"semantic_version": "2.6.1",
"capabilities": ["search", "index"]
})
}
Expand All @@ -39,9 +39,9 @@ async fn test_module_list() {
test_module(),
{
"uid": "2",
"name": "RedisJSON",
"version": "2.4.0",
"status": "loaded",
"module_name": "RedisJSON",
"version": 20400,
"semantic_version": "2.4.0",
"capabilities": ["json"]
}
])))
Expand Down Expand Up @@ -87,13 +87,22 @@ async fn test_module_get() {
assert!(result.is_ok());
let module = result.unwrap();
assert_eq!(module.uid, "1");
assert_eq!(module.name, "RedisSearch");
assert_eq!(module.module_name, Some("RedisSearch".to_string()));
}

#[tokio::test]
async fn test_module_upload() {
let mock_server = MockServer::start().await;

// Mock v2 endpoint as not found
Mock::given(method("POST"))
.and(path("/v2/modules"))
.and(basic_auth("admin", "password"))
.respond_with(ResponseTemplate::new(404))
.mount(&mock_server)
.await;

// Mock v1 endpoint as success
Mock::given(method("POST"))
.and(path("/v1/modules"))
.and(basic_auth("admin", "password"))
Expand All @@ -109,12 +118,13 @@ async fn test_module_upload() {
.unwrap();

let handler = ModuleHandler::new(client);
let result = handler.upload(vec![1, 2, 3, 4]).await; // Mock binary data
let result = handler.upload(vec![1, 2, 3, 4], "test.zip").await; // Mock binary data

assert!(result.is_ok());
let module = result.unwrap();
assert_eq!(module.uid, "1");
assert_eq!(module.name, "RedisSearch");
let response = result.unwrap();
// Response is now a Value, not a Module
assert_eq!(response["uid"], "1");
assert_eq!(response["module_name"], "RedisSearch");
}

#[tokio::test]
Expand Down
4 changes: 4 additions & 0 deletions crates/redisctl/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -992,6 +992,10 @@ pub enum EnterpriseCommands {
/// Log operations
#[command(subcommand)]
Logs(crate::commands::enterprise::logs::LogsCommands),

/// Module management operations
#[command(subcommand)]
Module(crate::commands::enterprise::module::ModuleCommands),
}

// Placeholder command structures - will be expanded in later PRs
Expand Down
2 changes: 2 additions & 0 deletions crates/redisctl/src/commands/enterprise/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ pub mod database;
pub mod database_impl;
pub mod logs;
pub mod logs_impl;
pub mod module;
pub mod module_impl;
pub mod node;
pub mod node_impl;
pub mod rbac;
Expand Down
Loading
Loading