use crate::{
error::{ErrorData, Result},
resource::{ResourceDefinition, ResourceOutputsDefinition, ResourceRef},
ResourceType,
};
use alien_error::AlienError;
use bon::Builder;
use serde::{Deserialize, Serialize};
use std::{any::Any, fmt::Debug};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct LifecycleRule {
pub days: u32,
#[serde(default)]
pub prefix: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")]
pub enum StorageNotificationEvent {
Created,
}
fn default_notification_events() -> Vec<StorageNotificationEvent> {
vec![StorageNotificationEvent::Created]
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Builder)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct StorageNotification {
pub queue: ResourceRef,
#[serde(default = "default_notification_events")]
#[builder(default = default_notification_events())]
pub events: Vec<StorageNotificationEvent>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub prefix: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub suffix: Option<String>,
}
impl StorageNotification {
pub fn matches_key(&self, key: &str) -> bool {
self.prefix
.as_ref()
.is_none_or(|prefix| key.starts_with(prefix))
&& self
.suffix
.as_ref()
.is_none_or(|suffix| key.ends_with(suffix))
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Builder)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
#[builder(start_fn = new)]
pub struct Storage {
#[builder(start_fn)]
pub id: String,
#[serde(default)]
#[builder(default)]
pub public_read: bool,
#[serde(default)]
#[builder(default)]
pub versioning: bool,
#[serde(default)]
#[builder(default)]
pub lifecycle_rules: Vec<LifecycleRule>,
#[serde(default)]
#[builder(default)]
pub cors_allowed_origins: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
#[builder(default)]
#[cfg_attr(feature = "openapi", schema(max_items = 10))]
pub notifications: Vec<StorageNotification>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub encryption_key: Option<ResourceRef>,
}
impl Storage {
pub const RESOURCE_TYPE: ResourceType = ResourceType::from_static("storage");
pub fn id(&self) -> &str {
&self.id
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")]
pub struct StorageOutputs {
pub bucket_name: String,
}
impl ResourceOutputsDefinition for StorageOutputs {
fn get_resource_type(&self) -> ResourceType {
Storage::RESOURCE_TYPE.clone()
}
fn as_any(&self) -> &dyn Any {
self
}
fn box_clone(&self) -> Box<dyn ResourceOutputsDefinition> {
Box::new(self.clone())
}
fn outputs_eq(&self, other: &dyn ResourceOutputsDefinition) -> bool {
other.as_any().downcast_ref::<StorageOutputs>() == Some(self)
}
fn to_json_value(&self) -> serde_json::Result<serde_json::Value> {
serde_json::to_value(self)
}
}
impl ResourceDefinition for Storage {
fn get_resource_type(&self) -> ResourceType {
Self::RESOURCE_TYPE
}
fn id(&self) -> &str {
&self.id
}
fn replace_after_failed_create_is_safe(&self) -> bool {
false
}
fn get_dependencies(&self) -> Vec<ResourceRef> {
let mut dependencies: Vec<_> = self.encryption_key.iter().cloned().collect();
for notification in &self.notifications {
if !dependencies.contains(¬ification.queue) {
dependencies.push(notification.queue.clone());
}
}
dependencies
}
fn validate_update(&self, new_config: &dyn ResourceDefinition) -> Result<()> {
let new_storage = new_config
.as_any()
.downcast_ref::<Storage>()
.ok_or_else(|| {
AlienError::new(ErrorData::UnexpectedResourceType {
resource_id: self.id.clone(),
expected: Self::RESOURCE_TYPE,
actual: new_config.get_resource_type(),
})
})?;
if self.id != new_storage.id {
return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
resource_id: self.id.clone(),
reason: "the 'id' field is immutable".to_string(),
}));
}
if self.encryption_key != new_storage.encryption_key {
return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
resource_id: self.id.clone(),
reason: "the 'encryptionKey' field is immutable once the resource exists"
.to_string(),
}));
}
Ok(())
}
fn as_any(&self) -> &dyn Any {
self
}
fn as_any_mut(&mut self) -> &mut dyn Any {
self
}
fn box_clone(&self) -> Box<dyn ResourceDefinition> {
Box::new(self.clone())
}
fn resource_eq(&self, other: &dyn ResourceDefinition) -> bool {
other.as_any().downcast_ref::<Storage>() == Some(self)
}
fn to_json_value(&self) -> serde_json::Result<serde_json::Value> {
serde_json::to_value(self)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn encryption_key_is_an_implicit_dependency() {
let key = ResourceRef::new(crate::Key::RESOURCE_TYPE, "customer-key");
let storage = Storage::new("customer-data".to_string())
.encryption_key(key.clone())
.build();
assert_eq!(storage.get_dependencies(), vec![key]);
}
#[test]
fn notifications_preserve_existing_serialization_and_deduplicate_dependencies() {
let original: Storage = serde_json::from_value(serde_json::json!({
"id": "objects", "publicRead": false, "versioning": false,
"lifecycleRules": [], "corsAllowedOrigins": []
}))
.unwrap();
assert!(original.notifications.is_empty());
assert_eq!(
serde_json::to_value(&original).unwrap(),
serde_json::json!({
"id": "objects", "publicRead": false, "versioning": false,
"lifecycleRules": [], "corsAllowedOrigins": []
})
);
let key = ResourceRef::new(crate::Key::RESOURCE_TYPE, "key");
let queue = ResourceRef::new(crate::Queue::RESOURCE_TYPE, "work");
let rule = StorageNotification::builder().queue(queue.clone()).build();
let storage = Storage::new("objects".to_string())
.encryption_key(key.clone())
.notifications(vec![rule.clone(), rule])
.build();
assert_eq!(storage.get_dependencies(), vec![key, queue]);
let encoded = serde_json::to_value(&storage).unwrap();
assert_eq!(
encoded["notifications"][0]["events"],
serde_json::json!(["created"])
);
assert_eq!(serde_json::from_value::<Storage>(encoded).unwrap(), storage);
}
#[test]
fn notification_filters_are_literal_case_sensitive_and_combined() {
let rule = StorageNotification::builder()
.queue(ResourceRef::new(crate::Queue::RESOURCE_TYPE, "work"))
.prefix("in/*/é/".to_string())
.suffix(".json".to_string())
.build();
assert!(rule.matches_key("in/*/é/object.json"));
for key in [
"in/a/é/object.json",
"in/*/e/object.json",
"in/*/é/object.JSON",
"other/object.json",
"in/*/é/object.json.tmp",
] {
assert!(!rule.matches_key(key), "{key}");
}
let mut unfiltered = rule.clone();
unfiltered.prefix = None;
unfiltered.suffix = None;
assert!(unfiltered.matches_key("anything"));
unfiltered.suffix = Some(".json".to_string());
assert!(unfiltered.matches_key("any/path.json"));
assert!(!unfiltered.matches_key("any/path.txt"));
unfiltered.prefix = Some("same".to_string());
unfiltered.suffix = Some("same".to_string());
assert!(unfiltered.matches_key("same"));
}
#[test]
fn notification_events_default_to_created_and_reject_unknown_events() {
let rule: StorageNotification = serde_json::from_value(serde_json::json!({
"queue": { "type": "queue", "id": "work" }
}))
.unwrap();
assert_eq!(rule.events, vec![StorageNotificationEvent::Created]);
assert!(
serde_json::from_value::<StorageNotification>(serde_json::json!({
"queue": { "type": "queue", "id": "work" }, "events": ["deleted"]
}))
.is_err()
);
}
}