Skip to main content

objectiveai_sdk/http/
client.rs

1//! HTTP client implementation for ObjectiveAI API.
2
3use crate::error;
4use eventsource_stream::Event as MessageEvent;
5use futures::{Stream, StreamExt};
6use reqwest_eventsource::{Event, RequestBuilderExt};
7use std::sync::Arc;
8
9/// HTTP client for making requests to the ObjectiveAI API.
10///
11/// Handles authentication, request building, and response parsing for both
12/// unary and streaming endpoints.
13///
14/// # Example
15///
16/// ```ignore
17/// let client = HttpClient::new(
18///     reqwest::Client::new(),
19///     None, // Use default address
20///     Some("your-api-key"),
21///     None, // user_agent
22///     None, // x_title
23///     None, // http_referer
24///     None, // x_github_authorization
25///     None, // x_openrouter_authorization
26///     None, // x_mcp_authorization
27///     None, // x_viewer_signature
28///     None, // x_viewer_address
29///     None, // x_commit_author_name
30///     None, // x_commit_author_email
31/// );
32/// ```
33#[derive(Debug, Clone)]
34pub struct HttpClient {
35    /// The underlying reqwest HTTP client.
36    pub http_client: reqwest::Client,
37    /// Base URL for API requests. Defaults to `https://api.objectiveai.dev`.
38    pub address: String,
39    /// API key for authentication. Sent as `Bearer` token in `Authorization` header.
40    pub authorization: Option<Arc<String>>,
41    /// Value for the `User-Agent` header.
42    pub user_agent: Option<String>,
43    /// Value for the `X-Title` header.
44    pub x_title: Option<String>,
45    /// Value for both `Referer` and `HTTP-Referer` headers.
46    pub http_referer: Option<String>,
47    /// Value for the `X-GITHUB-AUTHORIZATION` header.
48    pub x_github_authorization: Option<Arc<String>>,
49    /// Value for the `X-OPENROUTER-AUTHORIZATION` header.
50    pub x_openrouter_authorization: Option<Arc<String>>,
51    /// Values for the `X-MCP-AUTHORIZATION` header (JSON-encoded).
52    pub x_mcp_authorization: Option<Arc<std::collections::HashMap<String, String>>>,
53    /// Value for the `X-VIEWER-SIGNATURE` header.
54    pub x_viewer_signature: Option<Arc<String>>,
55    /// Value for the `X-VIEWER-ADDRESS` header.
56    pub x_viewer_address: Option<Arc<String>>,
57    /// Value for the `X-COMMIT-AUTHOR-NAME` header.
58    pub x_commit_author_name: Option<Arc<String>>,
59    /// Value for the `X-COMMIT-AUTHOR-EMAIL` header.
60    pub x_commit_author_email: Option<Arc<String>>,
61}
62
63impl HttpClient {
64    /// Creates a new HTTP client.
65    ///
66    /// # Arguments
67    ///
68    /// * `http_client` - The reqwest client to use for requests
69    /// * `address` - Base URL for API requests (defaults to `https://api.objectiveai.dev`)
70    /// * `authorization` - API key for authentication
71    /// * `user_agent` - Optional User-Agent header value
72    /// * `x_title` - Optional X-Title header value
73    /// * `http_referer` - Optional Referer header value
74    /// * `x_github_authorization` - Optional X-GITHUB-AUTHORIZATION header value
75    /// * `x_openrouter_authorization` - Optional X-OPENROUTER-AUTHORIZATION header value
76    /// * `x_mcp_authorization` - Optional X-MCP-AUTHORIZATION header value (HashMap)
77    /// * `x_viewer_signature` - Optional X-VIEWER-SIGNATURE header value
78    /// * `x_viewer_address` - Optional X-VIEWER-ADDRESS header value
79    /// * `x_commit_author_name` - Optional X-COMMIT-AUTHOR-NAME header value
80    /// * `x_commit_author_email` - Optional X-COMMIT-AUTHOR-EMAIL header value
81    pub fn new(
82        http_client: reqwest::Client,
83        address: Option<impl Into<String>>,
84        authorization: Option<impl Into<String>>,
85        user_agent: Option<impl Into<String>>,
86        x_title: Option<impl Into<String>>,
87        http_referer: Option<impl Into<String>>,
88        x_github_authorization: Option<impl Into<String>>,
89        x_openrouter_authorization: Option<impl Into<String>>,
90        x_mcp_authorization: Option<std::collections::HashMap<String, String>>,
91        x_viewer_signature: Option<impl Into<String>>,
92        x_viewer_address: Option<impl Into<String>>,
93        x_commit_author_name: Option<impl Into<String>>,
94        x_commit_author_email: Option<impl Into<String>>,
95    ) -> Self {
96        #[cfg(feature = "env")]
97        let env = |name: &str| -> Option<String> { std::env::var(name).ok() };
98
99        Self {
100            http_client,
101            address: match address {
102                Some(base) => base.into(),
103                #[cfg(feature = "env")]
104                None => env("OBJECTIVEAI_ADDRESS")
105                    .unwrap_or_else(|| "https://api.objectiveai.dev".to_string()),
106                #[cfg(not(feature = "env"))]
107                None => "https://api.objectiveai.dev".to_string(),
108            },
109            authorization: authorization.map(|k| Arc::new(k.into()))
110                .or_else(|| { #[cfg(feature = "env")] { env("OBJECTIVEAI_AUTHORIZATION").map(Arc::new) } #[cfg(not(feature = "env"))] { None } }),
111            user_agent: user_agent.map(Into::into)
112                .or_else(|| { #[cfg(feature = "env")] { env("USER_AGENT") } #[cfg(not(feature = "env"))] { None } }),
113            x_title: x_title.map(Into::into)
114                .or_else(|| { #[cfg(feature = "env")] { env("X_TITLE") } #[cfg(not(feature = "env"))] { None } }),
115            http_referer: http_referer.map(Into::into)
116                .or_else(|| { #[cfg(feature = "env")] { env("HTTP_REFERER") } #[cfg(not(feature = "env"))] { None } }),
117            x_github_authorization: x_github_authorization.map(|v| Arc::new(v.into()))
118                .or_else(|| { #[cfg(feature = "env")] { env("GITHUB_AUTHORIZATION").map(Arc::new) } #[cfg(not(feature = "env"))] { None } }),
119            x_openrouter_authorization: x_openrouter_authorization.map(|v| Arc::new(v.into()))
120                .or_else(|| { #[cfg(feature = "env")] { env("OPENROUTER_AUTHORIZATION").map(Arc::new) } #[cfg(not(feature = "env"))] { None } }),
121            x_mcp_authorization: x_mcp_authorization.map(Arc::new)
122                .or_else(|| { #[cfg(feature = "env")] { env("MCP_AUTHORIZATION").and_then(|v| serde_json::from_str(&v).ok()).map(Arc::new) } #[cfg(not(feature = "env"))] { None } }),
123            x_viewer_signature: x_viewer_signature.map(|v| Arc::new(v.into()))
124                .or_else(|| { #[cfg(feature = "env")] { env("VIEWER_SIGNATURE").map(Arc::new) } #[cfg(not(feature = "env"))] { None } }),
125            x_viewer_address: x_viewer_address.map(|v| Arc::new(v.into()))
126                .or_else(|| { #[cfg(feature = "env")] { env("VIEWER_ADDRESS").map(Arc::new) } #[cfg(not(feature = "env"))] { None } }),
127            x_commit_author_name: x_commit_author_name.map(|v| Arc::new(v.into()))
128                .or_else(|| { #[cfg(feature = "env")] { env("COMMIT_AUTHOR_NAME").map(Arc::new) } #[cfg(not(feature = "env"))] { None } }),
129            x_commit_author_email: x_commit_author_email.map(|v| Arc::new(v.into()))
130                .or_else(|| { #[cfg(feature = "env")] { env("COMMIT_AUTHOR_EMAIL").map(Arc::new) } #[cfg(not(feature = "env"))] { None } }),
131        }
132    }
133
134    /// Builds a request with authentication and custom headers.
135    fn request(
136        &self,
137        method: reqwest::Method,
138        path: &str,
139        body: Option<impl serde::Serialize>,
140    ) -> reqwest::RequestBuilder {
141        let url = format!(
142            "{}/{}",
143            self.address.trim_end_matches('/'),
144            path.trim_start_matches('/')
145        );
146        let mut request = self.http_client.request(method, &url);
147        if let Some(authorization) = &self.authorization {
148            let key = authorization.strip_prefix("Bearer ").unwrap_or(authorization);
149            request =
150                request.header("authorization", format!("Bearer {}", key));
151        }
152        if let Some(user_agent) = &self.user_agent {
153            request = request.header("user-agent", user_agent);
154        }
155        if let Some(x_title) = &self.x_title {
156            request = request.header("x-title", x_title);
157        }
158        if let Some(http_referer) = &self.http_referer {
159            request = request.header("referer", http_referer);
160            request = request.header("http-referer", http_referer);
161        }
162        if let Some(token) = &self.x_github_authorization {
163            request = request.header("X-GITHUB-AUTHORIZATION", token.as_str());
164        }
165        if let Some(token) = &self.x_openrouter_authorization {
166            request = request.header("X-OPENROUTER-AUTHORIZATION", token.as_str());
167        }
168        if let Some(headers) = &self.x_mcp_authorization {
169            if let Ok(json) = serde_json::to_string(headers.as_ref()) {
170                request = request.header("X-MCP-AUTHORIZATION", json);
171            }
172        }
173        if let Some(sig) = &self.x_viewer_signature {
174            request = request.header("X-VIEWER-SIGNATURE", sig.as_str());
175        }
176        if let Some(addr) = &self.x_viewer_address {
177            request = request.header("X-VIEWER-ADDRESS", addr.as_str());
178        }
179        if let Some(name) = &self.x_commit_author_name {
180            request = request.header("X-COMMIT-AUTHOR-NAME", name.as_str());
181        }
182        if let Some(email) = &self.x_commit_author_email {
183            request = request.header("X-COMMIT-AUTHOR-EMAIL", email.as_str());
184        }
185        if let Some(body) = body {
186            request = request.json(&body);
187        }
188        request
189    }
190
191    /// Sends a unary (request-response) API call and deserializes the response.
192    ///
193    /// # Type Parameters
194    ///
195    /// * `T` - The expected response type to deserialize into
196    ///
197    /// # Errors
198    ///
199    /// Returns [`super::HttpError`] if the request fails, returns a non-success status,
200    /// or the response cannot be deserialized.
201    pub async fn send_unary<T: serde::de::DeserializeOwned + Send + 'static>(
202        &self,
203        method: reqwest::Method,
204        path: impl AsRef<str>,
205        body: Option<impl serde::Serialize>,
206    ) -> Result<T, super::HttpError> {
207        let response = self
208            .http_client
209            .execute(
210                self.request(method, path.as_ref(), body)
211                    .build()
212                    .map_err(super::HttpError::RequestError)?,
213            )
214            .await
215            .map_err(super::HttpError::HttpError)?;
216        let code = response.status();
217        if code.is_success() {
218            let text =
219                response.text().await.map_err(super::HttpError::HttpError)?;
220            let mut de = serde_json::Deserializer::from_str(&text);
221            match serde_path_to_error::deserialize::<_, T>(&mut de) {
222                Ok(value) => Ok(value),
223                Err(e) => Err(super::HttpError::DeserializationError(e)),
224            }
225        } else {
226            match response.text().await {
227                Ok(text) => Err(super::HttpError::BadStatus {
228                    code,
229                    body: match serde_json::from_str::<serde_json::Value>(&text)
230                    {
231                        Ok(body) => body,
232                        Err(_) => serde_json::Value::String(text),
233                    },
234                }),
235                Err(_) => Err(super::HttpError::BadStatus {
236                    code,
237                    body: serde_json::Value::Null,
238                }),
239            }
240        }
241    }
242
243    /// Sends a unary API call that expects no response body.
244    ///
245    /// Useful for DELETE or other operations that only return a status code.
246    ///
247    /// # Errors
248    ///
249    /// Returns [`super::HttpError`] if the request fails or returns a non-success status.
250    pub async fn send_unary_no_response(
251        &self,
252        method: reqwest::Method,
253        path: impl AsRef<str>,
254        body: Option<impl serde::Serialize>,
255    ) -> Result<(), super::HttpError> {
256        let response = self
257            .http_client
258            .execute(
259                self.request(method, path.as_ref(), body)
260                    .build()
261                    .map_err(super::HttpError::RequestError)?,
262            )
263            .await
264            .map_err(super::HttpError::HttpError)?;
265        let code = response.status();
266        if code.is_success() {
267            Ok(())
268        } else {
269            match response.text().await {
270                Ok(text) => Err(super::HttpError::BadStatus {
271                    code,
272                    body: match serde_json::from_str::<serde_json::Value>(&text)
273                    {
274                        Ok(body) => body,
275                        Err(_) => serde_json::Value::String(text),
276                    },
277                }),
278                Err(_) => Err(super::HttpError::BadStatus {
279                    code,
280                    body: serde_json::Value::Null,
281                }),
282            }
283        }
284    }
285
286    /// Sends a streaming API call using Server-Sent Events (SSE).
287    ///
288    /// Returns a stream of deserialized chunks. The stream automatically handles:
289    /// - SSE `[DONE]` messages (filtered out)
290    /// - Comment lines starting with `:` (filtered out)
291    /// - Empty data lines (filtered out)
292    /// - API errors embedded in stream data
293    ///
294    /// # Type Parameters
295    ///
296    /// * `T` - The expected chunk type to deserialize each SSE message into
297    ///
298    /// # Errors
299    ///
300    /// Returns [`super::HttpError`] if the stream cannot be established.
301    pub async fn send_streaming<
302        T: serde::de::DeserializeOwned + Send + 'static,
303        P: AsRef<str> + Send,
304        B: serde::Serialize + Send,
305    >(
306        &self,
307        method: reqwest::Method,
308        path: P,
309        body: Option<B>,
310    ) -> Result<
311        impl Stream<Item = Result<T, super::HttpError>>
312        + Send
313        + 'static
314        + use<T, P, B>,
315        super::HttpError,
316    > {
317        // Stop the stream at [DONE] to prevent reqwest_eventsource from
318        // auto-reconnecting. Uses take_while on the raw SSE events, then
319        // maps/filters the remaining events into typed chunks.
320        Ok(
321            self.request(method, path.as_ref(), body)
322                .eventsource()?
323                .take_while(|result| {
324                    let dominated = matches!(
325                        result,
326                        Ok(Event::Message(MessageEvent { data, .. })) if data == "[DONE]"
327                    );
328                    async move { !dominated }
329                })
330                .then(|result| async {
331                    match result {
332                        Ok(Event::Open) => None,
333                        Ok(Event::Message(MessageEvent { data, .. }))
334                            if data.starts_with(":")
335                                || data.is_empty() =>
336                        {
337                            None
338                        }
339                        Ok(Event::Message(MessageEvent { data, .. })) => {
340                            let mut de =
341                                serde_json::Deserializer::from_str(&data);
342                            Some(
343                                match serde_path_to_error::deserialize::<_, T>(
344                                    &mut de,
345                                ) {
346                                    Ok(value) => Ok(value),
347                                    Err(e) => match serde_json::from_str::<error::ResponseError>(&data) {
348                                        Ok(err) => Err(super::HttpError::ApiError(err)),
349                                        Err(_) => Err(super::HttpError::DeserializationError(e)),
350                                    },
351                                }
352                            )
353                        }
354                        Err(reqwest_eventsource::Error::InvalidStatusCode(
355                            code,
356                            response,
357                        )) => match response.text().await {
358                            Ok(body) => {
359                                Some(Err(super::HttpError::BadStatus {
360                                    code,
361                                    body: match serde_json::from_str::<
362                                        serde_json::Value,
363                                    >(
364                                        &body
365                                    ) {
366                                        Ok(body) => body,
367                                        Err(_) => {
368                                            serde_json::Value::String(body)
369                                        }
370                                    },
371                                }))
372                            }
373                            Err(_) => Some(Err(super::HttpError::BadStatus {
374                                code,
375                                body: serde_json::Value::Null,
376                            })),
377                        },
378                        Err(e) => Some(Err(super::HttpError::StreamError(e))),
379                    }
380                })
381                .filter_map(|x| async { x }),
382        )
383    }
384}