sie_sdk/client/
connections.rs1use 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#[derive(Debug, Clone)]
19pub struct Connections {
20 client: Client,
21}
22
23impl Client {
24 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 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 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 pub async fn revoke(&self, name: &str) -> Result<ConnectionRevoked> {
77 let canonical = require_connection_name(name)?;
78 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
88pub 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 pub fn source_schema(mut self, schema: impl Into<String>) -> Self {
102 self.source_schema = Some(schema.into());
103 self
104 }
105
106 pub fn sink_schema(mut self, schema: impl Into<String>) -> Self {
108 self.sink_schema = Some(schema.into());
109 self
110 }
111
112 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}