Skip to main content

vantage_aws/vista/
source.rs

1//! `AwsTableShell` — owns a `Table<AwsAccount, EmptyEntity>` and
2//! exposes it through the `TableShell` boundary.
3//!
4//! AWS already speaks `ciborium::Value` natively (the wire protocols
5//! parse into CBOR), so this is a passthrough on reads. Conditions
6//! translate `(field, CborValue)` into an `AwsCondition::Eq` and push
7//! it onto the wrapped table; the dispatch layer folds AwsConditions
8//! into the request body at fetch time. AWS is read-only in v0 — only
9//! `can_count` is advertised.
10//!
11//! AWS list APIs expose no HEAD / COUNT / point-get operation, so
12//! `get_vista_count` and `get_vista_value` both materialise the listing
13//! and then count or filter in memory. [`crate::AwsAccount::with_max_pages`]
14//! caps the walk; without it, both methods will keep paginating until the
15//! response stream is exhausted. Callers that need an unbounded count on
16//! an unbounded list should not call these.
17
18use async_trait::async_trait;
19use ciborium::Value as CborValue;
20use indexmap::IndexMap;
21use vantage_core::Result;
22use vantage_dataset::traits::ReadableValueSet;
23use vantage_table::table::Table;
24use vantage_types::{EmptyEntity, Record};
25use vantage_vista::{
26    Column as VistaColumn, Reference as VistaReference, ReferenceKind, TableShell, Vista,
27    VistaCapabilities, VistaMetadata,
28};
29
30use crate::AwsAccount;
31use crate::condition::AwsCondition;
32
33pub struct AwsTableShell {
34    pub(crate) table: Table<AwsAccount, EmptyEntity>,
35    pub(crate) capabilities: VistaCapabilities,
36    pub(crate) metadata: VistaMetadata,
37}
38
39impl AwsTableShell {
40    pub(crate) fn new(
41        table: Table<AwsAccount, EmptyEntity>,
42        capabilities: VistaCapabilities,
43        metadata: VistaMetadata,
44    ) -> Self {
45        Self {
46            table,
47            capabilities,
48            metadata,
49        }
50    }
51}
52
53#[async_trait]
54impl TableShell for AwsTableShell {
55    fn columns(&self) -> &IndexMap<String, VistaColumn> {
56        &self.metadata.columns
57    }
58
59    fn references(&self) -> &IndexMap<String, VistaReference> {
60        &self.metadata.references
61    }
62
63    fn id_column(&self) -> Option<&str> {
64        self.metadata.id_column.as_deref()
65    }
66
67    async fn list_vista_values(
68        &self,
69        _vista: &Vista,
70    ) -> Result<IndexMap<String, Record<CborValue>>> {
71        self.table.list_values().await
72    }
73
74    async fn get_vista_value(
75        &self,
76        _vista: &Vista,
77        id: &String,
78    ) -> Result<Option<Record<CborValue>>> {
79        // AWS list endpoints don't expose a point-get; fall back to
80        // narrowing the listed map by id — same shape as the REST shell.
81        let mut data = self.table.list_values().await?;
82        Ok(data.shift_remove(id))
83    }
84
85    async fn get_vista_some_value(
86        &self,
87        _vista: &Vista,
88    ) -> Result<Option<(String, Record<CborValue>)>> {
89        let data = self.table.list_values().await?;
90        Ok(data.into_iter().next())
91    }
92
93    async fn get_vista_count(&self, _vista: &Vista) -> Result<i64> {
94        Ok(self.table.list_values().await?.len() as i64)
95    }
96
97    /// One page per call, S3-style. The token is the **last key of the
98    /// previous page**, sent as `start-after` — S3 lists keys in
99    /// lexicographic order and accepts any key as a starting point, so
100    /// unlike an opaque continuation token this cursor survives process
101    /// restarts and can be reconstructed from already-fetched data.
102    /// `IsTruncated` on the response decides whether a next page exists.
103    async fn fetch_next(
104        &self,
105        _vista: &Vista,
106        token: Option<CborValue>,
107    ) -> Result<(Vec<(String, Record<CborValue>)>, Option<CborValue>)> {
108        if !self.capabilities.can_fetch_next {
109            return Err(self.default_error("fetch_next", "can_fetch_next"));
110        }
111        let account = self.table.data_source();
112        let name = self.table.table_name();
113        let mut conditions: Vec<AwsCondition> = self.table.conditions().cloned().collect();
114        if let Some(after) = token {
115            conditions.push(AwsCondition::eq("start-after".to_string(), after));
116        }
117        let resp = account.execute_rpc_page(name, &conditions).await?;
118        let truncated = match resp.get("IsTruncated") {
119            Some(serde_json::Value::String(s)) => s == "true",
120            Some(serde_json::Value::Bool(b)) => *b,
121            _ => false,
122        };
123        let rows = account.parse_records(name, resp, self.metadata.id_column.as_deref())?;
124        let next = truncated
125            .then(|| rows.keys().last().map(|k| CborValue::Text(k.clone())))
126            .flatten();
127        Ok((rows.into_iter().collect(), next))
128    }
129
130    /// Cheap: the wrapped table's query state is small and the account is
131    /// `Arc`-shared. Lets consumers narrow a private copy per use — e.g. an
132    /// augmentation's `Detail::Fixed` rebuilding its detail vista per row.
133    fn clone_shell(&self) -> Option<Box<dyn TableShell>> {
134        Some(Box::new(AwsTableShell {
135            table: self.table.clone(),
136            capabilities: self.capabilities.clone(),
137            metadata: self.metadata.clone(),
138        }))
139    }
140
141    fn add_eq_condition(&mut self, field: &str, value: &CborValue) -> Result<()> {
142        // `AwsCondition::Eq` carries the value as `CborValue` directly;
143        // the wire-format builders (`build_json1_body`, `build_query_form`)
144        // do the JSON / string conversion at execute time.
145        self.table
146            .add_condition(AwsCondition::eq(field.to_string(), value.clone()));
147        Ok(())
148    }
149
150    fn get_ref(&self, relation: &str, row: &Record<CborValue>) -> Result<Vista> {
151        let target = self.table.get_ref_from_row::<EmptyEntity>(relation, row)?;
152        let factory = crate::vista::factory::AwsVistaFactory::new(self.table.data_source().clone());
153        factory.from_table(target)
154    }
155
156    fn get_ref_kinds(&self) -> Vec<(String, ReferenceKind)> {
157        self.table.ref_kinds()
158    }
159
160    fn capabilities(&self) -> &VistaCapabilities {
161        &self.capabilities
162    }
163
164    fn driver_name(&self) -> &'static str {
165        "aws"
166    }
167}
168
169#[cfg(test)]
170mod tests {
171    use super::*;
172    use crate::AwsAccount;
173    use crate::models::iam;
174    use crate::vista::factory::metadata_from_table;
175    use vantage_types::EmptyEntity;
176
177    fn shell_for_iam_users() -> AwsTableShell {
178        let aws = AwsAccount::new("AKIATEST", "secret", "eu-west-2");
179        let table = iam::users_table(aws).into_entity::<EmptyEntity>();
180        let metadata = metadata_from_table(&table);
181        let capabilities = VistaCapabilities {
182            can_count: true,
183            ..VistaCapabilities::default()
184        };
185        AwsTableShell::new(table, capabilities, metadata)
186    }
187
188    #[test]
189    fn add_eq_condition_pushes_aws_condition_eq_onto_wrapped_table() {
190        // `dispatch` reads `table.conditions()` when assembling the
191        // request body; a missing or mistranslated condition is invisible
192        // without this introspection.
193        let mut shell = shell_for_iam_users();
194        shell
195            .add_eq_condition("PathPrefix", &CborValue::Text("/admin/".into()))
196            .expect("add_eq_condition");
197
198        let conditions: Vec<&AwsCondition> = shell.table.conditions().collect();
199        assert_eq!(conditions.len(), 1);
200        match conditions[0] {
201            AwsCondition::Eq { field, value } => {
202                assert_eq!(field, "PathPrefix");
203                assert_eq!(value, &CborValue::Text("/admin/".into()));
204            }
205            other => panic!("expected AwsCondition::Eq, got {other:?}"),
206        }
207    }
208}