Skip to main content

inline_sdk/
api.rs

1//! HTTP API helpers for auth, uploads, and selected REST-style Inline endpoints.
2
3use reqwest::{Client, StatusCode};
4use serde::{Deserialize, Serialize};
5use serde_json::{Value, json};
6use std::fmt;
7use std::fs;
8use std::path::PathBuf;
9use std::str::FromStr;
10use std::time::Duration;
11use thiserror::Error;
12use url::Url;
13
14use crate::client_info::{self, AuthMetadata, ClientIdentity};
15
16/// Default timeout for API HTTP requests made by SDK-created clients.
17pub const DEFAULT_API_TIMEOUT: Duration = Duration::from_secs(60);
18const DEFAULT_AUTH_SESSION_CLIENT_TYPE: &str = "api";
19
20/// Error returned by [`ApiClient`] HTTP calls.
21#[derive(Error)]
22#[non_exhaustive]
23pub enum ApiError {
24    /// Invalid Inline API base URL supplied to an API client builder.
25    #[error("invalid API base URL: {message}")]
26    InvalidBaseUrl {
27        /// Original URL value supplied by the caller.
28        url: String,
29        /// Human-readable validation failure.
30        message: String,
31    },
32    /// A network, TLS, redirect, or HTTP client error from `reqwest`.
33    #[error("http error: {0}")]
34    Http(#[from] reqwest::Error),
35    /// Local file system error, currently used when reading upload inputs.
36    #[error("io error: {0}")]
37    Io(#[from] std::io::Error),
38    /// JSON encoding or decoding error.
39    #[error("json error: {0}")]
40    Json(#[from] serde_json::Error),
41    /// SDK input validation error before sending a request.
42    #[error("invalid input: {message}")]
43    InvalidInput {
44        /// Human-readable validation failure.
45        message: String,
46    },
47    /// Non-success HTTP status where the body could not be decoded as an Inline error.
48    #[error("api request failed with HTTP {status}: {message}")]
49    Status {
50        /// HTTP status code.
51        status: u16,
52        /// Human-readable HTTP status text.
53        message: String,
54        /// Short normalized response-body preview, when available.
55        body: Option<String>,
56    },
57    /// Inline API error decoded from a structured response body.
58    #[error("api error: {error}: {description}")]
59    Api {
60        /// HTTP status code, when the error came from an HTTP response.
61        status: Option<u16>,
62        /// Stable server error name or fallback error label.
63        error: String,
64        /// Numeric server error code, when provided.
65        error_code: Option<i32>,
66        /// Human-readable error description.
67        description: String,
68    },
69}
70
71impl fmt::Debug for ApiError {
72    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
73        match self {
74            ApiError::InvalidBaseUrl { url, message } => f
75                .debug_struct("InvalidBaseUrl")
76                .field("url", &api_base_url_for_debug(url))
77                .field("message", message)
78                .finish(),
79            ApiError::Http(error) => f.debug_tuple("Http").field(error).finish(),
80            ApiError::Io(error) => f.debug_tuple("Io").field(error).finish(),
81            ApiError::Json(error) => f.debug_tuple("Json").field(error).finish(),
82            ApiError::InvalidInput { message } => f
83                .debug_struct("InvalidInput")
84                .field("message", message)
85                .finish(),
86            ApiError::Status {
87                status,
88                message,
89                body,
90            } => f
91                .debug_struct("Status")
92                .field("status", status)
93                .field("message", message)
94                .field("body", body)
95                .finish(),
96            ApiError::Api {
97                status,
98                error,
99                error_code,
100                description,
101            } => f
102                .debug_struct("Api")
103                .field("status", status)
104                .field("error", error)
105                .field("error_code", error_code)
106                .field("description", description)
107                .finish(),
108        }
109    }
110}
111
112/// Thin HTTP client for Inline API endpoints.
113#[must_use]
114#[derive(Clone)]
115pub struct ApiClient {
116    base_url: String,
117    http: Client,
118    request_timeout: Option<Duration>,
119}
120
121impl fmt::Debug for ApiClient {
122    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
123        f.debug_struct("ApiClient")
124            .field("base_url", &self.base_url)
125            .field("http", &"<reqwest::Client>")
126            .field("request_timeout", &self.request_timeout)
127            .finish()
128    }
129}
130
131/// Builder for [`ApiClient`].
132#[must_use]
133#[derive(Clone)]
134pub struct ApiClientBuilder {
135    base_url: String,
136    identity: ClientIdentity,
137    http: Option<Client>,
138    request_timeout: Option<Duration>,
139}
140
141impl fmt::Debug for ApiClientBuilder {
142    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
143        f.debug_struct("ApiClientBuilder")
144            .field("base_url", &api_base_url_for_debug(&self.base_url))
145            .field("identity", &self.identity)
146            .field(
147                "http",
148                &self.http.as_ref().map(|_| "<custom reqwest::Client>"),
149            )
150            .field("request_timeout", &self.request_timeout)
151            .finish()
152    }
153}
154
155impl ApiClient {
156    /// Starts an [`ApiClient`] builder for an Inline API base URL.
157    pub fn builder(base_url: impl Into<String>) -> ApiClientBuilder {
158        ApiClientBuilder::new(base_url)
159    }
160
161    /// Creates an API client with the default SDK identity.
162    pub fn try_new(base_url: impl Into<String>) -> Result<Self, ApiError> {
163        Self::builder(base_url).build()
164    }
165
166    /// Creates an API client with a custom client identity.
167    pub fn try_new_with_identity(
168        base_url: impl Into<String>,
169        identity: ClientIdentity,
170    ) -> Result<Self, ApiError> {
171        Self::builder(base_url).identity(identity).build()
172    }
173
174    /// Returns the normalized API base URL.
175    pub fn base_url(&self) -> &str {
176        &self.base_url
177    }
178
179    /// Returns the underlying `reqwest` client.
180    pub fn http_client(&self) -> &Client {
181        &self.http
182    }
183
184    /// Returns the request timeout configured by the SDK builder.
185    ///
186    /// This is `None` when the caller provided a custom `reqwest` client.
187    pub fn request_timeout(&self) -> Option<Duration> {
188        self.request_timeout
189    }
190}
191
192impl ApiClientBuilder {
193    /// Creates a builder with the default SDK identity.
194    pub fn new(base_url: impl Into<String>) -> Self {
195        Self {
196            base_url: base_url.into(),
197            identity: ClientIdentity::sdk(),
198            http: None,
199            request_timeout: Some(DEFAULT_API_TIMEOUT),
200        }
201    }
202
203    /// Sets the client identity used for default HTTP headers and user agent.
204    pub fn identity(mut self, identity: ClientIdentity) -> Self {
205        self.identity = identity;
206        self
207    }
208
209    /// Uses a caller-provided `reqwest` client.
210    ///
211    /// When this is set, the SDK does not inject identity headers into that
212    /// client and does not apply request timeout settings; configure them
213    /// before passing the client if needed.
214    pub fn http_client(mut self, http: Client) -> Self {
215        self.http = Some(http);
216        self
217    }
218
219    /// Sets the timeout for API HTTP requests made by SDK-created clients.
220    pub fn request_timeout(mut self, timeout: Duration) -> Self {
221        self.request_timeout = Some(timeout);
222        self
223    }
224
225    /// Disables the SDK default API request timeout.
226    pub fn without_request_timeout(mut self) -> Self {
227        self.request_timeout = None;
228        self
229    }
230
231    /// Builds the API client.
232    pub fn build(self) -> Result<ApiClient, ApiError> {
233        let base_url = normalize_api_base_url(self.base_url)?;
234        log::debug!(
235            target: "inline_sdk::api",
236            "building API client base_url={base_url} identity_type={} request_timeout={:?} custom_http_client={}",
237            self.identity.client_type(),
238            self.request_timeout,
239            self.http.is_some()
240        );
241        let (http, request_timeout) = match self.http {
242            Some(http) => (http, None),
243            None => {
244                let mut builder = client_info::http_client_builder_for(&self.identity);
245                if let Some(timeout) = self.request_timeout {
246                    builder = builder.timeout(timeout);
247                }
248                (builder.build()?, self.request_timeout)
249            }
250        };
251        Ok(ApiClient {
252            base_url,
253            http,
254            request_timeout,
255        })
256    }
257}
258
259impl ApiClient {
260    /// Creates an API client from a caller-provided `reqwest` client.
261    pub fn try_with_http_client(
262        base_url: impl Into<String>,
263        http: Client,
264    ) -> Result<Self, ApiError> {
265        Ok(Self {
266            base_url: normalize_api_base_url(base_url)?,
267            http,
268            request_timeout: None,
269        })
270    }
271
272    /// Sends an email login code.
273    pub async fn send_email_code(
274        &self,
275        email: &str,
276        metadata: &AuthMetadata,
277    ) -> Result<SendCodeResult, ApiError> {
278        validate_required_str("email", email)?;
279        validate_auth_metadata(metadata)?;
280        let url = format!("{}/sendEmailCode", self.base_url);
281        let mut payload = serde_json::Map::new();
282        payload.insert("email".to_string(), json!(email));
283        add_auth_metadata(&mut payload, metadata);
284        self.post(url, payload).await
285    }
286
287    /// Verifies an email login code and returns an auth token on success.
288    pub async fn verify_email_code(
289        &self,
290        email: &str,
291        code: &str,
292        challenge_token: Option<&str>,
293        metadata: &AuthMetadata,
294    ) -> Result<VerifyCodeResult, ApiError> {
295        validate_required_str("email", email)?;
296        validate_required_str("verification code", code)?;
297        validate_auth_metadata(metadata)?;
298        let url = format!("{}/verifyEmailCode", self.base_url);
299        let mut payload = serde_json::Map::new();
300        payload.insert("email".to_string(), json!(email));
301        payload.insert("code".to_string(), json!(code));
302        if let Some(challenge_token) =
303            challenge_token.filter(|challenge_token| !challenge_token.trim().is_empty())
304        {
305            payload.insert("challengeToken".to_string(), json!(challenge_token));
306        }
307        add_auth_metadata(&mut payload, metadata);
308        self.post(url, payload).await
309    }
310
311    /// Sends an SMS login code.
312    pub async fn send_sms_code(
313        &self,
314        phone_number: &str,
315        metadata: &AuthMetadata,
316    ) -> Result<SendCodeResult, ApiError> {
317        validate_required_str("phone number", phone_number)?;
318        validate_auth_metadata(metadata)?;
319        let url = format!("{}/sendSmsCode", self.base_url);
320        let mut payload = serde_json::Map::new();
321        payload.insert("phoneNumber".to_string(), json!(phone_number));
322        add_auth_metadata(&mut payload, metadata);
323        self.post(url, payload).await
324    }
325
326    /// Verifies an SMS login code and returns an auth token on success.
327    pub async fn verify_sms_code(
328        &self,
329        phone_number: &str,
330        code: &str,
331        metadata: &AuthMetadata,
332    ) -> Result<VerifyCodeResult, ApiError> {
333        validate_required_str("phone number", phone_number)?;
334        validate_required_str("verification code", code)?;
335        validate_auth_metadata(metadata)?;
336        let url = format!("{}/verifySmsCode", self.base_url);
337        let mut payload = serde_json::Map::new();
338        payload.insert("phoneNumber".to_string(), json!(phone_number));
339        payload.insert("code".to_string(), json!(code));
340        add_auth_metadata(&mut payload, metadata);
341        self.post(url, payload).await
342    }
343
344    /// Uploads a local file using the Inline upload endpoint.
345    pub async fn upload_file(
346        &self,
347        token: &str,
348        input: UploadFileInput,
349    ) -> Result<UploadFileResult, ApiError> {
350        validate_bearer_token(token)?;
351        validate_upload_file_input(&input)?;
352        let UploadFileInput {
353            path,
354            file_name,
355            mime_type,
356            file_type,
357            video_metadata,
358        } = input;
359        let bytes = fs::read(&path)?;
360        self.upload_file_bytes(
361            token,
362            UploadFileBytesInput {
363                bytes,
364                file_name,
365                mime_type,
366                file_type,
367                video_metadata,
368            },
369        )
370        .await
371    }
372
373    /// Uploads file bytes using the Inline upload endpoint.
374    pub async fn upload_file_bytes(
375        &self,
376        token: &str,
377        input: UploadFileBytesInput,
378    ) -> Result<UploadFileResult, ApiError> {
379        validate_bearer_token(token)?;
380        validate_upload_file_bytes_input(&input)?;
381        let url = format!("{}/uploadFile", self.base_url);
382        log::debug!(
383            target: "inline_sdk::api",
384            "uploading file bytes type={} size_bytes={} has_mime_type={} has_video_metadata={}",
385            input.file_type,
386            input.bytes.len(),
387            input.mime_type.is_some(),
388            input.video_metadata.is_some()
389        );
390        let mut form = reqwest::multipart::Form::new().text("type", input.file_type.as_str());
391        let mut file_part = reqwest::multipart::Part::bytes(input.bytes);
392        file_part = file_part.file_name(input.file_name);
393        if let Some(mime) = input.mime_type {
394            file_part = file_part.mime_str(&mime)?;
395        }
396        form = form.part("file", file_part);
397
398        if let Some(video) = input.video_metadata {
399            form = form
400                .text("width", video.width.to_string())
401                .text("height", video.height.to_string())
402                .text("duration", video.duration.to_string());
403        }
404
405        let response = self
406            .http
407            .post(url)
408            .bearer_auth(token)
409            .multipart(form)
410            .send()
411            .await?;
412        log::trace!(
413            target: "inline_sdk::api",
414            "uploadFile response status={}",
415            response.status()
416        );
417        decode_api_response(response).await
418    }
419
420    /// Marks messages as read for a peer.
421    pub async fn read_messages(
422        &self,
423        token: &str,
424        input: ReadMessagesInput,
425    ) -> Result<ReadMessagesResult, ApiError> {
426        validate_bearer_token(token)?;
427        validate_peer_id(input.peer)?;
428        if let Some(max_id) = input.max_id {
429            validate_positive_id("max_id", max_id)?;
430        }
431        let url = format!("{}/readMessages", self.base_url);
432        let mut payload = serde_json::Map::new();
433        add_peer_selector_fields(&mut payload, input.peer);
434        if let Some(max_id) = input.max_id {
435            payload.insert("maxId".to_string(), json!(max_id));
436        }
437        self.post_with_token(url, token, payload).await
438    }
439
440    /// Creates or opens a private chat with a user.
441    pub async fn create_private_chat(
442        &self,
443        token: &str,
444        user_id: i64,
445    ) -> Result<CreatePrivateChatResult, ApiError> {
446        validate_bearer_token(token)?;
447        validate_positive_id("user_id", user_id)?;
448        let url = format!("{}/createPrivateChat", self.base_url);
449        let mut payload = serde_json::Map::new();
450        payload.insert("userId".to_string(), json!(user_id));
451        self.post_with_token(url, token, payload).await
452    }
453
454    /// Revokes the current authenticated API session.
455    pub async fn logout(&self, token: &str) -> Result<(), ApiError> {
456        validate_bearer_token(token)?;
457        let url = format!("{}/logout", self.base_url);
458        let _: Value = self
459            .post_with_token(url, token, serde_json::Map::new())
460            .await?;
461        Ok(())
462    }
463
464    /// Creates a Linear issue from an Inline message.
465    pub async fn create_linear_issue(
466        &self,
467        token: &str,
468        input: CreateLinearIssueInput,
469    ) -> Result<CreateLinearIssueResult, ApiError> {
470        validate_bearer_token(token)?;
471        validate_create_linear_issue_input(&input)?;
472        let url = format!("{}/createLinearIssue", self.base_url);
473        let mut payload = serde_json::Map::new();
474        payload.insert("text".to_string(), json!(input.text));
475        payload.insert("messageId".to_string(), json!(input.message_id));
476        payload.insert("chatId".to_string(), json!(input.chat_id));
477        payload.insert("fromId".to_string(), json!(input.from_id));
478
479        payload.insert("peerId".to_string(), json!(peer_id_object(input.peer)));
480
481        if let Some(space_id) = input.space_id {
482            payload.insert("spaceId".to_string(), json!(space_id));
483        }
484
485        self.post_with_token(url, token, payload).await
486    }
487
488    /// Creates a Notion task from an Inline message.
489    pub async fn create_notion_task(
490        &self,
491        token: &str,
492        input: CreateNotionTaskInput,
493    ) -> Result<CreateNotionTaskResult, ApiError> {
494        validate_bearer_token(token)?;
495        validate_create_notion_task_input(&input)?;
496        let url = format!("{}/createNotionTask", self.base_url);
497        let mut payload = serde_json::Map::new();
498        payload.insert("spaceId".to_string(), json!(input.space_id));
499        payload.insert("messageId".to_string(), json!(input.message_id));
500        payload.insert("chatId".to_string(), json!(input.chat_id));
501
502        payload.insert("peerId".to_string(), json!(peer_id_object(input.peer)));
503
504        self.post_with_token(url, token, payload).await
505    }
506
507    async fn post<T: for<'de> Deserialize<'de>>(
508        &self,
509        url: String,
510        payload: serde_json::Map<String, serde_json::Value>,
511    ) -> Result<T, ApiError> {
512        log::trace!(
513            target: "inline_sdk::api",
514            "POST {}",
515            api_url_path_for_log(&url)
516        );
517        let response = self.http.post(url).json(&payload).send().await?;
518        log::trace!(
519            target: "inline_sdk::api",
520            "API response status={}",
521            response.status()
522        );
523        decode_api_response(response).await
524    }
525
526    async fn post_with_token<T: for<'de> Deserialize<'de>>(
527        &self,
528        url: String,
529        token: &str,
530        payload: serde_json::Map<String, serde_json::Value>,
531    ) -> Result<T, ApiError> {
532        log::trace!(
533            target: "inline_sdk::api",
534            "POST {} with bearer auth",
535            api_url_path_for_log(&url)
536        );
537        let response = self
538            .http
539            .post(url)
540            .bearer_auth(token)
541            .json(&payload)
542            .send()
543            .await?;
544        log::trace!(
545            target: "inline_sdk::api",
546            "API response status={}",
547            response.status()
548        );
549        decode_api_response(response).await
550    }
551}
552
553/// Response from sending an auth code.
554#[derive(Clone, Deserialize, Serialize, PartialEq, Eq)]
555#[serde(rename_all = "camelCase")]
556pub struct SendCodeResult {
557    /// Whether the contact belongs to an existing user.
558    pub existing_user: bool,
559    /// Whether the login flow requires an invite code.
560    pub needs_invite_code: bool,
561    /// Challenge token required by some email verification flows.
562    pub challenge_token: Option<String>,
563}
564
565impl fmt::Debug for SendCodeResult {
566    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
567        f.debug_struct("SendCodeResult")
568            .field("existing_user", &self.existing_user)
569            .field("needs_invite_code", &self.needs_invite_code)
570            .field(
571                "challenge_token",
572                &self.challenge_token.as_ref().map(|_| "<redacted>"),
573            )
574            .finish()
575    }
576}
577
578/// Successful auth verification response.
579#[derive(Clone, Deserialize, Serialize, PartialEq, Eq)]
580#[serde(rename_all = "camelCase")]
581pub struct VerifyCodeResult {
582    /// Inline user id.
583    pub user_id: i64,
584    /// Bearer token for authenticated API and realtime calls.
585    pub token: String,
586}
587
588impl fmt::Debug for VerifyCodeResult {
589    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
590        f.debug_struct("VerifyCodeResult")
591            .field("user_id", &self.user_id)
592            .field("token", &"<redacted>")
593            .finish()
594    }
595}
596
597/// Upload type accepted by the Inline upload endpoint.
598#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, Hash)]
599#[non_exhaustive]
600#[serde(rename_all = "lowercase")]
601pub enum UploadFileType {
602    /// Photo/image upload.
603    Photo,
604    /// Video upload.
605    Video,
606    /// Generic document upload.
607    Document,
608}
609
610impl UploadFileType {
611    /// Returns the wire-format upload type string.
612    pub fn as_str(&self) -> &'static str {
613        match self {
614            UploadFileType::Photo => "photo",
615            UploadFileType::Video => "video",
616            UploadFileType::Document => "document",
617        }
618    }
619}
620
621impl fmt::Display for UploadFileType {
622    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
623        f.write_str(self.as_str())
624    }
625}
626
627impl FromStr for UploadFileType {
628    type Err = UploadFileTypeParseError;
629
630    fn from_str(value: &str) -> Result<Self, Self::Err> {
631        match value.trim().to_ascii_lowercase().as_str() {
632            "photo" => Ok(UploadFileType::Photo),
633            "video" => Ok(UploadFileType::Video),
634            "document" => Ok(UploadFileType::Document),
635            _ => Err(UploadFileTypeParseError {
636                value: value.to_string(),
637            }),
638        }
639    }
640}
641
642/// Error returned when parsing an [`UploadFileType`] from a string.
643#[derive(Debug, Clone, Error, PartialEq, Eq)]
644#[error("unknown upload file type `{value}`")]
645pub struct UploadFileTypeParseError {
646    /// Original value that could not be parsed.
647    pub value: String,
648}
649
650/// Video metadata sent with video uploads.
651#[must_use]
652#[derive(Debug, Clone, Copy, PartialEq, Eq)]
653pub struct UploadVideoMetadata {
654    /// Video width in pixels.
655    pub width: i32,
656    /// Video height in pixels.
657    pub height: i32,
658    /// Video duration in seconds.
659    pub duration: i32,
660}
661
662impl UploadVideoMetadata {
663    /// Creates video metadata for a video upload.
664    pub fn new(width: i32, height: i32, duration: i32) -> Self {
665        Self {
666            width,
667            height,
668            duration,
669        }
670    }
671}
672
673/// Local file upload input.
674#[must_use]
675#[derive(Clone, PartialEq, Eq)]
676pub struct UploadFileInput {
677    /// Local path to read and upload.
678    pub path: PathBuf,
679    /// File name reported to the server.
680    pub file_name: String,
681    /// Optional MIME type override.
682    pub mime_type: Option<String>,
683    /// Inline upload category.
684    pub file_type: UploadFileType,
685    /// Required video details when uploading a video.
686    pub video_metadata: Option<UploadVideoMetadata>,
687}
688
689impl fmt::Debug for UploadFileInput {
690    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
691        f.debug_struct("UploadFileInput")
692            .field("path", &"<redacted>")
693            .field("file_name", &self.file_name)
694            .field("mime_type", &self.mime_type)
695            .field("file_type", &self.file_type)
696            .field("video_metadata", &self.video_metadata)
697            .finish()
698    }
699}
700
701impl UploadFileInput {
702    /// Creates an upload input with an explicit upload type.
703    pub fn new(
704        path: impl Into<PathBuf>,
705        file_name: impl Into<String>,
706        file_type: UploadFileType,
707    ) -> Self {
708        Self {
709            path: path.into(),
710            file_name: file_name.into(),
711            mime_type: None,
712            file_type,
713            video_metadata: None,
714        }
715    }
716
717    /// Creates a photo upload input.
718    pub fn photo(path: impl Into<PathBuf>, file_name: impl Into<String>) -> Self {
719        Self::new(path, file_name, UploadFileType::Photo)
720    }
721
722    /// Creates a video upload input with video metadata.
723    pub fn video(
724        path: impl Into<PathBuf>,
725        file_name: impl Into<String>,
726        metadata: UploadVideoMetadata,
727    ) -> Self {
728        Self::new(path, file_name, UploadFileType::Video).with_video_metadata(metadata)
729    }
730
731    /// Creates a generic document upload input.
732    pub fn document(path: impl Into<PathBuf>, file_name: impl Into<String>) -> Self {
733        Self::new(path, file_name, UploadFileType::Document)
734    }
735
736    /// Sets a MIME type override.
737    pub fn with_mime_type(mut self, mime_type: impl Into<String>) -> Self {
738        let mime_type = mime_type.into().trim().to_string();
739        if !mime_type.is_empty() {
740            self.mime_type = Some(mime_type);
741        }
742        self
743    }
744
745    /// Sets video metadata for a video upload.
746    pub fn with_video_metadata(mut self, metadata: UploadVideoMetadata) -> Self {
747        self.video_metadata = Some(metadata);
748        self
749    }
750}
751
752/// In-memory file upload input.
753#[must_use]
754#[derive(Clone, PartialEq, Eq)]
755pub struct UploadFileBytesInput {
756    /// File bytes to upload.
757    pub bytes: Vec<u8>,
758    /// File name reported to the server.
759    pub file_name: String,
760    /// Optional MIME type override.
761    pub mime_type: Option<String>,
762    /// Inline upload category.
763    pub file_type: UploadFileType,
764    /// Required video details when uploading a video.
765    pub video_metadata: Option<UploadVideoMetadata>,
766}
767
768impl fmt::Debug for UploadFileBytesInput {
769    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
770        f.debug_struct("UploadFileBytesInput")
771            .field("bytes_len", &self.bytes.len())
772            .field("file_name", &self.file_name)
773            .field("mime_type", &self.mime_type)
774            .field("file_type", &self.file_type)
775            .field("video_metadata", &self.video_metadata)
776            .finish()
777    }
778}
779
780impl UploadFileBytesInput {
781    /// Creates an upload input with an explicit upload type.
782    pub fn new(
783        bytes: impl Into<Vec<u8>>,
784        file_name: impl Into<String>,
785        file_type: UploadFileType,
786    ) -> Self {
787        Self {
788            bytes: bytes.into(),
789            file_name: file_name.into(),
790            mime_type: None,
791            file_type,
792            video_metadata: None,
793        }
794    }
795
796    /// Creates a photo upload input.
797    pub fn photo(bytes: impl Into<Vec<u8>>, file_name: impl Into<String>) -> Self {
798        Self::new(bytes, file_name, UploadFileType::Photo)
799    }
800
801    /// Creates a video upload input with video metadata.
802    pub fn video(
803        bytes: impl Into<Vec<u8>>,
804        file_name: impl Into<String>,
805        metadata: UploadVideoMetadata,
806    ) -> Self {
807        Self::new(bytes, file_name, UploadFileType::Video).with_video_metadata(metadata)
808    }
809
810    /// Creates a generic document upload input.
811    pub fn document(bytes: impl Into<Vec<u8>>, file_name: impl Into<String>) -> Self {
812        Self::new(bytes, file_name, UploadFileType::Document)
813    }
814
815    /// Sets a MIME type override.
816    pub fn with_mime_type(mut self, mime_type: impl Into<String>) -> Self {
817        let mime_type = mime_type.into().trim().to_string();
818        if !mime_type.is_empty() {
819            self.mime_type = Some(mime_type);
820        }
821        self
822    }
823
824    /// Sets video metadata for a video upload.
825    pub fn with_video_metadata(mut self, metadata: UploadVideoMetadata) -> Self {
826        self.video_metadata = Some(metadata);
827        self
828    }
829}
830
831/// File upload response.
832#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)]
833#[serde(rename_all = "camelCase")]
834pub struct UploadFileResult {
835    /// Stable unique file id.
836    pub file_unique_id: String,
837    /// Uploaded photo id when the file was a photo.
838    pub photo_id: Option<i64>,
839    /// Uploaded video id when the file was a video.
840    pub video_id: Option<i64>,
841    /// Uploaded document id when the file was a document.
842    pub document_id: Option<i64>,
843}
844
845/// Input for marking messages read.
846#[must_use]
847#[derive(Debug, Clone, Copy, PartialEq, Eq)]
848pub struct ReadMessagesInput {
849    /// Peer to mark read.
850    pub peer: PeerId,
851    /// Highest message id to mark as read.
852    pub max_id: Option<i64>,
853}
854
855impl ReadMessagesInput {
856    /// Creates a read-marker request for a peer.
857    pub fn new(peer: PeerId) -> Self {
858        Self { peer, max_id: None }
859    }
860
861    /// Sets the highest message id to mark as read.
862    pub fn with_max_id(mut self, max_id: i64) -> Self {
863        self.max_id = Some(max_id);
864        self
865    }
866}
867
868/// Empty response for marking messages read.
869#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)]
870#[serde(rename_all = "camelCase")]
871pub struct ReadMessagesResult {}
872
873/// Peer identifier used by HTTP API helpers.
874#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
875#[non_exhaustive]
876pub enum PeerId {
877    /// Direct-message peer by user id.
878    User(i64),
879    /// Chat/thread peer by thread id.
880    Thread(i64),
881}
882
883impl PeerId {
884    /// Creates a direct-message peer id.
885    pub fn user(user_id: i64) -> Self {
886        Self::User(user_id)
887    }
888
889    /// Creates a chat/thread peer id.
890    pub fn thread(thread_id: i64) -> Self {
891        Self::Thread(thread_id)
892    }
893
894    /// Returns the user id when this is a user peer.
895    pub fn user_id(self) -> Option<i64> {
896        match self {
897            Self::User(user_id) => Some(user_id),
898            Self::Thread(_) => None,
899        }
900    }
901
902    /// Returns the thread id when this is a thread peer.
903    pub fn thread_id(self) -> Option<i64> {
904        match self {
905            Self::User(_) => None,
906            Self::Thread(thread_id) => Some(thread_id),
907        }
908    }
909}
910
911/// Response from creating or opening a private chat.
912#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
913#[serde(rename_all = "camelCase")]
914pub struct CreatePrivateChatResult {
915    /// Raw chat object returned by the server.
916    pub chat: Value,
917    /// Raw dialog object returned by the server.
918    pub dialog: Value,
919    /// Raw user object returned by the server.
920    pub user: Value,
921}
922
923/// Input for creating a Linear issue from a message.
924#[must_use]
925#[derive(Debug, Clone, PartialEq, Eq)]
926pub struct CreateLinearIssueInput {
927    /// Source message text.
928    pub text: String,
929    /// Source message id.
930    pub message_id: i64,
931    /// Source chat id.
932    pub chat_id: i64,
933    /// Source sender user id.
934    pub from_id: i64,
935    /// Source peer.
936    pub peer: PeerId,
937    /// Optional source space id.
938    pub space_id: Option<i64>,
939}
940
941impl CreateLinearIssueInput {
942    /// Creates input for creating a Linear issue from a message.
943    pub fn new(
944        text: impl Into<String>,
945        message_id: i64,
946        chat_id: i64,
947        from_id: i64,
948        peer: PeerId,
949    ) -> Self {
950        Self {
951            text: text.into(),
952            message_id,
953            chat_id,
954            from_id,
955            peer,
956            space_id: None,
957        }
958    }
959
960    /// Sets the optional source space id.
961    pub fn with_space_id(mut self, space_id: i64) -> Self {
962        self.space_id = Some(space_id);
963        self
964    }
965}
966
967/// Response from creating a Linear issue.
968#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)]
969#[serde(rename_all = "camelCase")]
970pub struct CreateLinearIssueResult {
971    /// Created Linear issue URL, if the integration returned one.
972    pub link: Option<String>,
973}
974
975/// Input for creating a Notion task from a message.
976#[must_use]
977#[derive(Debug, Clone, PartialEq, Eq)]
978pub struct CreateNotionTaskInput {
979    /// Source space id.
980    pub space_id: i64,
981    /// Source message id.
982    pub message_id: i64,
983    /// Source chat id.
984    pub chat_id: i64,
985    /// Source peer.
986    pub peer: PeerId,
987}
988
989impl CreateNotionTaskInput {
990    /// Creates input for creating a Notion task from a message.
991    pub fn new(space_id: i64, message_id: i64, chat_id: i64, peer: PeerId) -> Self {
992        Self {
993            space_id,
994            message_id,
995            chat_id,
996            peer,
997        }
998    }
999}
1000
1001/// Response from creating a Notion task.
1002#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)]
1003#[serde(rename_all = "camelCase")]
1004pub struct CreateNotionTaskResult {
1005    /// Created Notion task URL.
1006    pub url: String,
1007    /// Created task title, when returned by the integration.
1008    pub task_title: Option<String>,
1009}
1010
1011#[derive(Debug, Deserialize)]
1012#[serde(untagged, rename_all = "camelCase")]
1013enum ApiResponse<T> {
1014    Ok {
1015        #[serde(rename = "ok")]
1016        _ok: bool,
1017        result: T,
1018    },
1019    Err {
1020        #[serde(rename = "ok")]
1021        _ok: bool,
1022        error: String,
1023        #[serde(rename = "errorCode", alias = "error_code")]
1024        _error_code: Option<i32>,
1025        description: Option<String>,
1026    },
1027}
1028
1029fn normalize_api_base_url(base_url: impl Into<String>) -> Result<String, ApiError> {
1030    let original = base_url.into();
1031    let normalized = original.trim().trim_end_matches('/').to_string();
1032    if normalized.is_empty() {
1033        return Err(ApiError::InvalidBaseUrl {
1034            url: original,
1035            message: "base URL cannot be empty".to_string(),
1036        });
1037    }
1038
1039    let parsed = Url::parse(&normalized).map_err(|err| ApiError::InvalidBaseUrl {
1040        url: normalized.clone(),
1041        message: err.to_string(),
1042    })?;
1043
1044    if !matches!(parsed.scheme(), "http" | "https") {
1045        return Err(ApiError::InvalidBaseUrl {
1046            url: normalized,
1047            message: "scheme must be http or https".to_string(),
1048        });
1049    }
1050
1051    if parsed.host_str().is_none() {
1052        return Err(ApiError::InvalidBaseUrl {
1053            url: normalized,
1054            message: "host is required".to_string(),
1055        });
1056    }
1057
1058    if !parsed.username().is_empty() || parsed.password().is_some() {
1059        return Err(ApiError::InvalidBaseUrl {
1060            url: normalized,
1061            message: "credentials are not valid in the API base URL".to_string(),
1062        });
1063    }
1064
1065    if parsed.query().is_some() || parsed.fragment().is_some() {
1066        return Err(ApiError::InvalidBaseUrl {
1067            url: normalized,
1068            message: "query strings and fragments are not valid in the API base URL".to_string(),
1069        });
1070    }
1071
1072    Ok(normalized)
1073}
1074
1075fn api_url_path_for_log(url: &str) -> String {
1076    Url::parse(url)
1077        .map(|url| url.path().to_string())
1078        .unwrap_or_else(|_| "<invalid-url>".to_string())
1079}
1080
1081fn api_base_url_for_debug(raw_url: &str) -> String {
1082    Url::parse(raw_url.trim())
1083        .map(|url| {
1084            let host = url.host_str().unwrap_or("<missing-host>");
1085            let port = url
1086                .port()
1087                .map(|port| format!(":{port}"))
1088                .unwrap_or_default();
1089            let path = url.path().trim_end_matches('/');
1090            format!("{}://{}{}{}", url.scheme(), host, port, path)
1091        })
1092        .unwrap_or_else(|_| "<invalid>".to_string())
1093}
1094
1095async fn decode_api_response<T: for<'de> Deserialize<'de>>(
1096    response: reqwest::Response,
1097) -> Result<T, ApiError> {
1098    let status = response.status();
1099    let text = response.text().await?;
1100    decode_api_response_text(status, &text)
1101}
1102
1103fn decode_api_response_text<T: for<'de> Deserialize<'de>>(
1104    status: StatusCode,
1105    text: &str,
1106) -> Result<T, ApiError> {
1107    let value: Value = serde_json::from_str(text).map_err(|err| {
1108        if status.is_success() {
1109            ApiError::Json(err)
1110        } else {
1111            ApiError::Status {
1112                status: status.as_u16(),
1113                message: status
1114                    .canonical_reason()
1115                    .unwrap_or("HTTP error")
1116                    .to_string(),
1117                body: body_preview(text),
1118            }
1119        }
1120    })?;
1121
1122    if !status.is_success() {
1123        return Err(
1124            api_error_from_value(status, &value).unwrap_or_else(|| ApiError::Status {
1125                status: status.as_u16(),
1126                message: status
1127                    .canonical_reason()
1128                    .unwrap_or("HTTP error")
1129                    .to_string(),
1130                body: body_preview(text),
1131            }),
1132        );
1133    }
1134
1135    if value.get("ok").and_then(Value::as_bool) == Some(false) {
1136        return Err(
1137            api_error_from_value(status, &value).unwrap_or_else(|| ApiError::Api {
1138                status: None,
1139                error: "API_ERROR".to_string(),
1140                error_code: None,
1141                description: "The server returned ok=false without an error description."
1142                    .to_string(),
1143            }),
1144        );
1145    }
1146
1147    let api_response: ApiResponse<T> = serde_json::from_value(value)?;
1148    match api_response {
1149        ApiResponse::Ok { result, .. } => Ok(result),
1150        ApiResponse::Err {
1151            error,
1152            _error_code,
1153            description,
1154            ..
1155        } => Err(ApiError::Api {
1156            status: None,
1157            error,
1158            error_code: _error_code,
1159            description: description.unwrap_or_else(|| "Unknown error".to_string()),
1160        }),
1161    }
1162}
1163
1164fn api_error_from_value(status: StatusCode, value: &Value) -> Option<ApiError> {
1165    let object = value.as_object()?;
1166    let error = string_field(value, "error")
1167        .or_else(|| string_field(value, "code"))
1168        .or_else(|| string_field(value, "name"))
1169        .unwrap_or_else(|| {
1170            if value.get("ok").and_then(Value::as_bool) == Some(false) && status.is_success() {
1171                return "API_ERROR".to_string();
1172            }
1173            status
1174                .canonical_reason()
1175                .unwrap_or("HTTP error")
1176                .to_string()
1177        });
1178    let description = string_field(value, "description")
1179        .or_else(|| string_field(value, "message"))
1180        .or_else(|| string_field(value, "detail"))
1181        .unwrap_or_else(|| "No server error description was provided.".to_string());
1182    let error_code = object
1183        .get("error_code")
1184        .or_else(|| object.get("errorCode"))
1185        .and_then(|value| value.as_i64())
1186        .and_then(|value| i32::try_from(value).ok());
1187
1188    Some(ApiError::Api {
1189        status: Some(status.as_u16()),
1190        error,
1191        error_code,
1192        description,
1193    })
1194}
1195
1196fn string_field(value: &Value, key: &str) -> Option<String> {
1197    value
1198        .get(key)
1199        .and_then(Value::as_str)
1200        .map(str::trim)
1201        .filter(|value| !value.is_empty())
1202        .map(ToString::to_string)
1203}
1204
1205fn body_preview(text: &str) -> Option<String> {
1206    let normalized = text.split_whitespace().collect::<Vec<_>>().join(" ");
1207    if normalized.is_empty() {
1208        return None;
1209    }
1210    const MAX_BODY_PREVIEW_BYTES: usize = 500;
1211    if normalized.len() <= MAX_BODY_PREVIEW_BYTES {
1212        return Some(normalized);
1213    }
1214
1215    let mut end = MAX_BODY_PREVIEW_BYTES;
1216    while !normalized.is_char_boundary(end) {
1217        end -= 1;
1218    }
1219    Some(format!("{}...", &normalized[..end]))
1220}
1221
1222fn validate_required_str(field: &'static str, value: &str) -> Result<(), ApiError> {
1223    if value.trim().is_empty() {
1224        return Err(ApiError::InvalidInput {
1225            message: format!("{field} cannot be empty"),
1226        });
1227    }
1228    Ok(())
1229}
1230
1231fn validate_bearer_token(token: &str) -> Result<(), ApiError> {
1232    validate_required_str("bearer token", token)
1233}
1234
1235fn validate_auth_metadata(metadata: &AuthMetadata) -> Result<(), ApiError> {
1236    validate_required_str("device id", metadata.device_id())
1237}
1238
1239fn validate_upload_file_input(input: &UploadFileInput) -> Result<(), ApiError> {
1240    validate_upload_file_metadata(&input.file_name, input.file_type, input.video_metadata)
1241}
1242
1243fn validate_upload_file_bytes_input(input: &UploadFileBytesInput) -> Result<(), ApiError> {
1244    validate_upload_file_metadata(&input.file_name, input.file_type, input.video_metadata)?;
1245    if input.bytes.is_empty() {
1246        return Err(ApiError::InvalidInput {
1247            message: "upload file bytes cannot be empty".to_string(),
1248        });
1249    }
1250    Ok(())
1251}
1252
1253fn validate_upload_file_metadata(
1254    file_name: &str,
1255    file_type: UploadFileType,
1256    video_metadata: Option<UploadVideoMetadata>,
1257) -> Result<(), ApiError> {
1258    if file_name.trim().is_empty() {
1259        return Err(ApiError::InvalidInput {
1260            message: "upload file name cannot be empty".to_string(),
1261        });
1262    }
1263
1264    match (file_type, video_metadata) {
1265        (UploadFileType::Video, Some(metadata)) => validate_upload_video_metadata(metadata),
1266        (UploadFileType::Video, None) => Err(ApiError::InvalidInput {
1267            message: "video uploads require video metadata".to_string(),
1268        }),
1269        (_, Some(_)) => Err(ApiError::InvalidInput {
1270            message: "video metadata can only be used with video uploads".to_string(),
1271        }),
1272        (_, None) => Ok(()),
1273    }
1274}
1275
1276fn validate_upload_video_metadata(metadata: UploadVideoMetadata) -> Result<(), ApiError> {
1277    if metadata.width <= 0 || metadata.height <= 0 || metadata.duration <= 0 {
1278        return Err(ApiError::InvalidInput {
1279            message: "video metadata width, height, and duration must be positive".to_string(),
1280        });
1281    }
1282    Ok(())
1283}
1284
1285fn validate_positive_id(field: &'static str, value: i64) -> Result<(), ApiError> {
1286    if value <= 0 {
1287        return Err(ApiError::InvalidInput {
1288            message: format!("{field} must be positive"),
1289        });
1290    }
1291    Ok(())
1292}
1293
1294fn validate_peer_id(peer: PeerId) -> Result<(), ApiError> {
1295    match peer {
1296        PeerId::User(user_id) => validate_positive_id("peer user id", user_id),
1297        PeerId::Thread(thread_id) => validate_positive_id("peer thread id", thread_id),
1298    }
1299}
1300
1301fn validate_create_linear_issue_input(input: &CreateLinearIssueInput) -> Result<(), ApiError> {
1302    validate_required_str("Linear issue text", &input.text)?;
1303    validate_positive_id("message id", input.message_id)?;
1304    validate_positive_id("chat id", input.chat_id)?;
1305    validate_positive_id("sender user id", input.from_id)?;
1306    validate_peer_id(input.peer)?;
1307    if let Some(space_id) = input.space_id {
1308        validate_positive_id("space id", space_id)?;
1309    }
1310    Ok(())
1311}
1312
1313fn validate_create_notion_task_input(input: &CreateNotionTaskInput) -> Result<(), ApiError> {
1314    validate_positive_id("space id", input.space_id)?;
1315    validate_positive_id("message id", input.message_id)?;
1316    validate_positive_id("chat id", input.chat_id)?;
1317    validate_peer_id(input.peer)
1318}
1319
1320fn add_auth_metadata(
1321    payload: &mut serde_json::Map<String, serde_json::Value>,
1322    metadata: &AuthMetadata,
1323) {
1324    payload.insert("deviceId".to_string(), json!(metadata.device_id()));
1325    payload.insert(
1326        "clientType".to_string(),
1327        json!(auth_session_client_type(metadata.client())),
1328    );
1329    payload.insert(
1330        "clientVersion".to_string(),
1331        json!(metadata.client().client_version()),
1332    );
1333    if let Some(device_name) = metadata.device_name() {
1334        payload.insert("deviceName".to_string(), json!(device_name));
1335    }
1336}
1337
1338fn auth_session_client_type(identity: &ClientIdentity) -> &str {
1339    match identity.client_type() {
1340        // Keep this to server session client types. Custom SDK,
1341        // bridge, and agent identities still travel in headers/user agents.
1342        "ios" | "macos" | "web" | "api" | "android" | "windows" | "linux" | "cli" => {
1343            identity.client_type()
1344        }
1345        _ => DEFAULT_AUTH_SESSION_CLIENT_TYPE,
1346    }
1347}
1348
1349fn add_peer_selector_fields(
1350    payload: &mut serde_json::Map<String, serde_json::Value>,
1351    peer: PeerId,
1352) {
1353    match peer {
1354        PeerId::User(user_id) => {
1355            payload.insert("peerUserId".to_string(), json!(user_id));
1356        }
1357        PeerId::Thread(thread_id) => {
1358            payload.insert("peerThreadId".to_string(), json!(thread_id));
1359        }
1360    }
1361}
1362
1363fn peer_id_object(peer: PeerId) -> serde_json::Map<String, serde_json::Value> {
1364    let mut peer_id = serde_json::Map::new();
1365    match peer {
1366        PeerId::User(user_id) => {
1367            peer_id.insert("userId".to_string(), json!(user_id));
1368        }
1369        PeerId::Thread(thread_id) => {
1370            peer_id.insert("threadId".to_string(), json!(thread_id));
1371        }
1372    }
1373    peer_id
1374}
1375
1376#[cfg(test)]
1377mod tests {
1378    use super::*;
1379    use std::collections::BTreeMap;
1380    use std::future::Future;
1381    use tokio::io::{AsyncReadExt, AsyncWriteExt};
1382    use tokio::net::TcpListener;
1383
1384    #[derive(Debug, Deserialize, PartialEq)]
1385    #[serde(rename_all = "camelCase")]
1386    struct TestResult {
1387        value: String,
1388    }
1389
1390    #[derive(Debug)]
1391    struct CapturedRequest {
1392        method: String,
1393        path: String,
1394        headers: BTreeMap<String, String>,
1395        body: Value,
1396    }
1397
1398    #[test]
1399    fn api_client_builder_normalizes_base_url() {
1400        let client = ApiClient::try_new(" https://api.inline.chat/v1/ ").unwrap();
1401        assert_eq!(client.base_url(), "https://api.inline.chat/v1");
1402    }
1403
1404    #[test]
1405    fn api_client_builder_uses_default_request_timeout() {
1406        let client = ApiClient::try_new("https://api.inline.chat/v1").unwrap();
1407        assert_eq!(client.request_timeout(), Some(DEFAULT_API_TIMEOUT));
1408    }
1409
1410    #[test]
1411    fn api_client_builder_can_override_or_disable_request_timeout() {
1412        let client = ApiClient::builder("https://api.inline.chat/v1")
1413            .request_timeout(Duration::from_secs(5))
1414            .build()
1415            .unwrap();
1416        assert_eq!(client.request_timeout(), Some(Duration::from_secs(5)));
1417
1418        let client = ApiClient::builder("https://api.inline.chat/v1")
1419            .without_request_timeout()
1420            .build()
1421            .unwrap();
1422        assert_eq!(client.request_timeout(), None);
1423    }
1424
1425    #[test]
1426    fn custom_http_clients_own_their_timeout_policy() {
1427        let http = reqwest::Client::builder()
1428            .timeout(Duration::from_secs(10))
1429            .build()
1430            .unwrap();
1431        let client = ApiClient::try_with_http_client("https://api.inline.chat/v1", http).unwrap();
1432        assert_eq!(client.request_timeout(), None);
1433
1434        let http = reqwest::Client::builder()
1435            .timeout(Duration::from_secs(10))
1436            .build()
1437            .unwrap();
1438        let client = ApiClient::builder("https://api.inline.chat/v1")
1439            .request_timeout(Duration::from_secs(5))
1440            .http_client(http)
1441            .build()
1442            .unwrap();
1443        assert_eq!(client.request_timeout(), None);
1444    }
1445
1446    #[test]
1447    fn api_client_builder_rejects_invalid_base_url() {
1448        let err = match ApiClient::try_new("inline.test") {
1449            Ok(_) => panic!("expected invalid base URL"),
1450            Err(err) => err,
1451        };
1452        match err {
1453            ApiError::InvalidBaseUrl { url, message } => {
1454                assert_eq!(url, "inline.test");
1455                assert!(message.contains("relative URL without a base"));
1456            }
1457            other => panic!("expected invalid base URL, got {other:?}"),
1458        }
1459
1460        let err = match ApiClient::try_new("wss://api.inline.chat/v1") {
1461            Ok(_) => panic!("expected invalid base URL"),
1462            Err(err) => err,
1463        };
1464        match err {
1465            ApiError::InvalidBaseUrl { message, .. } => {
1466                assert_eq!(message, "scheme must be http or https");
1467            }
1468            other => panic!("expected invalid base URL, got {other:?}"),
1469        }
1470
1471        let err = match ApiClient::try_new("https://user:secret@api.inline.chat/v1") {
1472            Ok(_) => panic!("expected invalid base URL"),
1473            Err(err) => err,
1474        };
1475        match &err {
1476            ApiError::InvalidBaseUrl { message, .. } => {
1477                assert_eq!(message, "credentials are not valid in the API base URL");
1478                assert!(!err.to_string().contains("secret"));
1479            }
1480            other => panic!("expected invalid base URL, got {other:?}"),
1481        }
1482
1483        let err = match ApiClient::try_new("https://api.inline.chat/v1?debug=true") {
1484            Ok(_) => panic!("expected invalid base URL"),
1485            Err(err) => err,
1486        };
1487        match err {
1488            ApiError::InvalidBaseUrl { message, .. } => {
1489                assert_eq!(
1490                    message,
1491                    "query strings and fragments are not valid in the API base URL"
1492                );
1493            }
1494            other => panic!("expected invalid base URL, got {other:?}"),
1495        }
1496    }
1497
1498    #[test]
1499    fn api_debug_output_redacts_unsafe_url_parts() {
1500        let raw_url = "https://user:url-secret@api.inline.chat/v1?token=query-secret#frag";
1501        let builder = ApiClient::builder(raw_url);
1502        let builder_debug = format!("{builder:?}");
1503
1504        assert!(builder_debug.contains("https://api.inline.chat/v1"));
1505        assert!(!builder_debug.contains("url-secret"));
1506        assert!(!builder_debug.contains("query-secret"));
1507
1508        let err = ApiClient::try_new(raw_url).unwrap_err();
1509        let err_debug = format!("{err:?}");
1510
1511        assert!(err_debug.contains("https://api.inline.chat/v1"));
1512        assert!(!err_debug.contains("url-secret"));
1513        assert!(!err_debug.contains("query-secret"));
1514    }
1515
1516    #[test]
1517    fn api_url_path_for_log_omits_origin_query_and_fragment() {
1518        assert_eq!(
1519            api_url_path_for_log("https://api.inline.chat/v1/getMe?token=secret#frag"),
1520            "/v1/getMe"
1521        );
1522    }
1523
1524    #[tokio::test]
1525    async fn send_email_code_posts_json_body_with_auth_metadata() {
1526        let request = capture_json_request(
1527            r#"{"ok":true,"result":{"existingUser":true,"needsInviteCode":false,"challengeToken":"challenge-1"}}"#,
1528            |client| async move {
1529                client
1530                    .send_email_code(
1531                        "amy@example.com",
1532                        &AuthMetadata::new(
1533                            "device-1",
1534                            ClientIdentity::new("bridge-agent", "1.0.0"),
1535                        )
1536                        .with_device_name("umbrel"),
1537                    )
1538                    .await
1539            },
1540        )
1541        .await;
1542
1543        assert_eq!(request.method, "POST");
1544        assert_eq!(request.path, "/v1/sendEmailCode");
1545        assert!(
1546            request
1547                .headers
1548                .get("content-type")
1549                .is_some_and(|header| header.starts_with("application/json"))
1550        );
1551        assert_eq!(request.body.get("email"), Some(&json!("amy@example.com")));
1552        assert_eq!(request.body.get("deviceId"), Some(&json!("device-1")));
1553        assert_eq!(request.body.get("deviceName"), Some(&json!("umbrel")));
1554        assert_eq!(request.body.get("clientType"), Some(&json!("api")));
1555        assert_eq!(request.body.get("clientVersion"), Some(&json!("1.0.0")));
1556    }
1557
1558    #[tokio::test]
1559    async fn read_messages_posts_bearer_json_body() {
1560        let request = capture_json_request(r#"{"ok":true,"result":{}}"#, |client| async move {
1561            client
1562                .read_messages(
1563                    "secret-token",
1564                    ReadMessagesInput::new(PeerId::user(42)).with_max_id(99),
1565                )
1566                .await
1567        })
1568        .await;
1569
1570        assert_eq!(request.method, "POST");
1571        assert_eq!(request.path, "/v1/readMessages");
1572        assert_eq!(
1573            request.headers.get("authorization").map(String::as_str),
1574            Some("Bearer secret-token")
1575        );
1576        assert!(
1577            request
1578                .headers
1579                .get("content-type")
1580                .is_some_and(|header| header.starts_with("application/json"))
1581        );
1582        assert_eq!(request.body.get("peerUserId"), Some(&json!(42)));
1583        assert_eq!(request.body.get("maxId"), Some(&json!(99)));
1584        assert!(request.body.get("peerThreadId").is_none());
1585    }
1586
1587    #[tokio::test]
1588    async fn logout_posts_empty_bearer_request() {
1589        let request = capture_json_request(r#"{"ok":true,"result":null}"#, |client| async move {
1590            client.logout("secret-token").await
1591        })
1592        .await;
1593
1594        assert_eq!(request.method, "POST");
1595        assert_eq!(request.path, "/v1/logout");
1596        assert_eq!(
1597            request.headers.get("authorization").map(String::as_str),
1598            Some("Bearer secret-token")
1599        );
1600        assert_eq!(request.body, json!({}));
1601    }
1602
1603    #[test]
1604    fn upload_file_type_parses_and_displays_wire_values() {
1605        assert_eq!(
1606            "photo".parse::<UploadFileType>().unwrap(),
1607            UploadFileType::Photo
1608        );
1609        assert_eq!(
1610            "VIDEO".parse::<UploadFileType>().unwrap(),
1611            UploadFileType::Video
1612        );
1613        assert_eq!(UploadFileType::Document.to_string(), "document");
1614
1615        let err = "avatar".parse::<UploadFileType>().unwrap_err();
1616        assert_eq!(err.value, "avatar");
1617    }
1618
1619    #[test]
1620    fn upload_file_type_serializes_as_wire_values() {
1621        assert_eq!(
1622            serde_json::to_string(&UploadFileType::Photo).unwrap(),
1623            r#""photo""#
1624        );
1625        assert_eq!(
1626            serde_json::from_str::<UploadFileType>(r#""video""#).unwrap(),
1627            UploadFileType::Video
1628        );
1629    }
1630
1631    #[test]
1632    fn upload_input_constructors_set_expected_fields() {
1633        let metadata = UploadVideoMetadata::new(1920, 1080, 12);
1634        let video =
1635            UploadFileInput::video("clip.mp4", "clip.mp4", metadata).with_mime_type(" video/mp4 ");
1636
1637        assert_eq!(video.path, PathBuf::from("clip.mp4"));
1638        assert_eq!(video.file_name, "clip.mp4");
1639        assert_eq!(video.file_type, UploadFileType::Video);
1640        assert_eq!(video.mime_type.as_deref(), Some("video/mp4"));
1641        assert_eq!(video.video_metadata, Some(metadata));
1642
1643        let document = UploadFileInput::document("notes.txt", "notes.txt").with_mime_type(" ");
1644        assert_eq!(document.file_type, UploadFileType::Document);
1645        assert!(document.mime_type.is_none());
1646        assert!(document.video_metadata.is_none());
1647    }
1648
1649    #[test]
1650    fn upload_bytes_input_constructors_set_expected_fields() {
1651        let metadata = UploadVideoMetadata::new(1920, 1080, 12);
1652        let video = UploadFileBytesInput::video(vec![1, 2, 3], "clip.mp4", metadata)
1653            .with_mime_type(" video/mp4 ");
1654
1655        assert_eq!(video.bytes, vec![1, 2, 3]);
1656        assert_eq!(video.file_name, "clip.mp4");
1657        assert_eq!(video.file_type, UploadFileType::Video);
1658        assert_eq!(video.mime_type.as_deref(), Some("video/mp4"));
1659        assert_eq!(video.video_metadata, Some(metadata));
1660
1661        let document = UploadFileBytesInput::document(vec![1], "notes.txt").with_mime_type(" ");
1662        assert_eq!(document.file_type, UploadFileType::Document);
1663        assert!(document.mime_type.is_none());
1664        assert!(document.video_metadata.is_none());
1665    }
1666
1667    #[test]
1668    fn upload_input_debug_redacts_local_path() {
1669        let input = UploadFileInput::document("/home/alice/private/report.pdf", "report.pdf")
1670            .with_mime_type("application/pdf");
1671        let debug = format!("{input:?}");
1672
1673        assert!(debug.contains("report.pdf"));
1674        assert!(debug.contains("<redacted>"));
1675        assert!(!debug.contains("/home/alice/private"));
1676    }
1677
1678    #[test]
1679    fn upload_bytes_input_debug_redacts_contents() {
1680        let input = UploadFileBytesInput::document(vec![115, 101, 99, 114, 101, 116], "report.pdf")
1681            .with_mime_type("application/pdf");
1682        let debug = format!("{input:?}");
1683
1684        assert!(debug.contains("report.pdf"));
1685        assert!(debug.contains("bytes_len"));
1686        assert!(!debug.contains("secret"));
1687    }
1688
1689    #[test]
1690    fn upload_input_validation_rejects_invalid_video_metadata_shape() {
1691        let missing_metadata = UploadFileInput::new("clip.mp4", "clip.mp4", UploadFileType::Video);
1692        match validate_upload_file_input(&missing_metadata).unwrap_err() {
1693            ApiError::InvalidInput { message } => {
1694                assert_eq!(message, "video uploads require video metadata");
1695            }
1696            other => panic!("expected invalid input, got {other:?}"),
1697        }
1698
1699        let document_with_video = UploadFileInput::document("notes.txt", "notes.txt")
1700            .with_video_metadata(UploadVideoMetadata::new(1, 1, 1));
1701        match validate_upload_file_input(&document_with_video).unwrap_err() {
1702            ApiError::InvalidInput { message } => {
1703                assert_eq!(
1704                    message,
1705                    "video metadata can only be used with video uploads"
1706                );
1707            }
1708            other => panic!("expected invalid input, got {other:?}"),
1709        }
1710
1711        let bad_dimensions = UploadFileInput::video(
1712            "clip.mp4",
1713            "clip.mp4",
1714            UploadVideoMetadata::new(0, 1080, 12),
1715        );
1716        match validate_upload_file_input(&bad_dimensions).unwrap_err() {
1717            ApiError::InvalidInput { message } => {
1718                assert_eq!(
1719                    message,
1720                    "video metadata width, height, and duration must be positive"
1721                );
1722            }
1723            other => panic!("expected invalid input, got {other:?}"),
1724        }
1725    }
1726
1727    #[test]
1728    fn upload_input_validation_rejects_empty_file_name() {
1729        let input = UploadFileInput::document("notes.txt", " ");
1730        match validate_upload_file_input(&input).unwrap_err() {
1731            ApiError::InvalidInput { message } => {
1732                assert_eq!(message, "upload file name cannot be empty");
1733            }
1734            other => panic!("expected invalid input, got {other:?}"),
1735        }
1736    }
1737
1738    #[test]
1739    fn upload_bytes_input_validation_rejects_empty_bytes() {
1740        let input = UploadFileBytesInput::document(Vec::new(), "notes.txt");
1741        match validate_upload_file_bytes_input(&input).unwrap_err() {
1742            ApiError::InvalidInput { message } => {
1743                assert_eq!(message, "upload file bytes cannot be empty");
1744            }
1745            other => panic!("expected invalid input, got {other:?}"),
1746        }
1747    }
1748
1749    #[test]
1750    fn auth_and_token_validation_reject_blank_required_fields() {
1751        match validate_required_str("email", "  ").unwrap_err() {
1752            ApiError::InvalidInput { message } => {
1753                assert_eq!(message, "email cannot be empty");
1754            }
1755            other => panic!("expected invalid input, got {other:?}"),
1756        }
1757
1758        match validate_bearer_token("").unwrap_err() {
1759            ApiError::InvalidInput { message } => {
1760                assert_eq!(message, "bearer token cannot be empty");
1761            }
1762            other => panic!("expected invalid input, got {other:?}"),
1763        }
1764
1765        match validate_auth_metadata(&AuthMetadata::sdk(" ")).unwrap_err() {
1766            ApiError::InvalidInput { message } => {
1767                assert_eq!(message, "device id cannot be empty");
1768            }
1769            other => panic!("expected invalid input, got {other:?}"),
1770        }
1771    }
1772
1773    #[test]
1774    fn auth_result_debug_redacts_tokens() {
1775        let send_code = SendCodeResult {
1776            existing_user: true,
1777            needs_invite_code: false,
1778            challenge_token: Some("challenge-secret".to_string()),
1779        };
1780        let verify_code = VerifyCodeResult {
1781            user_id: 42,
1782            token: "bearer-secret".to_string(),
1783        };
1784
1785        let send_debug = format!("{send_code:?}");
1786        let verify_debug = format!("{verify_code:?}");
1787
1788        assert!(send_debug.contains("<redacted>"));
1789        assert!(!send_debug.contains("challenge-secret"));
1790        assert!(verify_debug.contains("<redacted>"));
1791        assert!(!verify_debug.contains("bearer-secret"));
1792    }
1793
1794    #[test]
1795    fn api_input_validation_rejects_non_positive_ids() {
1796        match validate_peer_id(PeerId::thread(0)).unwrap_err() {
1797            ApiError::InvalidInput { message } => {
1798                assert_eq!(message, "peer thread id must be positive");
1799            }
1800            other => panic!("expected invalid input, got {other:?}"),
1801        }
1802
1803        let linear = CreateLinearIssueInput::new("ship it", 0, 20, 30, PeerId::thread(20));
1804        match validate_create_linear_issue_input(&linear).unwrap_err() {
1805            ApiError::InvalidInput { message } => {
1806                assert_eq!(message, "message id must be positive");
1807            }
1808            other => panic!("expected invalid input, got {other:?}"),
1809        }
1810
1811        let notion = CreateNotionTaskInput::new(1, 2, 0, PeerId::thread(3));
1812        match validate_create_notion_task_input(&notion).unwrap_err() {
1813            ApiError::InvalidInput { message } => {
1814                assert_eq!(message, "chat id must be positive");
1815            }
1816            other => panic!("expected invalid input, got {other:?}"),
1817        }
1818    }
1819
1820    #[test]
1821    fn peer_id_helpers_encode_user_and_thread_peers() {
1822        assert_eq!(PeerId::user(42).user_id(), Some(42));
1823        assert_eq!(PeerId::user(42).thread_id(), None);
1824        assert_eq!(PeerId::thread(99).thread_id(), Some(99));
1825        assert_eq!(PeerId::thread(99).user_id(), None);
1826
1827        let mut payload = serde_json::Map::new();
1828        add_peer_selector_fields(&mut payload, PeerId::user(42));
1829        assert_eq!(payload.get("peerUserId"), Some(&json!(42)));
1830        assert!(payload.get("peerThreadId").is_none());
1831
1832        let peer_id = peer_id_object(PeerId::thread(99));
1833        assert_eq!(peer_id.get("threadId"), Some(&json!(99)));
1834        assert!(peer_id.get("userId").is_none());
1835    }
1836
1837    #[test]
1838    fn api_input_constructors_keep_required_fields_explicit() {
1839        let read = ReadMessagesInput::new(PeerId::user(42)).with_max_id(99);
1840        assert_eq!(read.peer, PeerId::user(42));
1841        assert_eq!(read.max_id, Some(99));
1842
1843        let linear = CreateLinearIssueInput::new("ship it", 10, 20, 30, PeerId::thread(20))
1844            .with_space_id(40);
1845        assert_eq!(linear.text, "ship it");
1846        assert_eq!(linear.message_id, 10);
1847        assert_eq!(linear.chat_id, 20);
1848        assert_eq!(linear.from_id, 30);
1849        assert_eq!(linear.peer, PeerId::thread(20));
1850        assert_eq!(linear.space_id, Some(40));
1851
1852        let notion = CreateNotionTaskInput::new(1, 2, 3, PeerId::thread(3));
1853        assert_eq!(notion.space_id, 1);
1854        assert_eq!(notion.message_id, 2);
1855        assert_eq!(notion.chat_id, 3);
1856        assert_eq!(notion.peer, PeerId::thread(3));
1857    }
1858
1859    #[test]
1860    fn decodes_success_envelope() {
1861        let result: TestResult =
1862            decode_api_response_text(StatusCode::OK, r#"{"ok":true,"result":{"value":"done"}}"#)
1863                .unwrap();
1864
1865        assert_eq!(
1866            result,
1867            TestResult {
1868                value: "done".to_string()
1869            }
1870        );
1871    }
1872
1873    #[test]
1874    fn preserves_json_api_error_fields() {
1875        let err = decode_api_response_text::<TestResult>(
1876            StatusCode::BAD_REQUEST,
1877            r#"{"ok":false,"error":"INVALID_CODE","error_code":123,"description":"Code is invalid"}"#,
1878        )
1879        .unwrap_err();
1880
1881        match err {
1882            ApiError::Api {
1883                status,
1884                error,
1885                error_code,
1886                description,
1887            } => {
1888                assert_eq!(status, Some(400));
1889                assert_eq!(error, "INVALID_CODE");
1890                assert_eq!(error_code, Some(123));
1891                assert_eq!(description, "Code is invalid");
1892            }
1893            other => panic!("expected api error, got {other:?}"),
1894        }
1895    }
1896
1897    #[test]
1898    fn preserves_nonstandard_ok_false_message() {
1899        let err = decode_api_response_text::<TestResult>(
1900            StatusCode::OK,
1901            r#"{"ok":false,"message":"Not enough permissions"}"#,
1902        )
1903        .unwrap_err();
1904
1905        match err {
1906            ApiError::Api {
1907                status,
1908                error,
1909                error_code,
1910                description,
1911            } => {
1912                assert_eq!(status, Some(200));
1913                assert_eq!(error, "API_ERROR");
1914                assert_eq!(error_code, None);
1915                assert_eq!(description, "Not enough permissions");
1916            }
1917            other => panic!("expected api error, got {other:?}"),
1918        }
1919    }
1920
1921    #[test]
1922    fn preserves_non_json_http_body_preview() {
1923        let err = decode_api_response_text::<TestResult>(
1924            StatusCode::INTERNAL_SERVER_ERROR,
1925            "upstream failed\nwith details",
1926        )
1927        .unwrap_err();
1928
1929        match err {
1930            ApiError::Status {
1931                status,
1932                message,
1933                body,
1934            } => {
1935                assert_eq!(status, 500);
1936                assert_eq!(message, "Internal Server Error");
1937                assert_eq!(body.as_deref(), Some("upstream failed with details"));
1938            }
1939            other => panic!("expected status error, got {other:?}"),
1940        }
1941    }
1942
1943    #[test]
1944    fn auth_metadata_includes_client_identity() {
1945        let mut payload = serde_json::Map::new();
1946        let identity = ClientIdentity::new("cli", "1.2.3");
1947        add_auth_metadata(
1948            &mut payload,
1949            &AuthMetadata::new("device-1", identity).with_device_name("mo-mac"),
1950        );
1951
1952        assert_eq!(payload.get("deviceId"), Some(&json!("device-1")));
1953        assert_eq!(payload.get("clientType"), Some(&json!("cli")));
1954        assert_eq!(payload.get("clientVersion"), Some(&json!("1.2.3")));
1955        assert_eq!(payload.get("deviceName"), Some(&json!("mo-mac")));
1956    }
1957
1958    #[test]
1959    fn auth_metadata_maps_non_session_client_identity_to_api() {
1960        let mut payload = serde_json::Map::new();
1961        add_auth_metadata(
1962            &mut payload,
1963            &AuthMetadata::new("device-1", ClientIdentity::new("my-agent", "0.1.0")),
1964        );
1965
1966        assert_eq!(payload.get("deviceId"), Some(&json!("device-1")));
1967        assert_eq!(payload.get("clientType"), Some(&json!("api")));
1968        assert_eq!(payload.get("clientVersion"), Some(&json!("0.1.0")));
1969        assert!(payload.get("deviceName").is_none());
1970    }
1971
1972    #[test]
1973    fn auth_metadata_preserves_known_session_client_identity() {
1974        let mut payload = serde_json::Map::new();
1975        add_auth_metadata(
1976            &mut payload,
1977            &AuthMetadata::new("device-1", ClientIdentity::new("web", "0.1.0")),
1978        );
1979
1980        assert_eq!(payload.get("clientType"), Some(&json!("web")));
1981    }
1982
1983    async fn capture_json_request<F, Fut, T>(
1984        response_body: &'static str,
1985        exercise: F,
1986    ) -> CapturedRequest
1987    where
1988        F: FnOnce(ApiClient) -> Fut,
1989        Fut: Future<Output = Result<T, ApiError>>,
1990    {
1991        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
1992        let addr = listener.local_addr().unwrap();
1993        let server = tokio::spawn(async move {
1994            let (mut stream, _) = listener.accept().await.unwrap();
1995            let mut request = Vec::new();
1996            loop {
1997                let mut chunk = [0_u8; 1024];
1998                let read = stream.read(&mut chunk).await.unwrap();
1999                assert!(read != 0, "client closed before completing request");
2000                request.extend_from_slice(&chunk[..read]);
2001                if let Some(header_end) = http_header_end(&request) {
2002                    let header_text = String::from_utf8_lossy(&request[..header_end]);
2003                    let content_length = http_content_length(&header_text);
2004                    if request.len() >= header_end + content_length {
2005                        break;
2006                    }
2007                }
2008            }
2009
2010            let captured = parse_captured_request(&request);
2011            let response = format!(
2012                "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
2013                response_body.len(),
2014                response_body
2015            );
2016            stream.write_all(response.as_bytes()).await.unwrap();
2017            captured
2018        });
2019
2020        let client = ApiClient::try_new(format!("http://{addr}/v1")).unwrap();
2021        exercise(client).await.unwrap();
2022        server.await.unwrap()
2023    }
2024
2025    fn http_header_end(request: &[u8]) -> Option<usize> {
2026        request
2027            .windows(4)
2028            .position(|window| window == b"\r\n\r\n")
2029            .map(|position| position + 4)
2030    }
2031
2032    fn http_content_length(headers: &str) -> usize {
2033        headers
2034            .lines()
2035            .find_map(|line| {
2036                let (name, value) = line.split_once(':')?;
2037                name.eq_ignore_ascii_case("content-length")
2038                    .then(|| value.trim().parse::<usize>().unwrap())
2039            })
2040            .unwrap_or(0)
2041    }
2042
2043    fn parse_captured_request(request: &[u8]) -> CapturedRequest {
2044        let header_end = http_header_end(request).unwrap();
2045        let headers = String::from_utf8_lossy(&request[..header_end]);
2046        let mut lines = headers.lines();
2047        let request_line = lines.next().unwrap();
2048        let mut request_parts = request_line.split_whitespace();
2049        let method = request_parts.next().unwrap().to_owned();
2050        let path = request_parts.next().unwrap().to_owned();
2051        let headers = lines
2052            .filter_map(|line| {
2053                let (name, value) = line.split_once(':')?;
2054                Some((name.to_ascii_lowercase(), value.trim().to_owned()))
2055            })
2056            .collect::<BTreeMap<_, _>>();
2057        let body = serde_json::from_slice(&request[header_end..]).unwrap();
2058
2059        CapturedRequest {
2060            method,
2061            path,
2062            headers,
2063            body,
2064        }
2065    }
2066}