1use crate::auth::{self, Credentials};
4use crate::errors::{Error, Result, map_connect_error};
5use crate::user_agent::user_agent;
6use buffa::Message;
7use connectrpc::ConnectError;
8use connectrpc::client::{CallOptions, ClientConfig, HttpClient};
9use connectrpc::rustls;
10use http::{HeaderValue, Uri, header::USER_AGENT};
11use serde::Serialize;
12use std::sync::Arc;
13use std::time::Duration;
14
15pub const DEFAULT_API_URL: &str = "https://api-devnet.polyester.ai";
16pub const DEFAULT_WS_URL: &str = "wss://api-devnet.polyester.ai";
17pub const MAX_CONNECT_RESPONSE_BYTES: usize = 4 * 1024 * 1024;
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
26pub enum WireFormat {
27 #[default]
28 Binary,
29 Json,
30}
31
32impl WireFormat {
33 pub fn parse(value: &str) -> Self {
34 match value.trim().to_ascii_lowercase().as_str() {
35 "json" => Self::Json,
36 _ => Self::Binary,
37 }
38 }
39}
40
41#[derive(Debug, Clone)]
43pub struct Config {
44 pub api_url: String,
45 pub ws_url: String,
46 pub timeout: Duration,
47 pub wire_format: WireFormat,
48}
49
50impl Default for Config {
51 fn default() -> Self {
52 Self {
53 api_url: DEFAULT_API_URL.to_owned(),
54 ws_url: DEFAULT_WS_URL.to_owned(),
55 timeout: Duration::from_secs(10),
56 wire_format: WireFormat::Binary,
57 }
58 }
59}
60
61pub type SharedTransport = HttpClient;
63
64#[derive(Clone)]
66pub struct Factory {
67 pub config: Config,
68 pub credentials: Option<Credentials>,
69 transport: SharedTransport,
70 connect_config: ClientConfig,
71}
72
73impl Factory {
74 pub fn new(config: Config, credentials: Option<Credentials>) -> Result<Self> {
75 let uri: Uri = config
76 .api_url
77 .parse()
78 .map_err(|e| Error::validation(format!("invalid api_url: {e}")))?;
79
80 let transport = build_http_client(&config.api_url)?;
81
82 let ua = HeaderValue::from_str(&user_agent())
83 .map_err(|e| Error::validation(format!("invalid User-Agent header value: {e}")))?;
84 let mut connect_config = ClientConfig::new(uri)
85 .with_default_timeout(config.timeout)
86 .with_default_max_message_size(MAX_CONNECT_RESPONSE_BYTES)
87 .with_default_header(USER_AGENT, ua);
88
89 if config.wire_format == WireFormat::Json {
90 connect_config = connect_config.json();
91 }
92
93 Ok(Self {
94 config,
95 credentials,
96 transport,
97 connect_config,
98 })
99 }
100
101 pub(crate) fn transport(&self) -> SharedTransport {
102 self.transport.clone()
103 }
104
105 pub(crate) fn connect_config(&self) -> ClientConfig {
106 self.connect_config.clone()
107 }
108
109 pub fn require_credentials(&self) -> Result<&Credentials> {
110 self.credentials
111 .as_ref()
112 .ok_or_else(|| Error::auth("This endpoint requires Polyester API-key credentials"))
113 }
114
115 pub fn map_error(err: ConnectError) -> Error {
116 map_connect_error(err)
117 }
118
119 pub fn sign_options<M: Message + Serialize>(
122 &self,
123 procedure: &str,
124 request: &M,
125 ) -> Result<CallOptions> {
126 let creds = self.require_credentials()?;
127 let body = match self.config.wire_format {
128 WireFormat::Binary => request.encode_to_bytes(),
129 WireFormat::Json => connectrpc::JsonCodec::encode(request).map_err(Self::map_error)?,
130 };
131 let sign_url = auth::request_url(&self.config.api_url, procedure);
132 let headers = creds.sign_request("POST", &sign_url, &body, None)?;
133 let mut opts = CallOptions::default().with_header(USER_AGENT, user_agent());
134 for (k, v) in headers {
135 opts = opts.with_header(k, v);
136 }
137 Ok(opts)
138 }
139
140 pub async fn sign_options_async<M: Message + Serialize>(
143 &self,
144 procedure: &str,
145 request: &M,
146 ) -> Result<CallOptions> {
147 let creds = self.require_credentials()?;
148 let body = match self.config.wire_format {
149 WireFormat::Binary => request.encode_to_bytes(),
150 WireFormat::Json => connectrpc::JsonCodec::encode(request).map_err(Self::map_error)?,
151 };
152 let sign_url = auth::request_url(&self.config.api_url, procedure);
153 let headers = creds
154 .sign_request_async("POST", &sign_url, &body, None)
155 .await?;
156 let mut opts = CallOptions::default().with_header(USER_AGENT, user_agent());
157 for (k, v) in headers {
158 opts = opts.with_header(k, v);
159 }
160 Ok(opts)
161 }
162}
163
164fn build_http_client(api_url: &str) -> Result<HttpClient> {
165 static INIT: std::sync::Once = std::sync::Once::new();
166 INIT.call_once(|| {
167 let _ = rustls::crypto::ring::default_provider().install_default();
168 });
169
170 if api_url.starts_with("https://") {
171 let mut roots = rustls::RootCertStore::empty();
172 roots.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
173 let tls = Arc::new(
174 rustls::ClientConfig::builder()
175 .with_root_certificates(roots)
176 .with_no_client_auth(),
177 );
178 Ok(HttpClient::with_tls(tls))
179 } else if api_url.starts_with("http://") {
180 Ok(HttpClient::plaintext())
181 } else {
182 Err(Error::validation(
183 "api_url must start with http:// or https://",
184 ))
185 }
186}
187
188#[cfg(test)]
189mod tests {
190 use super::*;
191 use crate::user_agent::user_agent;
192
193 #[test]
194 fn connect_config_sets_polyester_user_agent() {
195 let factory = Factory::new(
196 Config {
197 api_url: "http://127.0.0.1:9".into(),
198 ..Default::default()
199 },
200 None,
201 )
202 .expect("factory");
203 let config = factory.connect_config();
204 let ua = config
205 .default_headers()
206 .get(USER_AGENT)
207 .expect("User-Agent default header")
208 .to_str()
209 .expect("ascii");
210 assert_eq!(ua, user_agent());
211 }
212}