use std::collections::HashMap;
use std::fmt::Display;
use serde::{de::DeserializeOwned, Serialize};
use serde_json::Value;
use crate::clients::RestClient;
use crate::rest::{
build_path, get_path, ResourceError, ResourceOperation, ResourcePath, ResourceResponse,
};
pub trait ReadOnlyResource: RestResource {}
#[allow(async_fn_in_trait)]
pub trait RestResource: Serialize + DeserializeOwned + Clone + Send + Sync + Sized {
type Id: Display + Clone + Send + Sync;
type FindParams: Serialize + Default + Send + Sync;
type AllParams: Serialize + Default + Send + Sync;
type CountParams: Serialize + Default + Send + Sync;
const NAME: &'static str;
const PLURAL: &'static str;
const PATHS: &'static [ResourcePath];
const PREFIX: Option<&'static str> = None;
fn get_id(&self) -> Option<Self::Id>;
#[must_use]
fn resource_key() -> String {
Self::NAME.to_lowercase()
}
async fn find(
client: &RestClient,
id: Self::Id,
params: Option<Self::FindParams>,
) -> Result<ResourceResponse<Self>, ResourceError> {
let mut ids: HashMap<&str, String> = HashMap::new();
ids.insert("id", id.to_string());
let available_ids: Vec<&str> = ids.keys().copied().collect();
let path = get_path(Self::PATHS, ResourceOperation::Find, &available_ids).ok_or(
ResourceError::PathResolutionFailed {
resource: Self::NAME,
operation: "find",
},
)?;
let url = build_path(path.template, &ids);
let full_path = Self::build_full_path(&url);
let query = params
.map(|p| serialize_to_query(&p))
.transpose()?
.filter(|q| !q.is_empty());
let response = client.get(&full_path, query).await?;
if !response.is_ok() {
return Err(ResourceError::from_http_response(
response.code,
&response.body,
Self::NAME,
Some(&id.to_string()),
response.request_id(),
));
}
let key = Self::resource_key();
ResourceResponse::from_http_response(response, &key)
}
async fn all(
client: &RestClient,
params: Option<Self::AllParams>,
) -> Result<ResourceResponse<Vec<Self>>, ResourceError> {
let path = get_path(Self::PATHS, ResourceOperation::All, &[]).ok_or(
ResourceError::PathResolutionFailed {
resource: Self::NAME,
operation: "all",
},
)?;
let url = path.template;
let full_path = Self::build_full_path(url);
let query = params
.map(|p| serialize_to_query(&p))
.transpose()?
.filter(|q| !q.is_empty());
let response = client.get(&full_path, query).await?;
if !response.is_ok() {
return Err(ResourceError::from_http_response(
response.code,
&response.body,
Self::NAME,
None,
response.request_id(),
));
}
ResourceResponse::from_http_response(response, Self::PLURAL)
}
async fn all_with_parent<ParentId: Display + Send>(
client: &RestClient,
parent_id_name: &str,
parent_id: ParentId,
params: Option<Self::AllParams>,
) -> Result<ResourceResponse<Vec<Self>>, ResourceError> {
let mut ids: HashMap<&str, String> = HashMap::new();
ids.insert(parent_id_name, parent_id.to_string());
let available_ids: Vec<&str> = ids.keys().copied().collect();
let path = get_path(Self::PATHS, ResourceOperation::All, &available_ids).ok_or(
ResourceError::PathResolutionFailed {
resource: Self::NAME,
operation: "all",
},
)?;
let url = build_path(path.template, &ids);
let full_path = Self::build_full_path(&url);
let query = params
.map(|p| serialize_to_query(&p))
.transpose()?
.filter(|q| !q.is_empty());
let response = client.get(&full_path, query).await?;
if !response.is_ok() {
return Err(ResourceError::from_http_response(
response.code,
&response.body,
Self::NAME,
None,
response.request_id(),
));
}
ResourceResponse::from_http_response(response, Self::PLURAL)
}
async fn save(&self, client: &RestClient) -> Result<Self, ResourceError> {
let is_new = self.get_id().is_none();
let key = Self::resource_key();
if is_new {
let path = get_path(Self::PATHS, ResourceOperation::Create, &[]).ok_or(
ResourceError::PathResolutionFailed {
resource: Self::NAME,
operation: "create",
},
)?;
let url = path.template;
let full_path = Self::build_full_path(url);
let mut body_map = serde_json::Map::new();
body_map.insert(
key.clone(),
serde_json::to_value(self).map_err(|e| {
ResourceError::Http(crate::clients::HttpError::Response(
crate::clients::HttpResponseError {
code: 400,
message: format!("Failed to serialize resource: {e}"),
error_reference: None,
},
))
})?,
);
let body = Value::Object(body_map);
let response = client.post(&full_path, body, None).await?;
if !response.is_ok() {
return Err(ResourceError::from_http_response(
response.code,
&response.body,
Self::NAME,
None,
response.request_id(),
));
}
let result: ResourceResponse<Self> =
ResourceResponse::from_http_response(response, &key)?;
Ok(result.into_inner())
} else {
let id = self.get_id().unwrap();
let mut ids: HashMap<&str, String> = HashMap::new();
ids.insert("id", id.to_string());
let available_ids: Vec<&str> = ids.keys().copied().collect();
let path = get_path(Self::PATHS, ResourceOperation::Update, &available_ids).ok_or(
ResourceError::PathResolutionFailed {
resource: Self::NAME,
operation: "update",
},
)?;
let url = build_path(path.template, &ids);
let full_path = Self::build_full_path(&url);
let mut body_map = serde_json::Map::new();
body_map.insert(
key.clone(),
serde_json::to_value(self).map_err(|e| {
ResourceError::Http(crate::clients::HttpError::Response(
crate::clients::HttpResponseError {
code: 400,
message: format!("Failed to serialize resource: {e}"),
error_reference: None,
},
))
})?,
);
let body = Value::Object(body_map);
let response = client.put(&full_path, body, None).await?;
if !response.is_ok() {
return Err(ResourceError::from_http_response(
response.code,
&response.body,
Self::NAME,
Some(&id.to_string()),
response.request_id(),
));
}
let result: ResourceResponse<Self> =
ResourceResponse::from_http_response(response, &key)?;
Ok(result.into_inner())
}
}
async fn save_partial(
&self,
client: &RestClient,
changed_fields: Value,
) -> Result<Self, ResourceError> {
let id = self.get_id().ok_or(ResourceError::PathResolutionFailed {
resource: Self::NAME,
operation: "update",
})?;
let key = Self::resource_key();
let mut ids: HashMap<&str, String> = HashMap::new();
ids.insert("id", id.to_string());
let available_ids: Vec<&str> = ids.keys().copied().collect();
let path = get_path(Self::PATHS, ResourceOperation::Update, &available_ids).ok_or(
ResourceError::PathResolutionFailed {
resource: Self::NAME,
operation: "update",
},
)?;
let url = build_path(path.template, &ids);
let full_path = Self::build_full_path(&url);
let mut body_map = serde_json::Map::new();
body_map.insert(key.clone(), changed_fields);
let body = Value::Object(body_map);
let response = client.put(&full_path, body, None).await?;
if !response.is_ok() {
return Err(ResourceError::from_http_response(
response.code,
&response.body,
Self::NAME,
Some(&id.to_string()),
response.request_id(),
));
}
let result: ResourceResponse<Self> = ResourceResponse::from_http_response(response, &key)?;
Ok(result.into_inner())
}
async fn delete(&self, client: &RestClient) -> Result<(), ResourceError> {
let id = self.get_id().ok_or(ResourceError::PathResolutionFailed {
resource: Self::NAME,
operation: "delete",
})?;
let mut ids: HashMap<&str, String> = HashMap::new();
ids.insert("id", id.to_string());
let available_ids: Vec<&str> = ids.keys().copied().collect();
let path = get_path(Self::PATHS, ResourceOperation::Delete, &available_ids).ok_or(
ResourceError::PathResolutionFailed {
resource: Self::NAME,
operation: "delete",
},
)?;
let url = build_path(path.template, &ids);
let full_path = Self::build_full_path(&url);
let response = client.delete(&full_path, None).await?;
if !response.is_ok() {
return Err(ResourceError::from_http_response(
response.code,
&response.body,
Self::NAME,
Some(&id.to_string()),
response.request_id(),
));
}
Ok(())
}
async fn count(
client: &RestClient,
params: Option<Self::CountParams>,
) -> Result<u64, ResourceError> {
let path = get_path(Self::PATHS, ResourceOperation::Count, &[]).ok_or(
ResourceError::PathResolutionFailed {
resource: Self::NAME,
operation: "count",
},
)?;
let url = path.template;
let full_path = Self::build_full_path(url);
let query = params
.map(|p| serialize_to_query(&p))
.transpose()?
.filter(|q| !q.is_empty());
let response = client.get(&full_path, query).await?;
if !response.is_ok() {
return Err(ResourceError::from_http_response(
response.code,
&response.body,
Self::NAME,
None,
response.request_id(),
));
}
let count = response
.body
.get("count")
.and_then(serde_json::Value::as_u64)
.ok_or_else(|| {
ResourceError::Http(crate::clients::HttpError::Response(
crate::clients::HttpResponseError {
code: response.code,
message: "Missing 'count' in response".to_string(),
error_reference: response.request_id().map(ToString::to_string),
},
))
})?;
Ok(count)
}
#[must_use]
fn build_full_path(path: &str) -> String {
Self::PREFIX.map_or_else(|| path.to_string(), |prefix| format!("{prefix}/{path}"))
}
}
fn serialize_to_query<T: Serialize>(params: &T) -> Result<HashMap<String, String>, ResourceError> {
let value = serde_json::to_value(params).map_err(|e| {
ResourceError::Http(crate::clients::HttpError::Response(
crate::clients::HttpResponseError {
code: 400,
message: format!("Failed to serialize params: {e}"),
error_reference: None,
},
))
})?;
let mut query = HashMap::new();
if let Value::Object(map) = value {
for (key, val) in map {
match val {
Value::Null => {} Value::String(s) => {
query.insert(key, s);
}
Value::Number(n) => {
query.insert(key, n.to_string());
}
Value::Bool(b) => {
query.insert(key, b.to_string());
}
Value::Array(arr) => {
let values: Vec<String> = arr
.iter()
.filter_map(|v| match v {
Value::String(s) => Some(s.clone()),
Value::Number(n) => Some(n.to_string()),
_ => None,
})
.collect();
if !values.is_empty() {
query.insert(key, values.join(","));
}
}
Value::Object(_) => {
query.insert(key, val.to_string());
}
}
}
}
Ok(query)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::rest::{ResourceOperation, ResourcePath};
use crate::HttpMethod;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
struct MockProduct {
#[serde(skip_serializing_if = "Option::is_none")]
id: Option<u64>,
title: String,
#[serde(skip_serializing_if = "Option::is_none")]
vendor: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
struct MockProductParams {
#[serde(skip_serializing_if = "Option::is_none")]
limit: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
page_info: Option<String>,
}
impl RestResource for MockProduct {
type Id = u64;
type FindParams = ();
type AllParams = MockProductParams;
type CountParams = ();
const NAME: &'static str = "Product";
const PLURAL: &'static str = "products";
const PATHS: &'static [ResourcePath] = &[
ResourcePath::new(
HttpMethod::Get,
ResourceOperation::Find,
&["id"],
"products/{id}",
),
ResourcePath::new(HttpMethod::Get, ResourceOperation::All, &[], "products"),
ResourcePath::new(
HttpMethod::Get,
ResourceOperation::Count,
&[],
"products/count",
),
ResourcePath::new(HttpMethod::Post, ResourceOperation::Create, &[], "products"),
ResourcePath::new(
HttpMethod::Put,
ResourceOperation::Update,
&["id"],
"products/{id}",
),
ResourcePath::new(
HttpMethod::Delete,
ResourceOperation::Delete,
&["id"],
"products/{id}",
),
];
fn get_id(&self) -> Option<Self::Id> {
self.id
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
struct MockVariant {
#[serde(skip_serializing_if = "Option::is_none")]
id: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
product_id: Option<u64>,
title: String,
}
impl RestResource for MockVariant {
type Id = u64;
type FindParams = ();
type AllParams = ();
type CountParams = ();
const NAME: &'static str = "Variant";
const PLURAL: &'static str = "variants";
const PATHS: &'static [ResourcePath] = &[
ResourcePath::new(
HttpMethod::Get,
ResourceOperation::Find,
&["product_id", "id"],
"products/{product_id}/variants/{id}",
),
ResourcePath::new(
HttpMethod::Get,
ResourceOperation::All,
&["product_id"],
"products/{product_id}/variants",
),
ResourcePath::new(
HttpMethod::Get,
ResourceOperation::Find,
&["id"],
"variants/{id}",
),
];
fn get_id(&self) -> Option<Self::Id> {
self.id
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
struct MockLocation {
#[serde(skip_serializing_if = "Option::is_none")]
id: Option<u64>,
name: String,
active: bool,
}
impl RestResource for MockLocation {
type Id = u64;
type FindParams = ();
type AllParams = ();
type CountParams = ();
const NAME: &'static str = "Location";
const PLURAL: &'static str = "locations";
const PATHS: &'static [ResourcePath] = &[
ResourcePath::new(
HttpMethod::Get,
ResourceOperation::Find,
&["id"],
"locations/{id}",
),
ResourcePath::new(HttpMethod::Get, ResourceOperation::All, &[], "locations"),
ResourcePath::new(
HttpMethod::Get,
ResourceOperation::Count,
&[],
"locations/count",
),
];
fn get_id(&self) -> Option<Self::Id> {
self.id
}
}
impl ReadOnlyResource for MockLocation {}
#[test]
fn test_resource_defines_name_and_paths() {
assert_eq!(MockProduct::NAME, "Product");
assert_eq!(MockProduct::PLURAL, "products");
assert!(!MockProduct::PATHS.is_empty());
}
#[test]
fn test_get_id_returns_none_for_new_resource() {
let product = MockProduct {
id: None,
title: "New".to_string(),
vendor: None,
};
assert!(product.get_id().is_none());
}
#[test]
fn test_get_id_returns_some_for_existing_resource() {
let product = MockProduct {
id: Some(123),
title: "Existing".to_string(),
vendor: None,
};
assert_eq!(product.get_id(), Some(123));
}
#[test]
fn test_build_full_path_without_prefix() {
let path = MockProduct::build_full_path("products/123");
assert_eq!(path, "products/123");
}
#[test]
fn test_serialize_to_query_handles_basic_types() {
#[derive(Serialize)]
struct Params {
limit: u32,
title: String,
active: bool,
}
let params = Params {
limit: 50,
title: "Test".to_string(),
active: true,
};
let query = serialize_to_query(¶ms).unwrap();
assert_eq!(query.get("limit"), Some(&"50".to_string()));
assert_eq!(query.get("title"), Some(&"Test".to_string()));
assert_eq!(query.get("active"), Some(&"true".to_string()));
}
#[test]
fn test_serialize_to_query_skips_none() {
#[derive(Serialize)]
struct Params {
#[serde(skip_serializing_if = "Option::is_none")]
limit: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
page_info: Option<String>,
}
let params = Params {
limit: Some(50),
page_info: None,
};
let query = serialize_to_query(¶ms).unwrap();
assert_eq!(query.get("limit"), Some(&"50".to_string()));
assert!(!query.contains_key("page_info"));
}
#[test]
fn test_serialize_to_query_handles_arrays() {
#[derive(Serialize)]
struct Params {
ids: Vec<u64>,
}
let params = Params { ids: vec![1, 2, 3] };
let query = serialize_to_query(¶ms).unwrap();
assert_eq!(query.get("ids"), Some(&"1,2,3".to_string()));
}
#[test]
fn test_nested_resource_path_selection() {
let path = get_path(MockVariant::PATHS, ResourceOperation::All, &["product_id"]);
assert!(path.is_some());
assert_eq!(path.unwrap().template, "products/{product_id}/variants");
let path = get_path(
MockVariant::PATHS,
ResourceOperation::Find,
&["product_id", "id"],
);
assert!(path.is_some());
assert_eq!(
path.unwrap().template,
"products/{product_id}/variants/{id}"
);
let path = get_path(MockVariant::PATHS, ResourceOperation::Find, &["id"]);
assert!(path.is_some());
assert_eq!(path.unwrap().template, "variants/{id}");
}
#[test]
fn test_resource_trait_bounds() {
fn assert_trait_bounds<T: RestResource>() {}
assert_trait_bounds::<MockProduct>();
assert_trait_bounds::<MockVariant>();
}
#[test]
fn test_resource_key_lowercase() {
assert_eq!(MockProduct::resource_key(), "product");
assert_eq!(MockVariant::resource_key(), "variant");
}
#[test]
fn test_read_only_resource_marker_trait_compiles() {
fn assert_read_only<T: ReadOnlyResource>() {}
assert_read_only::<MockLocation>();
}
#[test]
fn test_read_only_resource_trait_bounds_with_rest_resource() {
fn assert_both_traits<T: ReadOnlyResource + RestResource>() {}
assert_both_traits::<MockLocation>();
}
#[test]
fn test_read_only_resource_has_only_get_paths() {
let paths = MockLocation::PATHS;
assert!(get_path(paths, ResourceOperation::Find, &["id"]).is_some());
assert!(get_path(paths, ResourceOperation::All, &[]).is_some());
assert!(get_path(paths, ResourceOperation::Count, &[]).is_some());
assert!(get_path(paths, ResourceOperation::Create, &[]).is_none());
assert!(get_path(paths, ResourceOperation::Update, &["id"]).is_none());
assert!(get_path(paths, ResourceOperation::Delete, &["id"]).is_none());
}
#[test]
fn test_read_only_resource_implements_rest_resource() {
let location = MockLocation {
id: Some(123),
name: "Main Warehouse".to_string(),
active: true,
};
assert_eq!(location.get_id(), Some(123));
assert_eq!(MockLocation::NAME, "Location");
assert_eq!(MockLocation::PLURAL, "locations");
assert_eq!(MockLocation::resource_key(), "location");
}
}