Skip to main content

sie_sdk/client/
connections.rs

1//! Stored credentials for the data stores connector jobs read and write.
2//!
3//! These live on the control plane, not the gateway, so they need `control_plane_url` and
4//! `org` on the client builder. Caller-supplied `base_url_headers` are gateway-edge
5//! credentials and are never sent here.
6
7use reqwest::Method;
8use serde_json::{Map, Value};
9
10use crate::client::jobs::{require_connection_name, require_connection_schema_policy};
11use crate::client::{Client, meta::parse_json};
12use crate::error::{Error, Result};
13use crate::http::{PreparedRequest, headers};
14use crate::retry::RetryPolicy;
15use crate::types::{Connection, ConnectionCreated, ConnectionRevoked};
16
17/// The connections namespace. Obtain one with [`Client::connections`].
18#[derive(Debug, Clone)]
19pub struct Connections {
20    client: Client,
21}
22
23impl Client {
24    /// Operations on stored data-store credentials.
25    pub fn connections(&self) -> Connections {
26        Connections {
27            client: self.clone(),
28        }
29    }
30}
31
32impl Connections {
33    fn base(&self) -> Result<reqwest::Url> {
34        let (control_plane, org) = self.client.control_plane()?;
35        control_plane
36            .join(&format!("internal/orgs/{org}/connections"))
37            .map_err(|err| Error::invalid(format!("could not build the connections URL: {err}")))
38    }
39
40    /// Store a credential.
41    pub fn add(
42        &self,
43        name: impl Into<String>,
44        connection_type: impl Into<String>,
45        secret: impl Into<String>,
46    ) -> ConnectionAdd {
47        ConnectionAdd {
48            client: self.client.clone(),
49            base: self.base(),
50            name: name.into(),
51            connection_type: connection_type.into(),
52            secret: secret.into(),
53            source_schema: None,
54            sink_schema: None,
55        }
56    }
57
58    /// List this org's connections. Secrets are never returned.
59    pub async fn list(&self) -> Result<Vec<Connection>> {
60        let request = PreparedRequest::new(Method::GET, self.base()?)
61            .header("accept", headers::JSON_CONTENT_TYPE);
62        let response = self.client.send_once(request, RetryPolicy::NONE).await?;
63        if let Ok(connections) = serde_json::from_slice::<Vec<Connection>>(&response.body) {
64            return Ok(connections);
65        }
66        let envelope: Value = parse_json(&response, "connection list")?;
67        let data = envelope
68            .get("connections")
69            .cloned()
70            .ok_or_else(|| Error::decode("connection list is missing its `connections` array"))?;
71        serde_json::from_value(data)
72            .map_err(|err| Error::decode(format!("malformed connection list: {err}")))
73    }
74
75    /// Revoke a connection.
76    pub async fn revoke(&self, name: &str) -> Result<ConnectionRevoked> {
77        let canonical = require_connection_name(name)?;
78        // The base has no trailing slash, so `join` would replace its last segment.
79        let url = reqwest::Url::parse(&format!("{}/{canonical}", self.base()?))
80            .map_err(|err| Error::invalid(format!("could not build the connection URL: {err}")))?;
81        let request =
82            PreparedRequest::new(Method::DELETE, url).header("accept", headers::JSON_CONTENT_TYPE);
83        let response = self.client.send_once(request, RetryPolicy::NONE).await?;
84        parse_json(&response, "connection")
85    }
86}
87
88/// Stores a credential. Build with [`Connections::add`].
89pub struct ConnectionAdd {
90    client: Client,
91    base: Result<reqwest::Url>,
92    name: String,
93    connection_type: String,
94    secret: String,
95    source_schema: Option<String>,
96    sink_schema: Option<String>,
97}
98
99impl ConnectionAdd {
100    /// `PostgreSQL` schema connector jobs read from. Must be paired with [`Self::sink_schema`].
101    pub fn source_schema(mut self, schema: impl Into<String>) -> Self {
102        self.source_schema = Some(schema.into());
103        self
104    }
105
106    /// `PostgreSQL` schema connector jobs write to. Must be paired with [`Self::source_schema`].
107    pub fn sink_schema(mut self, schema: impl Into<String>) -> Self {
108        self.sink_schema = Some(schema.into());
109        self
110    }
111
112    /// Send the request.
113    pub async fn send(self) -> Result<ConnectionCreated> {
114        let name = require_connection_name(&self.name)?;
115        let schemas = require_connection_schema_policy(
116            &self.connection_type,
117            self.source_schema.as_deref(),
118            self.sink_schema.as_deref(),
119        )?;
120
121        let mut body = Map::new();
122        body.insert(
123            "type".to_string(),
124            Value::String(self.connection_type.clone()),
125        );
126        body.insert("name".to_string(), Value::String(name));
127        body.insert("secret".to_string(), Value::String(self.secret.clone()));
128        if let Some((source, sink)) = schemas {
129            body.insert("source_schema".to_string(), Value::String(source));
130            body.insert("sink_schema".to_string(), Value::String(sink));
131        }
132
133        let request = PreparedRequest::new(Method::POST, self.base?)
134            .json_headers()
135            .body(serde_json::to_vec(&Value::Object(body)).unwrap_or_default());
136        let response = self.client.send_once(request, RetryPolicy::NONE).await?;
137        parse_json(&response, "connection")
138    }
139}
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144
145    fn configured() -> Client {
146        Client::builder("https://sie.example.com")
147            .control_plane_url("https://cp.example.com")
148            .org("acme")
149            .build()
150            .unwrap()
151    }
152
153    #[test]
154    fn the_namespace_needs_a_control_plane_and_org() {
155        let bare = Client::new("https://sie.example.com").unwrap();
156        assert!(bare.connections().base().is_err());
157        assert_eq!(
158            configured().connections().base().unwrap().as_str(),
159            "https://cp.example.com/internal/orgs/acme/connections"
160        );
161    }
162
163    #[tokio::test]
164    async fn a_malformed_name_is_rejected_before_any_request() {
165        let client = configured();
166        assert!(
167            client
168                .connections()
169                .add("../escape", "postgres", "s")
170                .send()
171                .await
172                .is_err()
173        );
174        assert!(client.connections().revoke("../escape").await.is_err());
175    }
176
177    #[tokio::test]
178    async fn schemas_must_be_supplied_together() {
179        let err = configured()
180            .connections()
181            .add("warehouse", "postgres", "secret")
182            .source_schema("public")
183            .send()
184            .await
185            .unwrap_err();
186        assert!(err.to_string().contains("together"), "{err}");
187    }
188
189    #[test]
190    fn control_plane_requests_carry_no_edge_headers() {
191        let client = Client::builder("https://sie.example.com")
192            .control_plane_url("https://cp.example.com")
193            .org("acme")
194            .base_url_headers(std::collections::HashMap::from([(
195                "Modal-Key".to_string(),
196                "k".to_string(),
197            )]))
198            .build()
199            .unwrap();
200        let control_plane = client.connections().base().unwrap();
201        assert!(!client.edge_headers_apply_to(&control_plane));
202    }
203}