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    /// Creates a Linear issue from an Inline message.
455    pub async fn create_linear_issue(
456        &self,
457        token: &str,
458        input: CreateLinearIssueInput,
459    ) -> Result<CreateLinearIssueResult, ApiError> {
460        validate_bearer_token(token)?;
461        validate_create_linear_issue_input(&input)?;
462        let url = format!("{}/createLinearIssue", self.base_url);
463        let mut payload = serde_json::Map::new();
464        payload.insert("text".to_string(), json!(input.text));
465        payload.insert("messageId".to_string(), json!(input.message_id));
466        payload.insert("chatId".to_string(), json!(input.chat_id));
467        payload.insert("fromId".to_string(), json!(input.from_id));
468
469        payload.insert("peerId".to_string(), json!(peer_id_object(input.peer)));
470
471        if let Some(space_id) = input.space_id {
472            payload.insert("spaceId".to_string(), json!(space_id));
473        }
474
475        self.post_with_token(url, token, payload).await
476    }
477
478    /// Creates a Notion task from an Inline message.
479    pub async fn create_notion_task(
480        &self,
481        token: &str,
482        input: CreateNotionTaskInput,
483    ) -> Result<CreateNotionTaskResult, ApiError> {
484        validate_bearer_token(token)?;
485        validate_create_notion_task_input(&input)?;
486        let url = format!("{}/createNotionTask", self.base_url);
487        let mut payload = serde_json::Map::new();
488        payload.insert("spaceId".to_string(), json!(input.space_id));
489        payload.insert("messageId".to_string(), json!(input.message_id));
490        payload.insert("chatId".to_string(), json!(input.chat_id));
491
492        payload.insert("peerId".to_string(), json!(peer_id_object(input.peer)));
493
494        self.post_with_token(url, token, payload).await
495    }
496
497    async fn post<T: for<'de> Deserialize<'de>>(
498        &self,
499        url: String,
500        payload: serde_json::Map<String, serde_json::Value>,
501    ) -> Result<T, ApiError> {
502        log::trace!(
503            target: "inline_sdk::api",
504            "POST {}",
505            api_url_path_for_log(&url)
506        );
507        let response = self.http.post(url).json(&payload).send().await?;
508        log::trace!(
509            target: "inline_sdk::api",
510            "API response status={}",
511            response.status()
512        );
513        decode_api_response(response).await
514    }
515
516    async fn post_with_token<T: for<'de> Deserialize<'de>>(
517        &self,
518        url: String,
519        token: &str,
520        payload: serde_json::Map<String, serde_json::Value>,
521    ) -> Result<T, ApiError> {
522        log::trace!(
523            target: "inline_sdk::api",
524            "POST {} with bearer auth",
525            api_url_path_for_log(&url)
526        );
527        let response = self
528            .http
529            .post(url)
530            .bearer_auth(token)
531            .json(&payload)
532            .send()
533            .await?;
534        log::trace!(
535            target: "inline_sdk::api",
536            "API response status={}",
537            response.status()
538        );
539        decode_api_response(response).await
540    }
541}
542
543/// Response from sending an auth code.
544#[derive(Clone, Deserialize, Serialize, PartialEq, Eq)]
545#[serde(rename_all = "camelCase")]
546pub struct SendCodeResult {
547    /// Whether the contact belongs to an existing user.
548    pub existing_user: bool,
549    /// Whether the login flow requires an invite code.
550    pub needs_invite_code: bool,
551    /// Challenge token required by some email verification flows.
552    pub challenge_token: Option<String>,
553}
554
555impl fmt::Debug for SendCodeResult {
556    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
557        f.debug_struct("SendCodeResult")
558            .field("existing_user", &self.existing_user)
559            .field("needs_invite_code", &self.needs_invite_code)
560            .field(
561                "challenge_token",
562                &self.challenge_token.as_ref().map(|_| "<redacted>"),
563            )
564            .finish()
565    }
566}
567
568/// Successful auth verification response.
569#[derive(Clone, Deserialize, Serialize, PartialEq, Eq)]
570#[serde(rename_all = "camelCase")]
571pub struct VerifyCodeResult {
572    /// Inline user id.
573    pub user_id: i64,
574    /// Bearer token for authenticated API and realtime calls.
575    pub token: String,
576}
577
578impl fmt::Debug for VerifyCodeResult {
579    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
580        f.debug_struct("VerifyCodeResult")
581            .field("user_id", &self.user_id)
582            .field("token", &"<redacted>")
583            .finish()
584    }
585}
586
587/// Upload type accepted by the Inline upload endpoint.
588#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, Hash)]
589#[non_exhaustive]
590#[serde(rename_all = "lowercase")]
591pub enum UploadFileType {
592    /// Photo/image upload.
593    Photo,
594    /// Video upload.
595    Video,
596    /// Generic document upload.
597    Document,
598}
599
600impl UploadFileType {
601    /// Returns the wire-format upload type string.
602    pub fn as_str(&self) -> &'static str {
603        match self {
604            UploadFileType::Photo => "photo",
605            UploadFileType::Video => "video",
606            UploadFileType::Document => "document",
607        }
608    }
609}
610
611impl fmt::Display for UploadFileType {
612    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
613        f.write_str(self.as_str())
614    }
615}
616
617impl FromStr for UploadFileType {
618    type Err = UploadFileTypeParseError;
619
620    fn from_str(value: &str) -> Result<Self, Self::Err> {
621        match value.trim().to_ascii_lowercase().as_str() {
622            "photo" => Ok(UploadFileType::Photo),
623            "video" => Ok(UploadFileType::Video),
624            "document" => Ok(UploadFileType::Document),
625            _ => Err(UploadFileTypeParseError {
626                value: value.to_string(),
627            }),
628        }
629    }
630}
631
632/// Error returned when parsing an [`UploadFileType`] from a string.
633#[derive(Debug, Clone, Error, PartialEq, Eq)]
634#[error("unknown upload file type `{value}`")]
635pub struct UploadFileTypeParseError {
636    /// Original value that could not be parsed.
637    pub value: String,
638}
639
640/// Video metadata sent with video uploads.
641#[must_use]
642#[derive(Debug, Clone, Copy, PartialEq, Eq)]
643pub struct UploadVideoMetadata {
644    /// Video width in pixels.
645    pub width: i32,
646    /// Video height in pixels.
647    pub height: i32,
648    /// Video duration in seconds.
649    pub duration: i32,
650}
651
652impl UploadVideoMetadata {
653    /// Creates video metadata for a video upload.
654    pub fn new(width: i32, height: i32, duration: i32) -> Self {
655        Self {
656            width,
657            height,
658            duration,
659        }
660    }
661}
662
663/// Local file upload input.
664#[must_use]
665#[derive(Clone, PartialEq, Eq)]
666pub struct UploadFileInput {
667    /// Local path to read and upload.
668    pub path: PathBuf,
669    /// File name reported to the server.
670    pub file_name: String,
671    /// Optional MIME type override.
672    pub mime_type: Option<String>,
673    /// Inline upload category.
674    pub file_type: UploadFileType,
675    /// Required video details when uploading a video.
676    pub video_metadata: Option<UploadVideoMetadata>,
677}
678
679impl fmt::Debug for UploadFileInput {
680    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
681        f.debug_struct("UploadFileInput")
682            .field("path", &"<redacted>")
683            .field("file_name", &self.file_name)
684            .field("mime_type", &self.mime_type)
685            .field("file_type", &self.file_type)
686            .field("video_metadata", &self.video_metadata)
687            .finish()
688    }
689}
690
691impl UploadFileInput {
692    /// Creates an upload input with an explicit upload type.
693    pub fn new(
694        path: impl Into<PathBuf>,
695        file_name: impl Into<String>,
696        file_type: UploadFileType,
697    ) -> Self {
698        Self {
699            path: path.into(),
700            file_name: file_name.into(),
701            mime_type: None,
702            file_type,
703            video_metadata: None,
704        }
705    }
706
707    /// Creates a photo upload input.
708    pub fn photo(path: impl Into<PathBuf>, file_name: impl Into<String>) -> Self {
709        Self::new(path, file_name, UploadFileType::Photo)
710    }
711
712    /// Creates a video upload input with video metadata.
713    pub fn video(
714        path: impl Into<PathBuf>,
715        file_name: impl Into<String>,
716        metadata: UploadVideoMetadata,
717    ) -> Self {
718        Self::new(path, file_name, UploadFileType::Video).with_video_metadata(metadata)
719    }
720
721    /// Creates a generic document upload input.
722    pub fn document(path: impl Into<PathBuf>, file_name: impl Into<String>) -> Self {
723        Self::new(path, file_name, UploadFileType::Document)
724    }
725
726    /// Sets a MIME type override.
727    pub fn with_mime_type(mut self, mime_type: impl Into<String>) -> Self {
728        let mime_type = mime_type.into().trim().to_string();
729        if !mime_type.is_empty() {
730            self.mime_type = Some(mime_type);
731        }
732        self
733    }
734
735    /// Sets video metadata for a video upload.
736    pub fn with_video_metadata(mut self, metadata: UploadVideoMetadata) -> Self {
737        self.video_metadata = Some(metadata);
738        self
739    }
740}
741
742/// In-memory file upload input.
743#[must_use]
744#[derive(Clone, PartialEq, Eq)]
745pub struct UploadFileBytesInput {
746    /// File bytes to upload.
747    pub bytes: Vec<u8>,
748    /// File name reported to the server.
749    pub file_name: String,
750    /// Optional MIME type override.
751    pub mime_type: Option<String>,
752    /// Inline upload category.
753    pub file_type: UploadFileType,
754    /// Required video details when uploading a video.
755    pub video_metadata: Option<UploadVideoMetadata>,
756}
757
758impl fmt::Debug for UploadFileBytesInput {
759    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
760        f.debug_struct("UploadFileBytesInput")
761            .field("bytes_len", &self.bytes.len())
762            .field("file_name", &self.file_name)
763            .field("mime_type", &self.mime_type)
764            .field("file_type", &self.file_type)
765            .field("video_metadata", &self.video_metadata)
766            .finish()
767    }
768}
769
770impl UploadFileBytesInput {
771    /// Creates an upload input with an explicit upload type.
772    pub fn new(
773        bytes: impl Into<Vec<u8>>,
774        file_name: impl Into<String>,
775        file_type: UploadFileType,
776    ) -> Self {
777        Self {
778            bytes: bytes.into(),
779            file_name: file_name.into(),
780            mime_type: None,
781            file_type,
782            video_metadata: None,
783        }
784    }
785
786    /// Creates a photo upload input.
787    pub fn photo(bytes: impl Into<Vec<u8>>, file_name: impl Into<String>) -> Self {
788        Self::new(bytes, file_name, UploadFileType::Photo)
789    }
790
791    /// Creates a video upload input with video metadata.
792    pub fn video(
793        bytes: impl Into<Vec<u8>>,
794        file_name: impl Into<String>,
795        metadata: UploadVideoMetadata,
796    ) -> Self {
797        Self::new(bytes, file_name, UploadFileType::Video).with_video_metadata(metadata)
798    }
799
800    /// Creates a generic document upload input.
801    pub fn document(bytes: impl Into<Vec<u8>>, file_name: impl Into<String>) -> Self {
802        Self::new(bytes, file_name, UploadFileType::Document)
803    }
804
805    /// Sets a MIME type override.
806    pub fn with_mime_type(mut self, mime_type: impl Into<String>) -> Self {
807        let mime_type = mime_type.into().trim().to_string();
808        if !mime_type.is_empty() {
809            self.mime_type = Some(mime_type);
810        }
811        self
812    }
813
814    /// Sets video metadata for a video upload.
815    pub fn with_video_metadata(mut self, metadata: UploadVideoMetadata) -> Self {
816        self.video_metadata = Some(metadata);
817        self
818    }
819}
820
821/// File upload response.
822#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)]
823#[serde(rename_all = "camelCase")]
824pub struct UploadFileResult {
825    /// Stable unique file id.
826    pub file_unique_id: String,
827    /// Uploaded photo id when the file was a photo.
828    pub photo_id: Option<i64>,
829    /// Uploaded video id when the file was a video.
830    pub video_id: Option<i64>,
831    /// Uploaded document id when the file was a document.
832    pub document_id: Option<i64>,
833}
834
835/// Input for marking messages read.
836#[must_use]
837#[derive(Debug, Clone, Copy, PartialEq, Eq)]
838pub struct ReadMessagesInput {
839    /// Peer to mark read.
840    pub peer: PeerId,
841    /// Highest message id to mark as read.
842    pub max_id: Option<i64>,
843}
844
845impl ReadMessagesInput {
846    /// Creates a read-marker request for a peer.
847    pub fn new(peer: PeerId) -> Self {
848        Self { peer, max_id: None }
849    }
850
851    /// Sets the highest message id to mark as read.
852    pub fn with_max_id(mut self, max_id: i64) -> Self {
853        self.max_id = Some(max_id);
854        self
855    }
856}
857
858/// Empty response for marking messages read.
859#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)]
860#[serde(rename_all = "camelCase")]
861pub struct ReadMessagesResult {}
862
863/// Peer identifier used by HTTP API helpers.
864#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
865#[non_exhaustive]
866pub enum PeerId {
867    /// Direct-message peer by user id.
868    User(i64),
869    /// Chat/thread peer by thread id.
870    Thread(i64),
871}
872
873impl PeerId {
874    /// Creates a direct-message peer id.
875    pub fn user(user_id: i64) -> Self {
876        Self::User(user_id)
877    }
878
879    /// Creates a chat/thread peer id.
880    pub fn thread(thread_id: i64) -> Self {
881        Self::Thread(thread_id)
882    }
883
884    /// Returns the user id when this is a user peer.
885    pub fn user_id(self) -> Option<i64> {
886        match self {
887            Self::User(user_id) => Some(user_id),
888            Self::Thread(_) => None,
889        }
890    }
891
892    /// Returns the thread id when this is a thread peer.
893    pub fn thread_id(self) -> Option<i64> {
894        match self {
895            Self::User(_) => None,
896            Self::Thread(thread_id) => Some(thread_id),
897        }
898    }
899}
900
901/// Response from creating or opening a private chat.
902#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
903#[serde(rename_all = "camelCase")]
904pub struct CreatePrivateChatResult {
905    /// Raw chat object returned by the server.
906    pub chat: Value,
907    /// Raw dialog object returned by the server.
908    pub dialog: Value,
909    /// Raw user object returned by the server.
910    pub user: Value,
911}
912
913/// Input for creating a Linear issue from a message.
914#[must_use]
915#[derive(Debug, Clone, PartialEq, Eq)]
916pub struct CreateLinearIssueInput {
917    /// Source message text.
918    pub text: String,
919    /// Source message id.
920    pub message_id: i64,
921    /// Source chat id.
922    pub chat_id: i64,
923    /// Source sender user id.
924    pub from_id: i64,
925    /// Source peer.
926    pub peer: PeerId,
927    /// Optional source space id.
928    pub space_id: Option<i64>,
929}
930
931impl CreateLinearIssueInput {
932    /// Creates input for creating a Linear issue from a message.
933    pub fn new(
934        text: impl Into<String>,
935        message_id: i64,
936        chat_id: i64,
937        from_id: i64,
938        peer: PeerId,
939    ) -> Self {
940        Self {
941            text: text.into(),
942            message_id,
943            chat_id,
944            from_id,
945            peer,
946            space_id: None,
947        }
948    }
949
950    /// Sets the optional source space id.
951    pub fn with_space_id(mut self, space_id: i64) -> Self {
952        self.space_id = Some(space_id);
953        self
954    }
955}
956
957/// Response from creating a Linear issue.
958#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)]
959#[serde(rename_all = "camelCase")]
960pub struct CreateLinearIssueResult {
961    /// Created Linear issue URL, if the integration returned one.
962    pub link: Option<String>,
963}
964
965/// Input for creating a Notion task from a message.
966#[must_use]
967#[derive(Debug, Clone, PartialEq, Eq)]
968pub struct CreateNotionTaskInput {
969    /// Source space id.
970    pub space_id: i64,
971    /// Source message id.
972    pub message_id: i64,
973    /// Source chat id.
974    pub chat_id: i64,
975    /// Source peer.
976    pub peer: PeerId,
977}
978
979impl CreateNotionTaskInput {
980    /// Creates input for creating a Notion task from a message.
981    pub fn new(space_id: i64, message_id: i64, chat_id: i64, peer: PeerId) -> Self {
982        Self {
983            space_id,
984            message_id,
985            chat_id,
986            peer,
987        }
988    }
989}
990
991/// Response from creating a Notion task.
992#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)]
993#[serde(rename_all = "camelCase")]
994pub struct CreateNotionTaskResult {
995    /// Created Notion task URL.
996    pub url: String,
997    /// Created task title, when returned by the integration.
998    pub task_title: Option<String>,
999}
1000
1001#[derive(Debug, Deserialize)]
1002#[serde(untagged, rename_all = "camelCase")]
1003enum ApiResponse<T> {
1004    Ok {
1005        #[serde(rename = "ok")]
1006        _ok: bool,
1007        result: T,
1008    },
1009    Err {
1010        #[serde(rename = "ok")]
1011        _ok: bool,
1012        error: String,
1013        #[serde(rename = "errorCode", alias = "error_code")]
1014        _error_code: Option<i32>,
1015        description: Option<String>,
1016    },
1017}
1018
1019fn normalize_api_base_url(base_url: impl Into<String>) -> Result<String, ApiError> {
1020    let original = base_url.into();
1021    let normalized = original.trim().trim_end_matches('/').to_string();
1022    if normalized.is_empty() {
1023        return Err(ApiError::InvalidBaseUrl {
1024            url: original,
1025            message: "base URL cannot be empty".to_string(),
1026        });
1027    }
1028
1029    let parsed = Url::parse(&normalized).map_err(|err| ApiError::InvalidBaseUrl {
1030        url: normalized.clone(),
1031        message: err.to_string(),
1032    })?;
1033
1034    if !matches!(parsed.scheme(), "http" | "https") {
1035        return Err(ApiError::InvalidBaseUrl {
1036            url: normalized,
1037            message: "scheme must be http or https".to_string(),
1038        });
1039    }
1040
1041    if parsed.host_str().is_none() {
1042        return Err(ApiError::InvalidBaseUrl {
1043            url: normalized,
1044            message: "host is required".to_string(),
1045        });
1046    }
1047
1048    if !parsed.username().is_empty() || parsed.password().is_some() {
1049        return Err(ApiError::InvalidBaseUrl {
1050            url: normalized,
1051            message: "credentials are not valid in the API base URL".to_string(),
1052        });
1053    }
1054
1055    if parsed.query().is_some() || parsed.fragment().is_some() {
1056        return Err(ApiError::InvalidBaseUrl {
1057            url: normalized,
1058            message: "query strings and fragments are not valid in the API base URL".to_string(),
1059        });
1060    }
1061
1062    Ok(normalized)
1063}
1064
1065fn api_url_path_for_log(url: &str) -> String {
1066    Url::parse(url)
1067        .map(|url| url.path().to_string())
1068        .unwrap_or_else(|_| "<invalid-url>".to_string())
1069}
1070
1071fn api_base_url_for_debug(raw_url: &str) -> String {
1072    Url::parse(raw_url.trim())
1073        .map(|url| {
1074            let host = url.host_str().unwrap_or("<missing-host>");
1075            let port = url
1076                .port()
1077                .map(|port| format!(":{port}"))
1078                .unwrap_or_default();
1079            let path = url.path().trim_end_matches('/');
1080            format!("{}://{}{}{}", url.scheme(), host, port, path)
1081        })
1082        .unwrap_or_else(|_| "<invalid>".to_string())
1083}
1084
1085async fn decode_api_response<T: for<'de> Deserialize<'de>>(
1086    response: reqwest::Response,
1087) -> Result<T, ApiError> {
1088    let status = response.status();
1089    let text = response.text().await?;
1090    decode_api_response_text(status, &text)
1091}
1092
1093fn decode_api_response_text<T: for<'de> Deserialize<'de>>(
1094    status: StatusCode,
1095    text: &str,
1096) -> Result<T, ApiError> {
1097    let value: Value = serde_json::from_str(text).map_err(|err| {
1098        if status.is_success() {
1099            ApiError::Json(err)
1100        } else {
1101            ApiError::Status {
1102                status: status.as_u16(),
1103                message: status
1104                    .canonical_reason()
1105                    .unwrap_or("HTTP error")
1106                    .to_string(),
1107                body: body_preview(text),
1108            }
1109        }
1110    })?;
1111
1112    if !status.is_success() {
1113        return Err(
1114            api_error_from_value(status, &value).unwrap_or_else(|| ApiError::Status {
1115                status: status.as_u16(),
1116                message: status
1117                    .canonical_reason()
1118                    .unwrap_or("HTTP error")
1119                    .to_string(),
1120                body: body_preview(text),
1121            }),
1122        );
1123    }
1124
1125    if value.get("ok").and_then(Value::as_bool) == Some(false) {
1126        return Err(
1127            api_error_from_value(status, &value).unwrap_or_else(|| ApiError::Api {
1128                status: None,
1129                error: "API_ERROR".to_string(),
1130                error_code: None,
1131                description: "The server returned ok=false without an error description."
1132                    .to_string(),
1133            }),
1134        );
1135    }
1136
1137    let api_response: ApiResponse<T> = serde_json::from_value(value)?;
1138    match api_response {
1139        ApiResponse::Ok { result, .. } => Ok(result),
1140        ApiResponse::Err {
1141            error,
1142            _error_code,
1143            description,
1144            ..
1145        } => Err(ApiError::Api {
1146            status: None,
1147            error,
1148            error_code: _error_code,
1149            description: description.unwrap_or_else(|| "Unknown error".to_string()),
1150        }),
1151    }
1152}
1153
1154fn api_error_from_value(status: StatusCode, value: &Value) -> Option<ApiError> {
1155    let object = value.as_object()?;
1156    let error = string_field(value, "error")
1157        .or_else(|| string_field(value, "code"))
1158        .or_else(|| string_field(value, "name"))
1159        .unwrap_or_else(|| {
1160            if value.get("ok").and_then(Value::as_bool) == Some(false) && status.is_success() {
1161                return "API_ERROR".to_string();
1162            }
1163            status
1164                .canonical_reason()
1165                .unwrap_or("HTTP error")
1166                .to_string()
1167        });
1168    let description = string_field(value, "description")
1169        .or_else(|| string_field(value, "message"))
1170        .or_else(|| string_field(value, "detail"))
1171        .unwrap_or_else(|| "No server error description was provided.".to_string());
1172    let error_code = object
1173        .get("error_code")
1174        .or_else(|| object.get("errorCode"))
1175        .and_then(|value| value.as_i64())
1176        .and_then(|value| i32::try_from(value).ok());
1177
1178    Some(ApiError::Api {
1179        status: Some(status.as_u16()),
1180        error,
1181        error_code,
1182        description,
1183    })
1184}
1185
1186fn string_field(value: &Value, key: &str) -> Option<String> {
1187    value
1188        .get(key)
1189        .and_then(Value::as_str)
1190        .map(str::trim)
1191        .filter(|value| !value.is_empty())
1192        .map(ToString::to_string)
1193}
1194
1195fn body_preview(text: &str) -> Option<String> {
1196    let normalized = text.split_whitespace().collect::<Vec<_>>().join(" ");
1197    if normalized.is_empty() {
1198        return None;
1199    }
1200    const MAX_BODY_PREVIEW_BYTES: usize = 500;
1201    if normalized.len() <= MAX_BODY_PREVIEW_BYTES {
1202        return Some(normalized);
1203    }
1204
1205    let mut end = MAX_BODY_PREVIEW_BYTES;
1206    while !normalized.is_char_boundary(end) {
1207        end -= 1;
1208    }
1209    Some(format!("{}...", &normalized[..end]))
1210}
1211
1212fn validate_required_str(field: &'static str, value: &str) -> Result<(), ApiError> {
1213    if value.trim().is_empty() {
1214        return Err(ApiError::InvalidInput {
1215            message: format!("{field} cannot be empty"),
1216        });
1217    }
1218    Ok(())
1219}
1220
1221fn validate_bearer_token(token: &str) -> Result<(), ApiError> {
1222    validate_required_str("bearer token", token)
1223}
1224
1225fn validate_auth_metadata(metadata: &AuthMetadata) -> Result<(), ApiError> {
1226    validate_required_str("device id", metadata.device_id())
1227}
1228
1229fn validate_upload_file_input(input: &UploadFileInput) -> Result<(), ApiError> {
1230    validate_upload_file_metadata(&input.file_name, input.file_type, input.video_metadata)
1231}
1232
1233fn validate_upload_file_bytes_input(input: &UploadFileBytesInput) -> Result<(), ApiError> {
1234    validate_upload_file_metadata(&input.file_name, input.file_type, input.video_metadata)?;
1235    if input.bytes.is_empty() {
1236        return Err(ApiError::InvalidInput {
1237            message: "upload file bytes cannot be empty".to_string(),
1238        });
1239    }
1240    Ok(())
1241}
1242
1243fn validate_upload_file_metadata(
1244    file_name: &str,
1245    file_type: UploadFileType,
1246    video_metadata: Option<UploadVideoMetadata>,
1247) -> Result<(), ApiError> {
1248    if file_name.trim().is_empty() {
1249        return Err(ApiError::InvalidInput {
1250            message: "upload file name cannot be empty".to_string(),
1251        });
1252    }
1253
1254    match (file_type, video_metadata) {
1255        (UploadFileType::Video, Some(metadata)) => validate_upload_video_metadata(metadata),
1256        (UploadFileType::Video, None) => Err(ApiError::InvalidInput {
1257            message: "video uploads require video metadata".to_string(),
1258        }),
1259        (_, Some(_)) => Err(ApiError::InvalidInput {
1260            message: "video metadata can only be used with video uploads".to_string(),
1261        }),
1262        (_, None) => Ok(()),
1263    }
1264}
1265
1266fn validate_upload_video_metadata(metadata: UploadVideoMetadata) -> Result<(), ApiError> {
1267    if metadata.width <= 0 || metadata.height <= 0 || metadata.duration <= 0 {
1268        return Err(ApiError::InvalidInput {
1269            message: "video metadata width, height, and duration must be positive".to_string(),
1270        });
1271    }
1272    Ok(())
1273}
1274
1275fn validate_positive_id(field: &'static str, value: i64) -> Result<(), ApiError> {
1276    if value <= 0 {
1277        return Err(ApiError::InvalidInput {
1278            message: format!("{field} must be positive"),
1279        });
1280    }
1281    Ok(())
1282}
1283
1284fn validate_peer_id(peer: PeerId) -> Result<(), ApiError> {
1285    match peer {
1286        PeerId::User(user_id) => validate_positive_id("peer user id", user_id),
1287        PeerId::Thread(thread_id) => validate_positive_id("peer thread id", thread_id),
1288    }
1289}
1290
1291fn validate_create_linear_issue_input(input: &CreateLinearIssueInput) -> Result<(), ApiError> {
1292    validate_required_str("Linear issue text", &input.text)?;
1293    validate_positive_id("message id", input.message_id)?;
1294    validate_positive_id("chat id", input.chat_id)?;
1295    validate_positive_id("sender user id", input.from_id)?;
1296    validate_peer_id(input.peer)?;
1297    if let Some(space_id) = input.space_id {
1298        validate_positive_id("space id", space_id)?;
1299    }
1300    Ok(())
1301}
1302
1303fn validate_create_notion_task_input(input: &CreateNotionTaskInput) -> Result<(), ApiError> {
1304    validate_positive_id("space id", input.space_id)?;
1305    validate_positive_id("message id", input.message_id)?;
1306    validate_positive_id("chat id", input.chat_id)?;
1307    validate_peer_id(input.peer)
1308}
1309
1310fn add_auth_metadata(
1311    payload: &mut serde_json::Map<String, serde_json::Value>,
1312    metadata: &AuthMetadata,
1313) {
1314    payload.insert("deviceId".to_string(), json!(metadata.device_id()));
1315    payload.insert(
1316        "clientType".to_string(),
1317        json!(auth_session_client_type(metadata.client())),
1318    );
1319    payload.insert(
1320        "clientVersion".to_string(),
1321        json!(metadata.client().client_version()),
1322    );
1323    if let Some(device_name) = metadata.device_name() {
1324        payload.insert("deviceName".to_string(), json!(device_name));
1325    }
1326}
1327
1328fn auth_session_client_type(identity: &ClientIdentity) -> &str {
1329    match identity.client_type() {
1330        // Keep this to server session client types. Custom SDK,
1331        // bridge, and agent identities still travel in headers/user agents.
1332        "ios" | "macos" | "web" | "api" | "android" | "windows" | "linux" | "cli" => {
1333            identity.client_type()
1334        }
1335        _ => DEFAULT_AUTH_SESSION_CLIENT_TYPE,
1336    }
1337}
1338
1339fn add_peer_selector_fields(
1340    payload: &mut serde_json::Map<String, serde_json::Value>,
1341    peer: PeerId,
1342) {
1343    match peer {
1344        PeerId::User(user_id) => {
1345            payload.insert("peerUserId".to_string(), json!(user_id));
1346        }
1347        PeerId::Thread(thread_id) => {
1348            payload.insert("peerThreadId".to_string(), json!(thread_id));
1349        }
1350    }
1351}
1352
1353fn peer_id_object(peer: PeerId) -> serde_json::Map<String, serde_json::Value> {
1354    let mut peer_id = serde_json::Map::new();
1355    match peer {
1356        PeerId::User(user_id) => {
1357            peer_id.insert("userId".to_string(), json!(user_id));
1358        }
1359        PeerId::Thread(thread_id) => {
1360            peer_id.insert("threadId".to_string(), json!(thread_id));
1361        }
1362    }
1363    peer_id
1364}
1365
1366#[cfg(test)]
1367mod tests {
1368    use super::*;
1369    use std::collections::BTreeMap;
1370    use std::future::Future;
1371    use tokio::io::{AsyncReadExt, AsyncWriteExt};
1372    use tokio::net::TcpListener;
1373
1374    #[derive(Debug, Deserialize, PartialEq)]
1375    #[serde(rename_all = "camelCase")]
1376    struct TestResult {
1377        value: String,
1378    }
1379
1380    #[derive(Debug)]
1381    struct CapturedRequest {
1382        method: String,
1383        path: String,
1384        headers: BTreeMap<String, String>,
1385        body: Value,
1386    }
1387
1388    #[test]
1389    fn api_client_builder_normalizes_base_url() {
1390        let client = ApiClient::try_new(" https://api.inline.chat/v1/ ").unwrap();
1391        assert_eq!(client.base_url(), "https://api.inline.chat/v1");
1392    }
1393
1394    #[test]
1395    fn api_client_builder_uses_default_request_timeout() {
1396        let client = ApiClient::try_new("https://api.inline.chat/v1").unwrap();
1397        assert_eq!(client.request_timeout(), Some(DEFAULT_API_TIMEOUT));
1398    }
1399
1400    #[test]
1401    fn api_client_builder_can_override_or_disable_request_timeout() {
1402        let client = ApiClient::builder("https://api.inline.chat/v1")
1403            .request_timeout(Duration::from_secs(5))
1404            .build()
1405            .unwrap();
1406        assert_eq!(client.request_timeout(), Some(Duration::from_secs(5)));
1407
1408        let client = ApiClient::builder("https://api.inline.chat/v1")
1409            .without_request_timeout()
1410            .build()
1411            .unwrap();
1412        assert_eq!(client.request_timeout(), None);
1413    }
1414
1415    #[test]
1416    fn custom_http_clients_own_their_timeout_policy() {
1417        let http = reqwest::Client::builder()
1418            .timeout(Duration::from_secs(10))
1419            .build()
1420            .unwrap();
1421        let client = ApiClient::try_with_http_client("https://api.inline.chat/v1", http).unwrap();
1422        assert_eq!(client.request_timeout(), None);
1423
1424        let http = reqwest::Client::builder()
1425            .timeout(Duration::from_secs(10))
1426            .build()
1427            .unwrap();
1428        let client = ApiClient::builder("https://api.inline.chat/v1")
1429            .request_timeout(Duration::from_secs(5))
1430            .http_client(http)
1431            .build()
1432            .unwrap();
1433        assert_eq!(client.request_timeout(), None);
1434    }
1435
1436    #[test]
1437    fn api_client_builder_rejects_invalid_base_url() {
1438        let err = match ApiClient::try_new("inline.test") {
1439            Ok(_) => panic!("expected invalid base URL"),
1440            Err(err) => err,
1441        };
1442        match err {
1443            ApiError::InvalidBaseUrl { url, message } => {
1444                assert_eq!(url, "inline.test");
1445                assert!(message.contains("relative URL without a base"));
1446            }
1447            other => panic!("expected invalid base URL, got {other:?}"),
1448        }
1449
1450        let err = match ApiClient::try_new("wss://api.inline.chat/v1") {
1451            Ok(_) => panic!("expected invalid base URL"),
1452            Err(err) => err,
1453        };
1454        match err {
1455            ApiError::InvalidBaseUrl { message, .. } => {
1456                assert_eq!(message, "scheme must be http or https");
1457            }
1458            other => panic!("expected invalid base URL, got {other:?}"),
1459        }
1460
1461        let err = match ApiClient::try_new("https://user:secret@api.inline.chat/v1") {
1462            Ok(_) => panic!("expected invalid base URL"),
1463            Err(err) => err,
1464        };
1465        match &err {
1466            ApiError::InvalidBaseUrl { message, .. } => {
1467                assert_eq!(message, "credentials are not valid in the API base URL");
1468                assert!(!err.to_string().contains("secret"));
1469            }
1470            other => panic!("expected invalid base URL, got {other:?}"),
1471        }
1472
1473        let err = match ApiClient::try_new("https://api.inline.chat/v1?debug=true") {
1474            Ok(_) => panic!("expected invalid base URL"),
1475            Err(err) => err,
1476        };
1477        match err {
1478            ApiError::InvalidBaseUrl { message, .. } => {
1479                assert_eq!(
1480                    message,
1481                    "query strings and fragments are not valid in the API base URL"
1482                );
1483            }
1484            other => panic!("expected invalid base URL, got {other:?}"),
1485        }
1486    }
1487
1488    #[test]
1489    fn api_debug_output_redacts_unsafe_url_parts() {
1490        let raw_url = "https://user:url-secret@api.inline.chat/v1?token=query-secret#frag";
1491        let builder = ApiClient::builder(raw_url);
1492        let builder_debug = format!("{builder:?}");
1493
1494        assert!(builder_debug.contains("https://api.inline.chat/v1"));
1495        assert!(!builder_debug.contains("url-secret"));
1496        assert!(!builder_debug.contains("query-secret"));
1497
1498        let err = ApiClient::try_new(raw_url).unwrap_err();
1499        let err_debug = format!("{err:?}");
1500
1501        assert!(err_debug.contains("https://api.inline.chat/v1"));
1502        assert!(!err_debug.contains("url-secret"));
1503        assert!(!err_debug.contains("query-secret"));
1504    }
1505
1506    #[test]
1507    fn api_url_path_for_log_omits_origin_query_and_fragment() {
1508        assert_eq!(
1509            api_url_path_for_log("https://api.inline.chat/v1/getMe?token=secret#frag"),
1510            "/v1/getMe"
1511        );
1512    }
1513
1514    #[tokio::test]
1515    async fn send_email_code_posts_json_body_with_auth_metadata() {
1516        let request = capture_json_request(
1517            r#"{"ok":true,"result":{"existingUser":true,"needsInviteCode":false,"challengeToken":"challenge-1"}}"#,
1518            |client| async move {
1519                client
1520                    .send_email_code(
1521                        "amy@example.com",
1522                        &AuthMetadata::new(
1523                            "device-1",
1524                            ClientIdentity::new("bridge-agent", "1.0.0"),
1525                        )
1526                        .with_device_name("umbrel"),
1527                    )
1528                    .await
1529            },
1530        )
1531        .await;
1532
1533        assert_eq!(request.method, "POST");
1534        assert_eq!(request.path, "/v1/sendEmailCode");
1535        assert!(
1536            request
1537                .headers
1538                .get("content-type")
1539                .is_some_and(|header| header.starts_with("application/json"))
1540        );
1541        assert_eq!(request.body.get("email"), Some(&json!("amy@example.com")));
1542        assert_eq!(request.body.get("deviceId"), Some(&json!("device-1")));
1543        assert_eq!(request.body.get("deviceName"), Some(&json!("umbrel")));
1544        assert_eq!(request.body.get("clientType"), Some(&json!("api")));
1545        assert_eq!(request.body.get("clientVersion"), Some(&json!("1.0.0")));
1546    }
1547
1548    #[tokio::test]
1549    async fn read_messages_posts_bearer_json_body() {
1550        let request = capture_json_request(r#"{"ok":true,"result":{}}"#, |client| async move {
1551            client
1552                .read_messages(
1553                    "secret-token",
1554                    ReadMessagesInput::new(PeerId::user(42)).with_max_id(99),
1555                )
1556                .await
1557        })
1558        .await;
1559
1560        assert_eq!(request.method, "POST");
1561        assert_eq!(request.path, "/v1/readMessages");
1562        assert_eq!(
1563            request.headers.get("authorization").map(String::as_str),
1564            Some("Bearer secret-token")
1565        );
1566        assert!(
1567            request
1568                .headers
1569                .get("content-type")
1570                .is_some_and(|header| header.starts_with("application/json"))
1571        );
1572        assert_eq!(request.body.get("peerUserId"), Some(&json!(42)));
1573        assert_eq!(request.body.get("maxId"), Some(&json!(99)));
1574        assert!(request.body.get("peerThreadId").is_none());
1575    }
1576
1577    #[test]
1578    fn upload_file_type_parses_and_displays_wire_values() {
1579        assert_eq!(
1580            "photo".parse::<UploadFileType>().unwrap(),
1581            UploadFileType::Photo
1582        );
1583        assert_eq!(
1584            "VIDEO".parse::<UploadFileType>().unwrap(),
1585            UploadFileType::Video
1586        );
1587        assert_eq!(UploadFileType::Document.to_string(), "document");
1588
1589        let err = "avatar".parse::<UploadFileType>().unwrap_err();
1590        assert_eq!(err.value, "avatar");
1591    }
1592
1593    #[test]
1594    fn upload_file_type_serializes_as_wire_values() {
1595        assert_eq!(
1596            serde_json::to_string(&UploadFileType::Photo).unwrap(),
1597            r#""photo""#
1598        );
1599        assert_eq!(
1600            serde_json::from_str::<UploadFileType>(r#""video""#).unwrap(),
1601            UploadFileType::Video
1602        );
1603    }
1604
1605    #[test]
1606    fn upload_input_constructors_set_expected_fields() {
1607        let metadata = UploadVideoMetadata::new(1920, 1080, 12);
1608        let video =
1609            UploadFileInput::video("clip.mp4", "clip.mp4", metadata).with_mime_type(" video/mp4 ");
1610
1611        assert_eq!(video.path, PathBuf::from("clip.mp4"));
1612        assert_eq!(video.file_name, "clip.mp4");
1613        assert_eq!(video.file_type, UploadFileType::Video);
1614        assert_eq!(video.mime_type.as_deref(), Some("video/mp4"));
1615        assert_eq!(video.video_metadata, Some(metadata));
1616
1617        let document = UploadFileInput::document("notes.txt", "notes.txt").with_mime_type(" ");
1618        assert_eq!(document.file_type, UploadFileType::Document);
1619        assert!(document.mime_type.is_none());
1620        assert!(document.video_metadata.is_none());
1621    }
1622
1623    #[test]
1624    fn upload_bytes_input_constructors_set_expected_fields() {
1625        let metadata = UploadVideoMetadata::new(1920, 1080, 12);
1626        let video = UploadFileBytesInput::video(vec![1, 2, 3], "clip.mp4", metadata)
1627            .with_mime_type(" video/mp4 ");
1628
1629        assert_eq!(video.bytes, vec![1, 2, 3]);
1630        assert_eq!(video.file_name, "clip.mp4");
1631        assert_eq!(video.file_type, UploadFileType::Video);
1632        assert_eq!(video.mime_type.as_deref(), Some("video/mp4"));
1633        assert_eq!(video.video_metadata, Some(metadata));
1634
1635        let document = UploadFileBytesInput::document(vec![1], "notes.txt").with_mime_type(" ");
1636        assert_eq!(document.file_type, UploadFileType::Document);
1637        assert!(document.mime_type.is_none());
1638        assert!(document.video_metadata.is_none());
1639    }
1640
1641    #[test]
1642    fn upload_input_debug_redacts_local_path() {
1643        let input = UploadFileInput::document("/home/alice/private/report.pdf", "report.pdf")
1644            .with_mime_type("application/pdf");
1645        let debug = format!("{input:?}");
1646
1647        assert!(debug.contains("report.pdf"));
1648        assert!(debug.contains("<redacted>"));
1649        assert!(!debug.contains("/home/alice/private"));
1650    }
1651
1652    #[test]
1653    fn upload_bytes_input_debug_redacts_contents() {
1654        let input = UploadFileBytesInput::document(vec![115, 101, 99, 114, 101, 116], "report.pdf")
1655            .with_mime_type("application/pdf");
1656        let debug = format!("{input:?}");
1657
1658        assert!(debug.contains("report.pdf"));
1659        assert!(debug.contains("bytes_len"));
1660        assert!(!debug.contains("secret"));
1661    }
1662
1663    #[test]
1664    fn upload_input_validation_rejects_invalid_video_metadata_shape() {
1665        let missing_metadata = UploadFileInput::new("clip.mp4", "clip.mp4", UploadFileType::Video);
1666        match validate_upload_file_input(&missing_metadata).unwrap_err() {
1667            ApiError::InvalidInput { message } => {
1668                assert_eq!(message, "video uploads require video metadata");
1669            }
1670            other => panic!("expected invalid input, got {other:?}"),
1671        }
1672
1673        let document_with_video = UploadFileInput::document("notes.txt", "notes.txt")
1674            .with_video_metadata(UploadVideoMetadata::new(1, 1, 1));
1675        match validate_upload_file_input(&document_with_video).unwrap_err() {
1676            ApiError::InvalidInput { message } => {
1677                assert_eq!(
1678                    message,
1679                    "video metadata can only be used with video uploads"
1680                );
1681            }
1682            other => panic!("expected invalid input, got {other:?}"),
1683        }
1684
1685        let bad_dimensions = UploadFileInput::video(
1686            "clip.mp4",
1687            "clip.mp4",
1688            UploadVideoMetadata::new(0, 1080, 12),
1689        );
1690        match validate_upload_file_input(&bad_dimensions).unwrap_err() {
1691            ApiError::InvalidInput { message } => {
1692                assert_eq!(
1693                    message,
1694                    "video metadata width, height, and duration must be positive"
1695                );
1696            }
1697            other => panic!("expected invalid input, got {other:?}"),
1698        }
1699    }
1700
1701    #[test]
1702    fn upload_input_validation_rejects_empty_file_name() {
1703        let input = UploadFileInput::document("notes.txt", " ");
1704        match validate_upload_file_input(&input).unwrap_err() {
1705            ApiError::InvalidInput { message } => {
1706                assert_eq!(message, "upload file name cannot be empty");
1707            }
1708            other => panic!("expected invalid input, got {other:?}"),
1709        }
1710    }
1711
1712    #[test]
1713    fn upload_bytes_input_validation_rejects_empty_bytes() {
1714        let input = UploadFileBytesInput::document(Vec::new(), "notes.txt");
1715        match validate_upload_file_bytes_input(&input).unwrap_err() {
1716            ApiError::InvalidInput { message } => {
1717                assert_eq!(message, "upload file bytes cannot be empty");
1718            }
1719            other => panic!("expected invalid input, got {other:?}"),
1720        }
1721    }
1722
1723    #[test]
1724    fn auth_and_token_validation_reject_blank_required_fields() {
1725        match validate_required_str("email", "  ").unwrap_err() {
1726            ApiError::InvalidInput { message } => {
1727                assert_eq!(message, "email cannot be empty");
1728            }
1729            other => panic!("expected invalid input, got {other:?}"),
1730        }
1731
1732        match validate_bearer_token("").unwrap_err() {
1733            ApiError::InvalidInput { message } => {
1734                assert_eq!(message, "bearer token cannot be empty");
1735            }
1736            other => panic!("expected invalid input, got {other:?}"),
1737        }
1738
1739        match validate_auth_metadata(&AuthMetadata::sdk(" ")).unwrap_err() {
1740            ApiError::InvalidInput { message } => {
1741                assert_eq!(message, "device id cannot be empty");
1742            }
1743            other => panic!("expected invalid input, got {other:?}"),
1744        }
1745    }
1746
1747    #[test]
1748    fn auth_result_debug_redacts_tokens() {
1749        let send_code = SendCodeResult {
1750            existing_user: true,
1751            needs_invite_code: false,
1752            challenge_token: Some("challenge-secret".to_string()),
1753        };
1754        let verify_code = VerifyCodeResult {
1755            user_id: 42,
1756            token: "bearer-secret".to_string(),
1757        };
1758
1759        let send_debug = format!("{send_code:?}");
1760        let verify_debug = format!("{verify_code:?}");
1761
1762        assert!(send_debug.contains("<redacted>"));
1763        assert!(!send_debug.contains("challenge-secret"));
1764        assert!(verify_debug.contains("<redacted>"));
1765        assert!(!verify_debug.contains("bearer-secret"));
1766    }
1767
1768    #[test]
1769    fn api_input_validation_rejects_non_positive_ids() {
1770        match validate_peer_id(PeerId::thread(0)).unwrap_err() {
1771            ApiError::InvalidInput { message } => {
1772                assert_eq!(message, "peer thread id must be positive");
1773            }
1774            other => panic!("expected invalid input, got {other:?}"),
1775        }
1776
1777        let linear = CreateLinearIssueInput::new("ship it", 0, 20, 30, PeerId::thread(20));
1778        match validate_create_linear_issue_input(&linear).unwrap_err() {
1779            ApiError::InvalidInput { message } => {
1780                assert_eq!(message, "message id must be positive");
1781            }
1782            other => panic!("expected invalid input, got {other:?}"),
1783        }
1784
1785        let notion = CreateNotionTaskInput::new(1, 2, 0, PeerId::thread(3));
1786        match validate_create_notion_task_input(&notion).unwrap_err() {
1787            ApiError::InvalidInput { message } => {
1788                assert_eq!(message, "chat id must be positive");
1789            }
1790            other => panic!("expected invalid input, got {other:?}"),
1791        }
1792    }
1793
1794    #[test]
1795    fn peer_id_helpers_encode_user_and_thread_peers() {
1796        assert_eq!(PeerId::user(42).user_id(), Some(42));
1797        assert_eq!(PeerId::user(42).thread_id(), None);
1798        assert_eq!(PeerId::thread(99).thread_id(), Some(99));
1799        assert_eq!(PeerId::thread(99).user_id(), None);
1800
1801        let mut payload = serde_json::Map::new();
1802        add_peer_selector_fields(&mut payload, PeerId::user(42));
1803        assert_eq!(payload.get("peerUserId"), Some(&json!(42)));
1804        assert!(payload.get("peerThreadId").is_none());
1805
1806        let peer_id = peer_id_object(PeerId::thread(99));
1807        assert_eq!(peer_id.get("threadId"), Some(&json!(99)));
1808        assert!(peer_id.get("userId").is_none());
1809    }
1810
1811    #[test]
1812    fn api_input_constructors_keep_required_fields_explicit() {
1813        let read = ReadMessagesInput::new(PeerId::user(42)).with_max_id(99);
1814        assert_eq!(read.peer, PeerId::user(42));
1815        assert_eq!(read.max_id, Some(99));
1816
1817        let linear = CreateLinearIssueInput::new("ship it", 10, 20, 30, PeerId::thread(20))
1818            .with_space_id(40);
1819        assert_eq!(linear.text, "ship it");
1820        assert_eq!(linear.message_id, 10);
1821        assert_eq!(linear.chat_id, 20);
1822        assert_eq!(linear.from_id, 30);
1823        assert_eq!(linear.peer, PeerId::thread(20));
1824        assert_eq!(linear.space_id, Some(40));
1825
1826        let notion = CreateNotionTaskInput::new(1, 2, 3, PeerId::thread(3));
1827        assert_eq!(notion.space_id, 1);
1828        assert_eq!(notion.message_id, 2);
1829        assert_eq!(notion.chat_id, 3);
1830        assert_eq!(notion.peer, PeerId::thread(3));
1831    }
1832
1833    #[test]
1834    fn decodes_success_envelope() {
1835        let result: TestResult =
1836            decode_api_response_text(StatusCode::OK, r#"{"ok":true,"result":{"value":"done"}}"#)
1837                .unwrap();
1838
1839        assert_eq!(
1840            result,
1841            TestResult {
1842                value: "done".to_string()
1843            }
1844        );
1845    }
1846
1847    #[test]
1848    fn preserves_json_api_error_fields() {
1849        let err = decode_api_response_text::<TestResult>(
1850            StatusCode::BAD_REQUEST,
1851            r#"{"ok":false,"error":"INVALID_CODE","error_code":123,"description":"Code is invalid"}"#,
1852        )
1853        .unwrap_err();
1854
1855        match err {
1856            ApiError::Api {
1857                status,
1858                error,
1859                error_code,
1860                description,
1861            } => {
1862                assert_eq!(status, Some(400));
1863                assert_eq!(error, "INVALID_CODE");
1864                assert_eq!(error_code, Some(123));
1865                assert_eq!(description, "Code is invalid");
1866            }
1867            other => panic!("expected api error, got {other:?}"),
1868        }
1869    }
1870
1871    #[test]
1872    fn preserves_nonstandard_ok_false_message() {
1873        let err = decode_api_response_text::<TestResult>(
1874            StatusCode::OK,
1875            r#"{"ok":false,"message":"Not enough permissions"}"#,
1876        )
1877        .unwrap_err();
1878
1879        match err {
1880            ApiError::Api {
1881                status,
1882                error,
1883                error_code,
1884                description,
1885            } => {
1886                assert_eq!(status, Some(200));
1887                assert_eq!(error, "API_ERROR");
1888                assert_eq!(error_code, None);
1889                assert_eq!(description, "Not enough permissions");
1890            }
1891            other => panic!("expected api error, got {other:?}"),
1892        }
1893    }
1894
1895    #[test]
1896    fn preserves_non_json_http_body_preview() {
1897        let err = decode_api_response_text::<TestResult>(
1898            StatusCode::INTERNAL_SERVER_ERROR,
1899            "upstream failed\nwith details",
1900        )
1901        .unwrap_err();
1902
1903        match err {
1904            ApiError::Status {
1905                status,
1906                message,
1907                body,
1908            } => {
1909                assert_eq!(status, 500);
1910                assert_eq!(message, "Internal Server Error");
1911                assert_eq!(body.as_deref(), Some("upstream failed with details"));
1912            }
1913            other => panic!("expected status error, got {other:?}"),
1914        }
1915    }
1916
1917    #[test]
1918    fn auth_metadata_includes_client_identity() {
1919        let mut payload = serde_json::Map::new();
1920        let identity = ClientIdentity::new("cli", "1.2.3");
1921        add_auth_metadata(
1922            &mut payload,
1923            &AuthMetadata::new("device-1", identity).with_device_name("mo-mac"),
1924        );
1925
1926        assert_eq!(payload.get("deviceId"), Some(&json!("device-1")));
1927        assert_eq!(payload.get("clientType"), Some(&json!("cli")));
1928        assert_eq!(payload.get("clientVersion"), Some(&json!("1.2.3")));
1929        assert_eq!(payload.get("deviceName"), Some(&json!("mo-mac")));
1930    }
1931
1932    #[test]
1933    fn auth_metadata_maps_non_session_client_identity_to_api() {
1934        let mut payload = serde_json::Map::new();
1935        add_auth_metadata(
1936            &mut payload,
1937            &AuthMetadata::new("device-1", ClientIdentity::new("my-agent", "0.1.0")),
1938        );
1939
1940        assert_eq!(payload.get("deviceId"), Some(&json!("device-1")));
1941        assert_eq!(payload.get("clientType"), Some(&json!("api")));
1942        assert_eq!(payload.get("clientVersion"), Some(&json!("0.1.0")));
1943        assert!(payload.get("deviceName").is_none());
1944    }
1945
1946    #[test]
1947    fn auth_metadata_preserves_known_session_client_identity() {
1948        let mut payload = serde_json::Map::new();
1949        add_auth_metadata(
1950            &mut payload,
1951            &AuthMetadata::new("device-1", ClientIdentity::new("web", "0.1.0")),
1952        );
1953
1954        assert_eq!(payload.get("clientType"), Some(&json!("web")));
1955    }
1956
1957    async fn capture_json_request<F, Fut, T>(
1958        response_body: &'static str,
1959        exercise: F,
1960    ) -> CapturedRequest
1961    where
1962        F: FnOnce(ApiClient) -> Fut,
1963        Fut: Future<Output = Result<T, ApiError>>,
1964    {
1965        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
1966        let addr = listener.local_addr().unwrap();
1967        let server = tokio::spawn(async move {
1968            let (mut stream, _) = listener.accept().await.unwrap();
1969            let mut request = Vec::new();
1970            loop {
1971                let mut chunk = [0_u8; 1024];
1972                let read = stream.read(&mut chunk).await.unwrap();
1973                assert!(read != 0, "client closed before completing request");
1974                request.extend_from_slice(&chunk[..read]);
1975                if let Some(header_end) = http_header_end(&request) {
1976                    let header_text = String::from_utf8_lossy(&request[..header_end]);
1977                    let content_length = http_content_length(&header_text);
1978                    if request.len() >= header_end + content_length {
1979                        break;
1980                    }
1981                }
1982            }
1983
1984            let captured = parse_captured_request(&request);
1985            let response = format!(
1986                "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
1987                response_body.len(),
1988                response_body
1989            );
1990            stream.write_all(response.as_bytes()).await.unwrap();
1991            captured
1992        });
1993
1994        let client = ApiClient::try_new(format!("http://{addr}/v1")).unwrap();
1995        exercise(client).await.unwrap();
1996        server.await.unwrap()
1997    }
1998
1999    fn http_header_end(request: &[u8]) -> Option<usize> {
2000        request
2001            .windows(4)
2002            .position(|window| window == b"\r\n\r\n")
2003            .map(|position| position + 4)
2004    }
2005
2006    fn http_content_length(headers: &str) -> usize {
2007        headers
2008            .lines()
2009            .find_map(|line| {
2010                let (name, value) = line.split_once(':')?;
2011                name.eq_ignore_ascii_case("content-length")
2012                    .then(|| value.trim().parse::<usize>().unwrap())
2013            })
2014            .unwrap_or(0)
2015    }
2016
2017    fn parse_captured_request(request: &[u8]) -> CapturedRequest {
2018        let header_end = http_header_end(request).unwrap();
2019        let headers = String::from_utf8_lossy(&request[..header_end]);
2020        let mut lines = headers.lines();
2021        let request_line = lines.next().unwrap();
2022        let mut request_parts = request_line.split_whitespace();
2023        let method = request_parts.next().unwrap().to_owned();
2024        let path = request_parts.next().unwrap().to_owned();
2025        let headers = lines
2026            .filter_map(|line| {
2027                let (name, value) = line.split_once(':')?;
2028                Some((name.to_ascii_lowercase(), value.trim().to_owned()))
2029            })
2030            .collect::<BTreeMap<_, _>>();
2031        let body = serde_json::from_slice(&request[header_end..]).unwrap();
2032
2033        CapturedRequest {
2034            method,
2035            path,
2036            headers,
2037            body,
2038        }
2039    }
2040}