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