vantage_aws/models/s3/object.rs
1use serde::{Deserialize, Serialize};
2use vantage_core::Result;
3use vantage_table::table::Table;
4
5use crate::types::AwsDateTime;
6use crate::{AwsAccount, eq};
7
8/// One S3 object from `ListObjectsV2`. Field names match the wire XML
9/// (`<Contents><Key/><Size/>…</Contents>`) — we surface them as-is so
10/// existing S3 docs translate directly. `Size` arrives as a numeric
11/// string in v0 (no XML-to-typed coercion).
12#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct Object {
14 #[serde(rename = "Key")]
15 pub key: String,
16 #[serde(rename = "Size", default)]
17 pub size: String,
18 #[serde(rename = "LastModified", default)]
19 pub last_modified: String,
20 #[serde(rename = "ETag", default)]
21 pub etag: String,
22 #[serde(rename = "StorageClass", default)]
23 pub storage_class: String,
24}
25
26/// `ListObjectsV2` table. Requires `eq("Bucket", "...")` — without it
27/// the path placeholder errors out at request-build time. Optional
28/// `prefix` / `delimiter` / `max-keys` filters become query params if
29/// supplied.
30///
31/// Auto-paginates: the `@continuation-token=NextContinuationToken`
32/// cursor makes a listing walk every page (S3 returns at most 1000
33/// keys per response, or `max-keys` if set). Cap the walk with
34/// [`AwsAccount::with_max_pages`].
35///
36/// Used as the `objects` relation on [`super::bucket::Bucket`] —
37/// traversing from a single bucket fills `Bucket` automatically.
38pub fn objects_table(aws: AwsAccount) -> Table<AwsAccount, Object> {
39 Table::new(
40 "restxml/Contents@continuation-token=NextContinuationToken:s3/GET /{Bucket}?list-type=2",
41 aws,
42 )
43 .with_id_column("Key")
44 .with_title_column_of::<String>("Size")
45 .with_title_column_of::<AwsDateTime>("LastModified")
46 .with_column_of::<String>("ETag")
47 .with_column_of::<String>("StorageClass")
48}
49
50/// Fetch one object's content (`GetObject`) as text. Goes through the
51/// same REST transport as the listings — signed for a credentialed
52/// [`AwsAccount`], bare for [`AwsAccount::public`] — but skips XML
53/// parsing: the response body *is* the object.
54pub async fn get_object(aws: &AwsAccount, bucket: &str, key: &str) -> Result<String> {
55 let path = crate::restxml::transport::encode_path(&format!("/{bucket}/{key}"));
56 crate::restxml::restxml_call(aws, "s3", "GET", &path, &[]).await
57}
58
59impl Object {
60 /// Build an [`objects_table`] narrowed to the object named in
61 /// `arn`. Accepts ARNs of the shape `arn:aws:s3:::<bucket>/<key>` —
62 /// the bucket fills the path placeholder, the key narrows
63 /// `prefix` so we only fetch the matching object.
64 pub fn from_arn(arn: &str, aws: AwsAccount) -> Option<Table<AwsAccount, Object>> {
65 let rest = arn.strip_prefix("arn:aws:s3:::")?;
66 let (bucket, key) = rest.split_once('/')?;
67 if bucket.is_empty() || key.is_empty() {
68 return None;
69 }
70 let mut t = objects_table(aws);
71 t.add_condition(eq("Bucket", bucket.to_string()));
72 t.add_condition(eq("prefix", key.to_string()));
73 // The post-hoc client filter at `impls::table_source` will
74 // narrow on `Key` once the response comes back — `prefix` only
75 // narrows server-side, so we add the literal Key match too.
76 t.add_condition(eq("Key", key.to_string()));
77 Some(t)
78 }
79}