Skip to main content

cloud/envoy/
cloud_object.rs

1//! `cloud.object.*` verb signatures — object-storage bucket lifecycle (R409-T6).
2//!
3//! Three verbs that cover the bucket plane shared by Cloudflare R2 and
4//! Hetzner Object Storage:
5//! - `cloud.object.bucket.create` — provision a bucket
6//! - `cloud.object.bucket.delete` — tear it down
7//! - `cloud.object.bucket.exists` — probe presence without side effects
8//!
9//! ACL management (`cloud.object.bucket.acl.set` from W144) is not modelled
10//! here — Cloudflare R2 exposes public access via custom domains and Workers,
11//! not per-bucket ACLs in the S3 sense. A future ticket can add it once a
12//! second tier-S object-storage provider (Hetzner) is wired through the envoy
13//! framework and the shape can be validated against both.
14//!
15//! `location_hint` is optional in `CloudObjectBucketCreateInput` so that
16//! Cloudflare (global, hint-only) and Hetzner (region-required) can diverge
17//! in adapter-side interpretation without changing the wire shape.
18
19use serde::{Deserialize, Serialize};
20
21use super::{InternalVerb, VerbCategory};
22
23// ── cloud.object.bucket.create ────────────────────────────────────────────
24
25/// Marker type for the `cloud.object.bucket.create` verb.
26pub struct CloudObjectBucketCreate;
27
28/// Request body for `cloud.object.bucket.create`.
29#[derive(Debug, Clone, Serialize, Deserialize)]
30#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
31pub struct CloudObjectBucketCreateInput {
32    /// Bucket name. Provider-side naming rules apply (Cloudflare: 3–63 chars,
33    /// lowercase alphanumeric + hyphens).
34    pub name: String,
35    /// Optional location hint. Adapter-specific: Cloudflare accepts CF
36    /// location codes (`WEUR`, `ENAM`, `WNAM`, `EEUR`, `APAC`); a Hetzner
37    /// adapter would expect region slugs (`fsn1`, `hil`, `ash`). `None`
38    /// delegates placement to the provider's default.
39    #[serde(default, skip_serializing_if = "Option::is_none")]
40    pub location_hint: Option<String>,
41}
42
43/// Response body for `cloud.object.bucket.create`.
44#[derive(Debug, Clone, Serialize, Deserialize)]
45#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
46pub struct CloudObjectBucketCreateOutput {
47    /// S3-compat base endpoint for this account (e.g.
48    /// `https://<account-id>.r2.cloudflarestorage.com` on Cloudflare,
49    /// `https://fsn1.your-objectstorage.com` on Hetzner).
50    pub endpoint: String,
51}
52
53impl InternalVerb for CloudObjectBucketCreate {
54    type Input = CloudObjectBucketCreateInput;
55    type Output = CloudObjectBucketCreateOutput;
56    const ID: &'static str = "cloud.object.bucket.create";
57    const CATEGORY: VerbCategory = VerbCategory::Cloud;
58}
59
60// ── cloud.object.bucket.delete ────────────────────────────────────────────
61
62/// Marker type for the `cloud.object.bucket.delete` verb.
63pub struct CloudObjectBucketDelete;
64
65/// Request body for `cloud.object.bucket.delete`.
66#[derive(Debug, Clone, Serialize, Deserialize)]
67#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
68pub struct CloudObjectBucketDeleteInput {
69    /// Bucket name to delete. Adapters that require an empty bucket before
70    /// deletion (e.g. Hetzner S3) must drain objects themselves; adapters
71    /// where the API handles non-empty buckets (Cloudflare R2) can call
72    /// the delete endpoint directly.
73    pub name: String,
74}
75
76/// Response body for `cloud.object.bucket.delete`. Empty — idempotent; `Ok`
77/// whether the bucket existed or was already gone.
78#[derive(Debug, Clone, Default, Serialize, Deserialize)]
79#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
80pub struct CloudObjectBucketDeleteOutput {}
81
82impl InternalVerb for CloudObjectBucketDelete {
83    type Input = CloudObjectBucketDeleteInput;
84    type Output = CloudObjectBucketDeleteOutput;
85    const ID: &'static str = "cloud.object.bucket.delete";
86    const CATEGORY: VerbCategory = VerbCategory::Cloud;
87}
88
89// ── cloud.object.bucket.exists ────────────────────────────────────────────
90
91/// Marker type for the `cloud.object.bucket.exists` verb.
92pub struct CloudObjectBucketExists;
93
94/// Request body for `cloud.object.bucket.exists`.
95#[derive(Debug, Clone, Serialize, Deserialize)]
96#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
97pub struct CloudObjectBucketExistsInput {
98    /// Bucket name to probe.
99    pub name: String,
100}
101
102/// Response body for `cloud.object.bucket.exists`.
103#[derive(Debug, Clone, Serialize, Deserialize)]
104#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
105pub struct CloudObjectBucketExistsOutput {
106    pub exists: bool,
107    /// S3-compat endpoint when `exists` is `true`; absent when `false`.
108    #[serde(default, skip_serializing_if = "Option::is_none")]
109    pub endpoint: Option<String>,
110}
111
112impl InternalVerb for CloudObjectBucketExists {
113    type Input = CloudObjectBucketExistsInput;
114    type Output = CloudObjectBucketExistsOutput;
115    const ID: &'static str = "cloud.object.bucket.exists";
116    const CATEGORY: VerbCategory = VerbCategory::Cloud;
117}
118
119#[cfg(test)]
120mod tests {
121    use super::*;
122
123    #[test]
124    fn verb_ids_match_canonical_namespace() {
125        assert_eq!(CloudObjectBucketCreate::ID, "cloud.object.bucket.create");
126        assert_eq!(CloudObjectBucketDelete::ID, "cloud.object.bucket.delete");
127        assert_eq!(CloudObjectBucketExists::ID, "cloud.object.bucket.exists");
128        for id in [
129            CloudObjectBucketCreate::ID,
130            CloudObjectBucketDelete::ID,
131            CloudObjectBucketExists::ID,
132        ] {
133            assert!(id.starts_with("cloud."), "{id}");
134        }
135    }
136
137    #[test]
138    fn verbs_are_under_cloud_category() {
139        assert_eq!(CloudObjectBucketCreate::CATEGORY, VerbCategory::Cloud);
140        assert_eq!(CloudObjectBucketDelete::CATEGORY, VerbCategory::Cloud);
141        assert_eq!(CloudObjectBucketExists::CATEGORY, VerbCategory::Cloud);
142    }
143
144    #[test]
145    fn create_input_location_hint_is_optional() {
146        let with_hint = CloudObjectBucketCreateInput {
147            name: "my-bucket".into(),
148            location_hint: Some("WEUR".into()),
149        };
150        let wire = serde_json::to_string(&with_hint).unwrap();
151        let back: CloudObjectBucketCreateInput = serde_json::from_str(&wire).unwrap();
152        assert_eq!(back.location_hint.as_deref(), Some("WEUR"));
153
154        let no_hint = r#"{"name":"my-bucket"}"#;
155        let parsed: CloudObjectBucketCreateInput = serde_json::from_str(no_hint).unwrap();
156        assert!(parsed.location_hint.is_none());
157    }
158
159    #[test]
160    fn create_input_omits_hint_when_absent() {
161        let input = CloudObjectBucketCreateInput {
162            name: "b".into(),
163            location_hint: None,
164        };
165        let wire = serde_json::to_value(&input).unwrap();
166        assert!(!wire.as_object().unwrap().contains_key("location_hint"));
167    }
168
169    #[test]
170    fn delete_output_serializes_to_empty_object() {
171        let wire = serde_json::to_value(CloudObjectBucketDeleteOutput::default()).unwrap();
172        assert_eq!(wire, serde_json::json!({}));
173    }
174
175    #[test]
176    fn exists_output_omits_endpoint_when_absent() {
177        let out = CloudObjectBucketExistsOutput {
178            exists: false,
179            endpoint: None,
180        };
181        let wire = serde_json::to_value(&out).unwrap();
182        assert_eq!(wire, serde_json::json!({ "exists": false }));
183    }
184
185    #[test]
186    fn exists_output_includes_endpoint_when_present() {
187        let out = CloudObjectBucketExistsOutput {
188            exists: true,
189            endpoint: Some("https://acct.r2.cloudflarestorage.com".into()),
190        };
191        let wire = serde_json::to_value(&out).unwrap();
192        assert_eq!(wire["exists"], true);
193        assert!(wire["endpoint"].as_str().is_some());
194    }
195
196    #[cfg(feature = "json-schema")]
197    #[test]
198    fn verbs_emit_schemas_via_for_verb() {
199        use super::super::VerbDescriptor;
200
201        let create = VerbDescriptor::for_verb::<CloudObjectBucketCreate>();
202        assert_eq!(create.id, "cloud.object.bucket.create");
203        assert!(create.input_schema.to_string().contains("name"));
204
205        let delete = VerbDescriptor::for_verb::<CloudObjectBucketDelete>();
206        assert_eq!(delete.id, "cloud.object.bucket.delete");
207
208        let exists = VerbDescriptor::for_verb::<CloudObjectBucketExists>();
209        assert_eq!(exists.id, "cloud.object.bucket.exists");
210        assert!(exists.output_schema.to_string().contains("exists"));
211    }
212}