Implemented methods as defined in the definition + tests

This commit is contained in:
2026-03-15 12:56:43 +01:00
parent d2292b8912
commit 09851b6044
5 changed files with 329 additions and 3 deletions

View File

@@ -0,0 +1,53 @@
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Endpoint {
// Service level
ListBuckets,
// Bucket level
CreateBucket,
DeleteBucket,
HeadBucket,
ListObjectsV2 {
delimiter: Option<String>,
prefix: Option<String>,
max_keys: Option<usize>,
continuation_token: Option<String>,
},
// Object level
GetObject {
key: String,
version_id: Option<String>,
part_number: Option<u64>,
},
PutObject {
key: String,
},
DeleteObject {
key: String,
version_id: Option<String>,
},
HeadObject {
key: String,
version_id: Option<String>,
part_number: Option<u64>,
},
// Multipart
CreateMultipartUpload {
key: String,
},
UploadPart {
key: String,
part_number: u64,
upload_id: String,
},
CompleteMultipartUpload {
key: String,
upload_id: String,
},
AbortMultipartUpload {
key: String,
upload_id: String,
},
}

View File

@@ -1,3 +1,5 @@
mod definitions;
mod parser;
pub use definitions::Endpoint;

View File

@@ -1,3 +1,271 @@
use std::collections::HashMap;
use hyper::Method;
use super::definitions::
use super::definitions::Endpoint;
use crate::errors::ApiError;
pub fn parse_endpoint(
method: &Method,
path: &str,
query: &HashMap<String, String>,
) -> Result<Endpoint, ApiError> {
let segments: Vec<&str> = path
.trim_start_matches('/')
.splitn(2, '/')
.collect();
let bucket = segments.get(0).copied().unwrap_or("");
let key = segments.get(1).copied().unwrap_or("");
match (method, bucket, key) {
// Service level
(&Method::GET, "", "") => Ok(Endpoint::ListBuckets),
// Bucket level
(&Method::PUT, b, "") if !b.is_empty() => Ok(Endpoint::CreateBucket),
(&Method::DELETE, b, "") if !b.is_empty() => Ok(Endpoint::DeleteBucket),
(&Method::HEAD, b, "") if !b.is_empty() => Ok(Endpoint::HeadBucket),
(&Method::GET, b, "") if !b.is_empty() => Ok(Endpoint::ListObjectsV2 {
delimiter: query.get("delimiter").cloned(),
prefix: query.get("prefix").cloned(),
max_keys: query.get("max-keys")
.and_then(|v| v.parse().ok()),
continuation_token: query.get("continuation-token").cloned(),
}),
// Object level
(&Method::GET, _, k) if !k.is_empty() => Ok(Endpoint::GetObject {
key: k.to_string(),
version_id: query.get("versionId").cloned(),
part_number: query.get("partNumber")
.and_then(|v| v.parse().ok()),
}),
(&Method::PUT, _, k) if !k.is_empty() => {
// distinguish UploadPart from PutObject
if let Some(upload_id) = query.get("uploadId") {
Ok(Endpoint::UploadPart {
key: k.to_string(),
upload_id: upload_id.clone(),
part_number: query.get("partNumber")
.and_then(|v| v.parse().ok())
.ok_or(ApiError::InvalidArgument("missing partNumber".into()))?,
})
} else {
Ok(Endpoint::PutObject {
key: k.to_string(),
})
}
},
(&Method::DELETE, _, k) if !k.is_empty() => {
if let Some(upload_id) = query.get("uploadId") {
Ok(Endpoint::AbortMultipartUpload {
key: k.to_string(),
upload_id: upload_id.clone(),
})
} else {
Ok(Endpoint::DeleteObject {
key: k.to_string(),
version_id: query.get("versionId").cloned(),
})
}
},
(&Method::HEAD, _, k) if !k.is_empty() => Ok(Endpoint::HeadObject {
key: k.to_string(),
version_id: query.get("versionId").cloned(),
part_number: query.get("partNumber")
.and_then(|v| v.parse().ok()),
}),
(&Method::POST, _, k) if !k.is_empty() => {
if query.contains_key("uploads") {
Ok(Endpoint::CreateMultipartUpload {
key: k.to_string(),
})
} else if let Some(upload_id) = query.get("uploadId") {
Ok(Endpoint::CompleteMultipartUpload {
key: k.to_string(),
upload_id: upload_id.clone(),
})
} else {
Err(ApiError::InvalidArgument("unknown POST operation".into()))
}
},
_ => Err(ApiError::InvalidArgument(
format!("unknown endpoint: {} {}", method, path)
)),
}
}
// src/stratum-api-s3/src/endpoint/parser.rs
// ... your existing parse_endpoint function ...
#[cfg(test)]
mod tests {
use super::*;
use hyper::Method;
use std::collections::HashMap;
fn empty_query() -> HashMap<String, String> {
HashMap::new()
}
fn query(pairs: &[(&str, &str)]) -> HashMap<String, String> {
pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect()
}
// Service level
#[test]
fn test_list_buckets() {
let result = parse_endpoint(&Method::GET, "/", &empty_query());
assert_eq!(result.unwrap(), Endpoint::ListBuckets);
}
// Bucket level
#[test]
fn test_create_bucket() {
let result = parse_endpoint(&Method::PUT, "/my-bucket", &empty_query());
assert_eq!(result.unwrap(), Endpoint::CreateBucket);
}
#[test]
fn test_delete_bucket() {
let result = parse_endpoint(&Method::DELETE, "/my-bucket", &empty_query());
assert_eq!(result.unwrap(), Endpoint::DeleteBucket);
}
#[test]
fn test_head_bucket() {
let result = parse_endpoint(&Method::HEAD, "/my-bucket", &empty_query());
assert_eq!(result.unwrap(), Endpoint::HeadBucket);
}
#[test]
fn test_list_objects_v2_empty() {
let result = parse_endpoint(&Method::GET, "/my-bucket", &empty_query());
assert_eq!(result.unwrap(), Endpoint::ListObjectsV2 {
delimiter: None,
prefix: None,
max_keys: None,
continuation_token: None,
});
}
#[test]
fn test_list_objects_v2_with_prefix() {
let q = query(&[("prefix", "photos/"), ("max-keys", "100")]);
let result = parse_endpoint(&Method::GET, "/my-bucket", &q);
assert_eq!(result.unwrap(), Endpoint::ListObjectsV2 {
delimiter: None,
prefix: Some("photos/".to_string()),
max_keys: Some(100),
continuation_token: None,
});
}
// Object level
#[test]
fn test_get_object() {
let result = parse_endpoint(&Method::GET, "/my-bucket/photo.jpg", &empty_query());
assert_eq!(result.unwrap(), Endpoint::GetObject {
key: "photo.jpg".to_string(),
version_id: None,
part_number: None,
});
}
#[test]
fn test_get_object_nested_key() {
let result = parse_endpoint(&Method::GET, "/my-bucket/photos/2024/beach.jpg", &empty_query());
assert_eq!(result.unwrap(), Endpoint::GetObject {
key: "photos/2024/beach.jpg".to_string(), // full path preserved
version_id: None,
part_number: None,
});
}
#[test]
fn test_put_object() {
let result = parse_endpoint(&Method::PUT, "/my-bucket/photo.jpg", &empty_query());
assert_eq!(result.unwrap(), Endpoint::PutObject {
key: "photo.jpg".to_string(),
});
}
#[test]
fn test_delete_object() {
let result = parse_endpoint(&Method::DELETE, "/my-bucket/photo.jpg", &empty_query());
assert_eq!(result.unwrap(), Endpoint::DeleteObject {
key: "photo.jpg".to_string(),
version_id: None,
});
}
#[test]
fn test_delete_object_with_version() {
let q = query(&[("versionId", "abc123")]);
let result = parse_endpoint(&Method::DELETE, "/my-bucket/photo.jpg", &q);
assert_eq!(result.unwrap(), Endpoint::DeleteObject {
key: "photo.jpg".to_string(),
version_id: Some("abc123".to_string()),
});
}
#[test]
fn test_head_object() {
let result = parse_endpoint(&Method::HEAD, "/my-bucket/photo.jpg", &empty_query());
assert_eq!(result.unwrap(), Endpoint::HeadObject {
key: "photo.jpg".to_string(),
version_id: None,
part_number: None,
});
}
// Multipart
#[test]
fn test_create_multipart_upload() {
let q = query(&[("uploads", "")]);
let result = parse_endpoint(&Method::POST, "/my-bucket/video.mp4", &q);
assert_eq!(result.unwrap(), Endpoint::CreateMultipartUpload {
key: "video.mp4".to_string(),
});
}
#[test]
fn test_upload_part() {
let q = query(&[("partNumber", "1"), ("uploadId", "abc123")]);
let result = parse_endpoint(&Method::PUT, "/my-bucket/video.mp4", &q);
assert_eq!(result.unwrap(), Endpoint::UploadPart {
key: "video.mp4".to_string(),
part_number: 1,
upload_id: "abc123".to_string(),
});
}
#[test]
fn test_complete_multipart_upload() {
let q = query(&[("uploadId", "abc123")]);
let result = parse_endpoint(&Method::POST, "/my-bucket/video.mp4", &q);
assert_eq!(result.unwrap(), Endpoint::CompleteMultipartUpload {
key: "video.mp4".to_string(),
upload_id: "abc123".to_string(),
});
}
#[test]
fn test_abort_multipart_upload() {
let q = query(&[("uploadId", "abc123")]);
let result = parse_endpoint(&Method::DELETE, "/my-bucket/video.mp4", &q);
assert_eq!(result.unwrap(), Endpoint::AbortMultipartUpload {
key: "video.mp4".to_string(),
upload_id: "abc123".to_string(),
});
}
// Error cases
#[test]
fn test_unknown_endpoint_returns_error() {
let result = parse_endpoint(&Method::PATCH, "/my-bucket/photo.jpg", &empty_query());
assert!(result.is_err());
}
}

View File

@@ -1 +1,3 @@
mod error;
pub use error::ApiError;

View File

@@ -1,3 +1,4 @@
pub mod endpoint;
pub mod common;
pub mod errors;
pub mod errors;
pub use endpoint::Endpoint;