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 url = format!("{}/uploadFile", self.base_url);
352        log::debug!(
353            target: "inline_sdk::api",
354            "uploading file type={} has_mime_type={} has_video_metadata={}",
355            input.file_type,
356            input.mime_type.is_some(),
357            input.video_metadata.is_some()
358        );
359        let mut form = reqwest::multipart::Form::new().text("type", input.file_type.as_str());
360        let bytes = fs::read(&input.path)?;
361        let mut file_part = reqwest::multipart::Part::bytes(bytes);
362        file_part = file_part.file_name(input.file_name);
363        if let Some(mime) = input.mime_type {
364            file_part = file_part.mime_str(&mime)?;
365        }
366        form = form.part("file", file_part);
367
368        if let Some(video) = input.video_metadata {
369            form = form
370                .text("width", video.width.to_string())
371                .text("height", video.height.to_string())
372                .text("duration", video.duration.to_string());
373        }
374
375        let response = self
376            .http
377            .post(url)
378            .bearer_auth(token)
379            .multipart(form)
380            .send()
381            .await?;
382        log::trace!(
383            target: "inline_sdk::api",
384            "uploadFile response status={}",
385            response.status()
386        );
387        decode_api_response(response).await
388    }
389
390    /// Marks messages as read for a peer.
391    pub async fn read_messages(
392        &self,
393        token: &str,
394        input: ReadMessagesInput,
395    ) -> Result<ReadMessagesResult, ApiError> {
396        validate_bearer_token(token)?;
397        validate_peer_id(input.peer)?;
398        if let Some(max_id) = input.max_id {
399            validate_positive_id("max_id", max_id)?;
400        }
401        let url = format!("{}/readMessages", self.base_url);
402        let mut payload = serde_json::Map::new();
403        add_peer_selector_fields(&mut payload, input.peer);
404        if let Some(max_id) = input.max_id {
405            payload.insert("maxId".to_string(), json!(max_id));
406        }
407        self.post_with_token(url, token, payload).await
408    }
409
410    /// Creates or opens a private chat with a user.
411    pub async fn create_private_chat(
412        &self,
413        token: &str,
414        user_id: i64,
415    ) -> Result<CreatePrivateChatResult, ApiError> {
416        validate_bearer_token(token)?;
417        validate_positive_id("user_id", user_id)?;
418        let url = format!("{}/createPrivateChat", self.base_url);
419        let mut payload = serde_json::Map::new();
420        payload.insert("userId".to_string(), json!(user_id));
421        self.post_with_token(url, token, payload).await
422    }
423
424    /// Creates a Linear issue from an Inline message.
425    pub async fn create_linear_issue(
426        &self,
427        token: &str,
428        input: CreateLinearIssueInput,
429    ) -> Result<CreateLinearIssueResult, ApiError> {
430        validate_bearer_token(token)?;
431        validate_create_linear_issue_input(&input)?;
432        let url = format!("{}/createLinearIssue", self.base_url);
433        let mut payload = serde_json::Map::new();
434        payload.insert("text".to_string(), json!(input.text));
435        payload.insert("messageId".to_string(), json!(input.message_id));
436        payload.insert("chatId".to_string(), json!(input.chat_id));
437        payload.insert("fromId".to_string(), json!(input.from_id));
438
439        payload.insert("peerId".to_string(), json!(peer_id_object(input.peer)));
440
441        if let Some(space_id) = input.space_id {
442            payload.insert("spaceId".to_string(), json!(space_id));
443        }
444
445        self.post_with_token(url, token, payload).await
446    }
447
448    /// Creates a Notion task from an Inline message.
449    pub async fn create_notion_task(
450        &self,
451        token: &str,
452        input: CreateNotionTaskInput,
453    ) -> Result<CreateNotionTaskResult, ApiError> {
454        validate_bearer_token(token)?;
455        validate_create_notion_task_input(&input)?;
456        let url = format!("{}/createNotionTask", self.base_url);
457        let mut payload = serde_json::Map::new();
458        payload.insert("spaceId".to_string(), json!(input.space_id));
459        payload.insert("messageId".to_string(), json!(input.message_id));
460        payload.insert("chatId".to_string(), json!(input.chat_id));
461
462        payload.insert("peerId".to_string(), json!(peer_id_object(input.peer)));
463
464        self.post_with_token(url, token, payload).await
465    }
466
467    async fn post<T: for<'de> Deserialize<'de>>(
468        &self,
469        url: String,
470        payload: serde_json::Map<String, serde_json::Value>,
471    ) -> Result<T, ApiError> {
472        log::trace!(
473            target: "inline_sdk::api",
474            "POST {}",
475            api_url_path_for_log(&url)
476        );
477        let response = self.http.post(url).json(&payload).send().await?;
478        log::trace!(
479            target: "inline_sdk::api",
480            "API response status={}",
481            response.status()
482        );
483        decode_api_response(response).await
484    }
485
486    async fn post_with_token<T: for<'de> Deserialize<'de>>(
487        &self,
488        url: String,
489        token: &str,
490        payload: serde_json::Map<String, serde_json::Value>,
491    ) -> Result<T, ApiError> {
492        log::trace!(
493            target: "inline_sdk::api",
494            "POST {} with bearer auth",
495            api_url_path_for_log(&url)
496        );
497        let response = self
498            .http
499            .post(url)
500            .bearer_auth(token)
501            .json(&payload)
502            .send()
503            .await?;
504        log::trace!(
505            target: "inline_sdk::api",
506            "API response status={}",
507            response.status()
508        );
509        decode_api_response(response).await
510    }
511}
512
513/// Response from sending an auth code.
514#[derive(Clone, Deserialize, Serialize, PartialEq, Eq)]
515#[serde(rename_all = "camelCase")]
516pub struct SendCodeResult {
517    /// Whether the contact belongs to an existing user.
518    pub existing_user: bool,
519    /// Whether the login flow requires an invite code.
520    pub needs_invite_code: bool,
521    /// Challenge token required by some email verification flows.
522    pub challenge_token: Option<String>,
523}
524
525impl fmt::Debug for SendCodeResult {
526    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
527        f.debug_struct("SendCodeResult")
528            .field("existing_user", &self.existing_user)
529            .field("needs_invite_code", &self.needs_invite_code)
530            .field(
531                "challenge_token",
532                &self.challenge_token.as_ref().map(|_| "<redacted>"),
533            )
534            .finish()
535    }
536}
537
538/// Successful auth verification response.
539#[derive(Clone, Deserialize, Serialize, PartialEq, Eq)]
540#[serde(rename_all = "camelCase")]
541pub struct VerifyCodeResult {
542    /// Inline user id.
543    pub user_id: i64,
544    /// Bearer token for authenticated API and realtime calls.
545    pub token: String,
546}
547
548impl fmt::Debug for VerifyCodeResult {
549    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
550        f.debug_struct("VerifyCodeResult")
551            .field("user_id", &self.user_id)
552            .field("token", &"<redacted>")
553            .finish()
554    }
555}
556
557/// Upload type accepted by the Inline upload endpoint.
558#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, Hash)]
559#[non_exhaustive]
560#[serde(rename_all = "lowercase")]
561pub enum UploadFileType {
562    /// Photo/image upload.
563    Photo,
564    /// Video upload.
565    Video,
566    /// Generic document upload.
567    Document,
568}
569
570impl UploadFileType {
571    /// Returns the wire-format upload type string.
572    pub fn as_str(&self) -> &'static str {
573        match self {
574            UploadFileType::Photo => "photo",
575            UploadFileType::Video => "video",
576            UploadFileType::Document => "document",
577        }
578    }
579}
580
581impl fmt::Display for UploadFileType {
582    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
583        f.write_str(self.as_str())
584    }
585}
586
587impl FromStr for UploadFileType {
588    type Err = UploadFileTypeParseError;
589
590    fn from_str(value: &str) -> Result<Self, Self::Err> {
591        match value.trim().to_ascii_lowercase().as_str() {
592            "photo" => Ok(UploadFileType::Photo),
593            "video" => Ok(UploadFileType::Video),
594            "document" => Ok(UploadFileType::Document),
595            _ => Err(UploadFileTypeParseError {
596                value: value.to_string(),
597            }),
598        }
599    }
600}
601
602/// Error returned when parsing an [`UploadFileType`] from a string.
603#[derive(Debug, Clone, Error, PartialEq, Eq)]
604#[error("unknown upload file type `{value}`")]
605pub struct UploadFileTypeParseError {
606    /// Original value that could not be parsed.
607    pub value: String,
608}
609
610/// Video metadata sent with video uploads.
611#[must_use]
612#[derive(Debug, Clone, Copy, PartialEq, Eq)]
613pub struct UploadVideoMetadata {
614    /// Video width in pixels.
615    pub width: i32,
616    /// Video height in pixels.
617    pub height: i32,
618    /// Video duration in seconds.
619    pub duration: i32,
620}
621
622impl UploadVideoMetadata {
623    /// Creates video metadata for a video upload.
624    pub fn new(width: i32, height: i32, duration: i32) -> Self {
625        Self {
626            width,
627            height,
628            duration,
629        }
630    }
631}
632
633/// Local file upload input.
634#[must_use]
635#[derive(Clone, PartialEq, Eq)]
636pub struct UploadFileInput {
637    /// Local path to read and upload.
638    pub path: PathBuf,
639    /// File name reported to the server.
640    pub file_name: String,
641    /// Optional MIME type override.
642    pub mime_type: Option<String>,
643    /// Inline upload category.
644    pub file_type: UploadFileType,
645    /// Required video details when uploading a video.
646    pub video_metadata: Option<UploadVideoMetadata>,
647}
648
649impl fmt::Debug for UploadFileInput {
650    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
651        f.debug_struct("UploadFileInput")
652            .field("path", &"<redacted>")
653            .field("file_name", &self.file_name)
654            .field("mime_type", &self.mime_type)
655            .field("file_type", &self.file_type)
656            .field("video_metadata", &self.video_metadata)
657            .finish()
658    }
659}
660
661impl UploadFileInput {
662    /// Creates an upload input with an explicit upload type.
663    pub fn new(
664        path: impl Into<PathBuf>,
665        file_name: impl Into<String>,
666        file_type: UploadFileType,
667    ) -> Self {
668        Self {
669            path: path.into(),
670            file_name: file_name.into(),
671            mime_type: None,
672            file_type,
673            video_metadata: None,
674        }
675    }
676
677    /// Creates a photo upload input.
678    pub fn photo(path: impl Into<PathBuf>, file_name: impl Into<String>) -> Self {
679        Self::new(path, file_name, UploadFileType::Photo)
680    }
681
682    /// Creates a video upload input with video metadata.
683    pub fn video(
684        path: impl Into<PathBuf>,
685        file_name: impl Into<String>,
686        metadata: UploadVideoMetadata,
687    ) -> Self {
688        Self::new(path, file_name, UploadFileType::Video).with_video_metadata(metadata)
689    }
690
691    /// Creates a generic document upload input.
692    pub fn document(path: impl Into<PathBuf>, file_name: impl Into<String>) -> Self {
693        Self::new(path, file_name, UploadFileType::Document)
694    }
695
696    /// Sets a MIME type override.
697    pub fn with_mime_type(mut self, mime_type: impl Into<String>) -> Self {
698        let mime_type = mime_type.into().trim().to_string();
699        if !mime_type.is_empty() {
700            self.mime_type = Some(mime_type);
701        }
702        self
703    }
704
705    /// Sets video metadata for a video upload.
706    pub fn with_video_metadata(mut self, metadata: UploadVideoMetadata) -> Self {
707        self.video_metadata = Some(metadata);
708        self
709    }
710}
711
712/// File upload response.
713#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)]
714#[serde(rename_all = "camelCase")]
715pub struct UploadFileResult {
716    /// Stable unique file id.
717    pub file_unique_id: String,
718    /// Uploaded photo id when the file was a photo.
719    pub photo_id: Option<i64>,
720    /// Uploaded video id when the file was a video.
721    pub video_id: Option<i64>,
722    /// Uploaded document id when the file was a document.
723    pub document_id: Option<i64>,
724}
725
726/// Input for marking messages read.
727#[must_use]
728#[derive(Debug, Clone, Copy, PartialEq, Eq)]
729pub struct ReadMessagesInput {
730    /// Peer to mark read.
731    pub peer: PeerId,
732    /// Highest message id to mark as read.
733    pub max_id: Option<i64>,
734}
735
736impl ReadMessagesInput {
737    /// Creates a read-marker request for a peer.
738    pub fn new(peer: PeerId) -> Self {
739        Self { peer, max_id: None }
740    }
741
742    /// Sets the highest message id to mark as read.
743    pub fn with_max_id(mut self, max_id: i64) -> Self {
744        self.max_id = Some(max_id);
745        self
746    }
747}
748
749/// Empty response for marking messages read.
750#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)]
751#[serde(rename_all = "camelCase")]
752pub struct ReadMessagesResult {}
753
754/// Peer identifier used by HTTP API helpers.
755#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
756#[non_exhaustive]
757pub enum PeerId {
758    /// Direct-message peer by user id.
759    User(i64),
760    /// Chat/thread peer by thread id.
761    Thread(i64),
762}
763
764impl PeerId {
765    /// Creates a direct-message peer id.
766    pub fn user(user_id: i64) -> Self {
767        Self::User(user_id)
768    }
769
770    /// Creates a chat/thread peer id.
771    pub fn thread(thread_id: i64) -> Self {
772        Self::Thread(thread_id)
773    }
774
775    /// Returns the user id when this is a user peer.
776    pub fn user_id(self) -> Option<i64> {
777        match self {
778            Self::User(user_id) => Some(user_id),
779            Self::Thread(_) => None,
780        }
781    }
782
783    /// Returns the thread id when this is a thread peer.
784    pub fn thread_id(self) -> Option<i64> {
785        match self {
786            Self::User(_) => None,
787            Self::Thread(thread_id) => Some(thread_id),
788        }
789    }
790}
791
792/// Response from creating or opening a private chat.
793#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
794#[serde(rename_all = "camelCase")]
795pub struct CreatePrivateChatResult {
796    /// Raw chat object returned by the server.
797    pub chat: Value,
798    /// Raw dialog object returned by the server.
799    pub dialog: Value,
800    /// Raw user object returned by the server.
801    pub user: Value,
802}
803
804/// Input for creating a Linear issue from a message.
805#[must_use]
806#[derive(Debug, Clone, PartialEq, Eq)]
807pub struct CreateLinearIssueInput {
808    /// Source message text.
809    pub text: String,
810    /// Source message id.
811    pub message_id: i64,
812    /// Source chat id.
813    pub chat_id: i64,
814    /// Source sender user id.
815    pub from_id: i64,
816    /// Source peer.
817    pub peer: PeerId,
818    /// Optional source space id.
819    pub space_id: Option<i64>,
820}
821
822impl CreateLinearIssueInput {
823    /// Creates input for creating a Linear issue from a message.
824    pub fn new(
825        text: impl Into<String>,
826        message_id: i64,
827        chat_id: i64,
828        from_id: i64,
829        peer: PeerId,
830    ) -> Self {
831        Self {
832            text: text.into(),
833            message_id,
834            chat_id,
835            from_id,
836            peer,
837            space_id: None,
838        }
839    }
840
841    /// Sets the optional source space id.
842    pub fn with_space_id(mut self, space_id: i64) -> Self {
843        self.space_id = Some(space_id);
844        self
845    }
846}
847
848/// Response from creating a Linear issue.
849#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)]
850#[serde(rename_all = "camelCase")]
851pub struct CreateLinearIssueResult {
852    /// Created Linear issue URL, if the integration returned one.
853    pub link: Option<String>,
854}
855
856/// Input for creating a Notion task from a message.
857#[must_use]
858#[derive(Debug, Clone, PartialEq, Eq)]
859pub struct CreateNotionTaskInput {
860    /// Source space id.
861    pub space_id: i64,
862    /// Source message id.
863    pub message_id: i64,
864    /// Source chat id.
865    pub chat_id: i64,
866    /// Source peer.
867    pub peer: PeerId,
868}
869
870impl CreateNotionTaskInput {
871    /// Creates input for creating a Notion task from a message.
872    pub fn new(space_id: i64, message_id: i64, chat_id: i64, peer: PeerId) -> Self {
873        Self {
874            space_id,
875            message_id,
876            chat_id,
877            peer,
878        }
879    }
880}
881
882/// Response from creating a Notion task.
883#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)]
884#[serde(rename_all = "camelCase")]
885pub struct CreateNotionTaskResult {
886    /// Created Notion task URL.
887    pub url: String,
888    /// Created task title, when returned by the integration.
889    pub task_title: Option<String>,
890}
891
892#[derive(Debug, Deserialize)]
893#[serde(untagged, rename_all = "camelCase")]
894enum ApiResponse<T> {
895    Ok {
896        #[serde(rename = "ok")]
897        _ok: bool,
898        result: T,
899    },
900    Err {
901        #[serde(rename = "ok")]
902        _ok: bool,
903        error: String,
904        #[serde(rename = "errorCode", alias = "error_code")]
905        _error_code: Option<i32>,
906        description: Option<String>,
907    },
908}
909
910fn normalize_api_base_url(base_url: impl Into<String>) -> Result<String, ApiError> {
911    let original = base_url.into();
912    let normalized = original.trim().trim_end_matches('/').to_string();
913    if normalized.is_empty() {
914        return Err(ApiError::InvalidBaseUrl {
915            url: original,
916            message: "base URL cannot be empty".to_string(),
917        });
918    }
919
920    let parsed = Url::parse(&normalized).map_err(|err| ApiError::InvalidBaseUrl {
921        url: normalized.clone(),
922        message: err.to_string(),
923    })?;
924
925    if !matches!(parsed.scheme(), "http" | "https") {
926        return Err(ApiError::InvalidBaseUrl {
927            url: normalized,
928            message: "scheme must be http or https".to_string(),
929        });
930    }
931
932    if parsed.host_str().is_none() {
933        return Err(ApiError::InvalidBaseUrl {
934            url: normalized,
935            message: "host is required".to_string(),
936        });
937    }
938
939    if !parsed.username().is_empty() || parsed.password().is_some() {
940        return Err(ApiError::InvalidBaseUrl {
941            url: normalized,
942            message: "credentials are not valid in the API base URL".to_string(),
943        });
944    }
945
946    if parsed.query().is_some() || parsed.fragment().is_some() {
947        return Err(ApiError::InvalidBaseUrl {
948            url: normalized,
949            message: "query strings and fragments are not valid in the API base URL".to_string(),
950        });
951    }
952
953    Ok(normalized)
954}
955
956fn api_url_path_for_log(url: &str) -> String {
957    Url::parse(url)
958        .map(|url| url.path().to_string())
959        .unwrap_or_else(|_| "<invalid-url>".to_string())
960}
961
962fn api_base_url_for_debug(raw_url: &str) -> String {
963    Url::parse(raw_url.trim())
964        .map(|url| {
965            let host = url.host_str().unwrap_or("<missing-host>");
966            let port = url
967                .port()
968                .map(|port| format!(":{port}"))
969                .unwrap_or_default();
970            let path = url.path().trim_end_matches('/');
971            format!("{}://{}{}{}", url.scheme(), host, port, path)
972        })
973        .unwrap_or_else(|_| "<invalid>".to_string())
974}
975
976async fn decode_api_response<T: for<'de> Deserialize<'de>>(
977    response: reqwest::Response,
978) -> Result<T, ApiError> {
979    let status = response.status();
980    let text = response.text().await?;
981    decode_api_response_text(status, &text)
982}
983
984fn decode_api_response_text<T: for<'de> Deserialize<'de>>(
985    status: StatusCode,
986    text: &str,
987) -> Result<T, ApiError> {
988    let value: Value = serde_json::from_str(text).map_err(|err| {
989        if status.is_success() {
990            ApiError::Json(err)
991        } else {
992            ApiError::Status {
993                status: status.as_u16(),
994                message: status
995                    .canonical_reason()
996                    .unwrap_or("HTTP error")
997                    .to_string(),
998                body: body_preview(text),
999            }
1000        }
1001    })?;
1002
1003    if !status.is_success() {
1004        return Err(
1005            api_error_from_value(status, &value).unwrap_or_else(|| ApiError::Status {
1006                status: status.as_u16(),
1007                message: status
1008                    .canonical_reason()
1009                    .unwrap_or("HTTP error")
1010                    .to_string(),
1011                body: body_preview(text),
1012            }),
1013        );
1014    }
1015
1016    if value.get("ok").and_then(Value::as_bool) == Some(false) {
1017        return Err(
1018            api_error_from_value(status, &value).unwrap_or_else(|| ApiError::Api {
1019                status: None,
1020                error: "API_ERROR".to_string(),
1021                error_code: None,
1022                description: "The server returned ok=false without an error description."
1023                    .to_string(),
1024            }),
1025        );
1026    }
1027
1028    let api_response: ApiResponse<T> = serde_json::from_value(value)?;
1029    match api_response {
1030        ApiResponse::Ok { result, .. } => Ok(result),
1031        ApiResponse::Err {
1032            error,
1033            _error_code,
1034            description,
1035            ..
1036        } => Err(ApiError::Api {
1037            status: None,
1038            error,
1039            error_code: _error_code,
1040            description: description.unwrap_or_else(|| "Unknown error".to_string()),
1041        }),
1042    }
1043}
1044
1045fn api_error_from_value(status: StatusCode, value: &Value) -> Option<ApiError> {
1046    let object = value.as_object()?;
1047    let error = string_field(value, "error")
1048        .or_else(|| string_field(value, "code"))
1049        .or_else(|| string_field(value, "name"))
1050        .unwrap_or_else(|| {
1051            if value.get("ok").and_then(Value::as_bool) == Some(false) && status.is_success() {
1052                return "API_ERROR".to_string();
1053            }
1054            status
1055                .canonical_reason()
1056                .unwrap_or("HTTP error")
1057                .to_string()
1058        });
1059    let description = string_field(value, "description")
1060        .or_else(|| string_field(value, "message"))
1061        .or_else(|| string_field(value, "detail"))
1062        .unwrap_or_else(|| "No server error description was provided.".to_string());
1063    let error_code = object
1064        .get("error_code")
1065        .or_else(|| object.get("errorCode"))
1066        .and_then(|value| value.as_i64())
1067        .and_then(|value| i32::try_from(value).ok());
1068
1069    Some(ApiError::Api {
1070        status: Some(status.as_u16()),
1071        error,
1072        error_code,
1073        description,
1074    })
1075}
1076
1077fn string_field(value: &Value, key: &str) -> Option<String> {
1078    value
1079        .get(key)
1080        .and_then(Value::as_str)
1081        .map(str::trim)
1082        .filter(|value| !value.is_empty())
1083        .map(ToString::to_string)
1084}
1085
1086fn body_preview(text: &str) -> Option<String> {
1087    let normalized = text.split_whitespace().collect::<Vec<_>>().join(" ");
1088    if normalized.is_empty() {
1089        return None;
1090    }
1091    const MAX_BODY_PREVIEW_BYTES: usize = 500;
1092    if normalized.len() <= MAX_BODY_PREVIEW_BYTES {
1093        return Some(normalized);
1094    }
1095
1096    let mut end = MAX_BODY_PREVIEW_BYTES;
1097    while !normalized.is_char_boundary(end) {
1098        end -= 1;
1099    }
1100    Some(format!("{}...", &normalized[..end]))
1101}
1102
1103fn validate_required_str(field: &'static str, value: &str) -> Result<(), ApiError> {
1104    if value.trim().is_empty() {
1105        return Err(ApiError::InvalidInput {
1106            message: format!("{field} cannot be empty"),
1107        });
1108    }
1109    Ok(())
1110}
1111
1112fn validate_bearer_token(token: &str) -> Result<(), ApiError> {
1113    validate_required_str("bearer token", token)
1114}
1115
1116fn validate_auth_metadata(metadata: &AuthMetadata) -> Result<(), ApiError> {
1117    validate_required_str("device id", metadata.device_id())
1118}
1119
1120fn validate_upload_file_input(input: &UploadFileInput) -> Result<(), ApiError> {
1121    if input.file_name.trim().is_empty() {
1122        return Err(ApiError::InvalidInput {
1123            message: "upload file name cannot be empty".to_string(),
1124        });
1125    }
1126
1127    match (input.file_type, input.video_metadata) {
1128        (UploadFileType::Video, Some(metadata)) => validate_upload_video_metadata(metadata),
1129        (UploadFileType::Video, None) => Err(ApiError::InvalidInput {
1130            message: "video uploads require video metadata".to_string(),
1131        }),
1132        (_, Some(_)) => Err(ApiError::InvalidInput {
1133            message: "video metadata can only be used with video uploads".to_string(),
1134        }),
1135        (_, None) => Ok(()),
1136    }
1137}
1138
1139fn validate_upload_video_metadata(metadata: UploadVideoMetadata) -> Result<(), ApiError> {
1140    if metadata.width <= 0 || metadata.height <= 0 || metadata.duration <= 0 {
1141        return Err(ApiError::InvalidInput {
1142            message: "video metadata width, height, and duration must be positive".to_string(),
1143        });
1144    }
1145    Ok(())
1146}
1147
1148fn validate_positive_id(field: &'static str, value: i64) -> Result<(), ApiError> {
1149    if value <= 0 {
1150        return Err(ApiError::InvalidInput {
1151            message: format!("{field} must be positive"),
1152        });
1153    }
1154    Ok(())
1155}
1156
1157fn validate_peer_id(peer: PeerId) -> Result<(), ApiError> {
1158    match peer {
1159        PeerId::User(user_id) => validate_positive_id("peer user id", user_id),
1160        PeerId::Thread(thread_id) => validate_positive_id("peer thread id", thread_id),
1161    }
1162}
1163
1164fn validate_create_linear_issue_input(input: &CreateLinearIssueInput) -> Result<(), ApiError> {
1165    validate_required_str("Linear issue text", &input.text)?;
1166    validate_positive_id("message id", input.message_id)?;
1167    validate_positive_id("chat id", input.chat_id)?;
1168    validate_positive_id("sender user id", input.from_id)?;
1169    validate_peer_id(input.peer)?;
1170    if let Some(space_id) = input.space_id {
1171        validate_positive_id("space id", space_id)?;
1172    }
1173    Ok(())
1174}
1175
1176fn validate_create_notion_task_input(input: &CreateNotionTaskInput) -> Result<(), ApiError> {
1177    validate_positive_id("space id", input.space_id)?;
1178    validate_positive_id("message id", input.message_id)?;
1179    validate_positive_id("chat id", input.chat_id)?;
1180    validate_peer_id(input.peer)
1181}
1182
1183fn add_auth_metadata(
1184    payload: &mut serde_json::Map<String, serde_json::Value>,
1185    metadata: &AuthMetadata,
1186) {
1187    payload.insert("deviceId".to_string(), json!(metadata.device_id()));
1188    payload.insert(
1189        "clientType".to_string(),
1190        json!(metadata.client().client_type()),
1191    );
1192    payload.insert(
1193        "clientVersion".to_string(),
1194        json!(metadata.client().client_version()),
1195    );
1196    if let Some(device_name) = metadata.device_name() {
1197        payload.insert("deviceName".to_string(), json!(device_name));
1198    }
1199}
1200
1201fn add_peer_selector_fields(
1202    payload: &mut serde_json::Map<String, serde_json::Value>,
1203    peer: PeerId,
1204) {
1205    match peer {
1206        PeerId::User(user_id) => {
1207            payload.insert("peerUserId".to_string(), json!(user_id));
1208        }
1209        PeerId::Thread(thread_id) => {
1210            payload.insert("peerThreadId".to_string(), json!(thread_id));
1211        }
1212    }
1213}
1214
1215fn peer_id_object(peer: PeerId) -> serde_json::Map<String, serde_json::Value> {
1216    let mut peer_id = serde_json::Map::new();
1217    match peer {
1218        PeerId::User(user_id) => {
1219            peer_id.insert("userId".to_string(), json!(user_id));
1220        }
1221        PeerId::Thread(thread_id) => {
1222            peer_id.insert("threadId".to_string(), json!(thread_id));
1223        }
1224    }
1225    peer_id
1226}
1227
1228#[cfg(test)]
1229mod tests {
1230    use super::*;
1231
1232    #[derive(Debug, Deserialize, PartialEq)]
1233    #[serde(rename_all = "camelCase")]
1234    struct TestResult {
1235        value: String,
1236    }
1237
1238    #[test]
1239    fn api_client_builder_normalizes_base_url() {
1240        let client = ApiClient::try_new(" https://api.inline.chat/v1/ ").unwrap();
1241        assert_eq!(client.base_url(), "https://api.inline.chat/v1");
1242    }
1243
1244    #[test]
1245    fn api_client_builder_uses_default_request_timeout() {
1246        let client = ApiClient::try_new("https://api.inline.chat/v1").unwrap();
1247        assert_eq!(client.request_timeout(), Some(DEFAULT_API_TIMEOUT));
1248    }
1249
1250    #[test]
1251    fn api_client_builder_can_override_or_disable_request_timeout() {
1252        let client = ApiClient::builder("https://api.inline.chat/v1")
1253            .request_timeout(Duration::from_secs(5))
1254            .build()
1255            .unwrap();
1256        assert_eq!(client.request_timeout(), Some(Duration::from_secs(5)));
1257
1258        let client = ApiClient::builder("https://api.inline.chat/v1")
1259            .without_request_timeout()
1260            .build()
1261            .unwrap();
1262        assert_eq!(client.request_timeout(), None);
1263    }
1264
1265    #[test]
1266    fn custom_http_clients_own_their_timeout_policy() {
1267        let http = reqwest::Client::builder()
1268            .timeout(Duration::from_secs(10))
1269            .build()
1270            .unwrap();
1271        let client = ApiClient::try_with_http_client("https://api.inline.chat/v1", http).unwrap();
1272        assert_eq!(client.request_timeout(), None);
1273
1274        let http = reqwest::Client::builder()
1275            .timeout(Duration::from_secs(10))
1276            .build()
1277            .unwrap();
1278        let client = ApiClient::builder("https://api.inline.chat/v1")
1279            .request_timeout(Duration::from_secs(5))
1280            .http_client(http)
1281            .build()
1282            .unwrap();
1283        assert_eq!(client.request_timeout(), None);
1284    }
1285
1286    #[test]
1287    fn api_client_builder_rejects_invalid_base_url() {
1288        let err = match ApiClient::try_new("inline.test") {
1289            Ok(_) => panic!("expected invalid base URL"),
1290            Err(err) => err,
1291        };
1292        match err {
1293            ApiError::InvalidBaseUrl { url, message } => {
1294                assert_eq!(url, "inline.test");
1295                assert!(message.contains("relative URL without a base"));
1296            }
1297            other => panic!("expected invalid base URL, got {other:?}"),
1298        }
1299
1300        let err = match ApiClient::try_new("wss://api.inline.chat/v1") {
1301            Ok(_) => panic!("expected invalid base URL"),
1302            Err(err) => err,
1303        };
1304        match err {
1305            ApiError::InvalidBaseUrl { message, .. } => {
1306                assert_eq!(message, "scheme must be http or https");
1307            }
1308            other => panic!("expected invalid base URL, got {other:?}"),
1309        }
1310
1311        let err = match ApiClient::try_new("https://user:secret@api.inline.chat/v1") {
1312            Ok(_) => panic!("expected invalid base URL"),
1313            Err(err) => err,
1314        };
1315        match &err {
1316            ApiError::InvalidBaseUrl { message, .. } => {
1317                assert_eq!(message, "credentials are not valid in the API base URL");
1318                assert!(!err.to_string().contains("secret"));
1319            }
1320            other => panic!("expected invalid base URL, got {other:?}"),
1321        }
1322
1323        let err = match ApiClient::try_new("https://api.inline.chat/v1?debug=true") {
1324            Ok(_) => panic!("expected invalid base URL"),
1325            Err(err) => err,
1326        };
1327        match err {
1328            ApiError::InvalidBaseUrl { message, .. } => {
1329                assert_eq!(
1330                    message,
1331                    "query strings and fragments are not valid in the API base URL"
1332                );
1333            }
1334            other => panic!("expected invalid base URL, got {other:?}"),
1335        }
1336    }
1337
1338    #[test]
1339    fn api_debug_output_redacts_unsafe_url_parts() {
1340        let raw_url = "https://user:url-secret@api.inline.chat/v1?token=query-secret#frag";
1341        let builder = ApiClient::builder(raw_url);
1342        let builder_debug = format!("{builder:?}");
1343
1344        assert!(builder_debug.contains("https://api.inline.chat/v1"));
1345        assert!(!builder_debug.contains("url-secret"));
1346        assert!(!builder_debug.contains("query-secret"));
1347
1348        let err = ApiClient::try_new(raw_url).unwrap_err();
1349        let err_debug = format!("{err:?}");
1350
1351        assert!(err_debug.contains("https://api.inline.chat/v1"));
1352        assert!(!err_debug.contains("url-secret"));
1353        assert!(!err_debug.contains("query-secret"));
1354    }
1355
1356    #[test]
1357    fn api_url_path_for_log_omits_origin_query_and_fragment() {
1358        assert_eq!(
1359            api_url_path_for_log("https://api.inline.chat/v1/getMe?token=secret#frag"),
1360            "/v1/getMe"
1361        );
1362    }
1363
1364    #[test]
1365    fn upload_file_type_parses_and_displays_wire_values() {
1366        assert_eq!(
1367            "photo".parse::<UploadFileType>().unwrap(),
1368            UploadFileType::Photo
1369        );
1370        assert_eq!(
1371            "VIDEO".parse::<UploadFileType>().unwrap(),
1372            UploadFileType::Video
1373        );
1374        assert_eq!(UploadFileType::Document.to_string(), "document");
1375
1376        let err = "avatar".parse::<UploadFileType>().unwrap_err();
1377        assert_eq!(err.value, "avatar");
1378    }
1379
1380    #[test]
1381    fn upload_file_type_serializes_as_wire_values() {
1382        assert_eq!(
1383            serde_json::to_string(&UploadFileType::Photo).unwrap(),
1384            r#""photo""#
1385        );
1386        assert_eq!(
1387            serde_json::from_str::<UploadFileType>(r#""video""#).unwrap(),
1388            UploadFileType::Video
1389        );
1390    }
1391
1392    #[test]
1393    fn upload_input_constructors_set_expected_fields() {
1394        let metadata = UploadVideoMetadata::new(1920, 1080, 12);
1395        let video =
1396            UploadFileInput::video("clip.mp4", "clip.mp4", metadata).with_mime_type(" video/mp4 ");
1397
1398        assert_eq!(video.path, PathBuf::from("clip.mp4"));
1399        assert_eq!(video.file_name, "clip.mp4");
1400        assert_eq!(video.file_type, UploadFileType::Video);
1401        assert_eq!(video.mime_type.as_deref(), Some("video/mp4"));
1402        assert_eq!(video.video_metadata, Some(metadata));
1403
1404        let document = UploadFileInput::document("notes.txt", "notes.txt").with_mime_type(" ");
1405        assert_eq!(document.file_type, UploadFileType::Document);
1406        assert!(document.mime_type.is_none());
1407        assert!(document.video_metadata.is_none());
1408    }
1409
1410    #[test]
1411    fn upload_input_debug_redacts_local_path() {
1412        let input = UploadFileInput::document("/Users/mo/private/report.pdf", "report.pdf")
1413            .with_mime_type("application/pdf");
1414        let debug = format!("{input:?}");
1415
1416        assert!(debug.contains("report.pdf"));
1417        assert!(debug.contains("<redacted>"));
1418        assert!(!debug.contains("/Users/mo/private"));
1419    }
1420
1421    #[test]
1422    fn upload_input_validation_rejects_invalid_video_metadata_shape() {
1423        let missing_metadata = UploadFileInput::new("clip.mp4", "clip.mp4", UploadFileType::Video);
1424        match validate_upload_file_input(&missing_metadata).unwrap_err() {
1425            ApiError::InvalidInput { message } => {
1426                assert_eq!(message, "video uploads require video metadata");
1427            }
1428            other => panic!("expected invalid input, got {other:?}"),
1429        }
1430
1431        let document_with_video = UploadFileInput::document("notes.txt", "notes.txt")
1432            .with_video_metadata(UploadVideoMetadata::new(1, 1, 1));
1433        match validate_upload_file_input(&document_with_video).unwrap_err() {
1434            ApiError::InvalidInput { message } => {
1435                assert_eq!(
1436                    message,
1437                    "video metadata can only be used with video uploads"
1438                );
1439            }
1440            other => panic!("expected invalid input, got {other:?}"),
1441        }
1442
1443        let bad_dimensions = UploadFileInput::video(
1444            "clip.mp4",
1445            "clip.mp4",
1446            UploadVideoMetadata::new(0, 1080, 12),
1447        );
1448        match validate_upload_file_input(&bad_dimensions).unwrap_err() {
1449            ApiError::InvalidInput { message } => {
1450                assert_eq!(
1451                    message,
1452                    "video metadata width, height, and duration must be positive"
1453                );
1454            }
1455            other => panic!("expected invalid input, got {other:?}"),
1456        }
1457    }
1458
1459    #[test]
1460    fn upload_input_validation_rejects_empty_file_name() {
1461        let input = UploadFileInput::document("notes.txt", " ");
1462        match validate_upload_file_input(&input).unwrap_err() {
1463            ApiError::InvalidInput { message } => {
1464                assert_eq!(message, "upload file name cannot be empty");
1465            }
1466            other => panic!("expected invalid input, got {other:?}"),
1467        }
1468    }
1469
1470    #[test]
1471    fn auth_and_token_validation_reject_blank_required_fields() {
1472        match validate_required_str("email", "  ").unwrap_err() {
1473            ApiError::InvalidInput { message } => {
1474                assert_eq!(message, "email cannot be empty");
1475            }
1476            other => panic!("expected invalid input, got {other:?}"),
1477        }
1478
1479        match validate_bearer_token("").unwrap_err() {
1480            ApiError::InvalidInput { message } => {
1481                assert_eq!(message, "bearer token cannot be empty");
1482            }
1483            other => panic!("expected invalid input, got {other:?}"),
1484        }
1485
1486        match validate_auth_metadata(&AuthMetadata::sdk(" ")).unwrap_err() {
1487            ApiError::InvalidInput { message } => {
1488                assert_eq!(message, "device id cannot be empty");
1489            }
1490            other => panic!("expected invalid input, got {other:?}"),
1491        }
1492    }
1493
1494    #[test]
1495    fn auth_result_debug_redacts_tokens() {
1496        let send_code = SendCodeResult {
1497            existing_user: true,
1498            needs_invite_code: false,
1499            challenge_token: Some("challenge-secret".to_string()),
1500        };
1501        let verify_code = VerifyCodeResult {
1502            user_id: 42,
1503            token: "bearer-secret".to_string(),
1504        };
1505
1506        let send_debug = format!("{send_code:?}");
1507        let verify_debug = format!("{verify_code:?}");
1508
1509        assert!(send_debug.contains("<redacted>"));
1510        assert!(!send_debug.contains("challenge-secret"));
1511        assert!(verify_debug.contains("<redacted>"));
1512        assert!(!verify_debug.contains("bearer-secret"));
1513    }
1514
1515    #[test]
1516    fn api_input_validation_rejects_non_positive_ids() {
1517        match validate_peer_id(PeerId::thread(0)).unwrap_err() {
1518            ApiError::InvalidInput { message } => {
1519                assert_eq!(message, "peer thread id must be positive");
1520            }
1521            other => panic!("expected invalid input, got {other:?}"),
1522        }
1523
1524        let linear = CreateLinearIssueInput::new("ship it", 0, 20, 30, PeerId::thread(20));
1525        match validate_create_linear_issue_input(&linear).unwrap_err() {
1526            ApiError::InvalidInput { message } => {
1527                assert_eq!(message, "message id must be positive");
1528            }
1529            other => panic!("expected invalid input, got {other:?}"),
1530        }
1531
1532        let notion = CreateNotionTaskInput::new(1, 2, 0, PeerId::thread(3));
1533        match validate_create_notion_task_input(&notion).unwrap_err() {
1534            ApiError::InvalidInput { message } => {
1535                assert_eq!(message, "chat id must be positive");
1536            }
1537            other => panic!("expected invalid input, got {other:?}"),
1538        }
1539    }
1540
1541    #[test]
1542    fn peer_id_helpers_encode_user_and_thread_peers() {
1543        assert_eq!(PeerId::user(42).user_id(), Some(42));
1544        assert_eq!(PeerId::user(42).thread_id(), None);
1545        assert_eq!(PeerId::thread(99).thread_id(), Some(99));
1546        assert_eq!(PeerId::thread(99).user_id(), None);
1547
1548        let mut payload = serde_json::Map::new();
1549        add_peer_selector_fields(&mut payload, PeerId::user(42));
1550        assert_eq!(payload.get("peerUserId"), Some(&json!(42)));
1551        assert!(payload.get("peerThreadId").is_none());
1552
1553        let peer_id = peer_id_object(PeerId::thread(99));
1554        assert_eq!(peer_id.get("threadId"), Some(&json!(99)));
1555        assert!(peer_id.get("userId").is_none());
1556    }
1557
1558    #[test]
1559    fn api_input_constructors_keep_required_fields_explicit() {
1560        let read = ReadMessagesInput::new(PeerId::user(42)).with_max_id(99);
1561        assert_eq!(read.peer, PeerId::user(42));
1562        assert_eq!(read.max_id, Some(99));
1563
1564        let linear = CreateLinearIssueInput::new("ship it", 10, 20, 30, PeerId::thread(20))
1565            .with_space_id(40);
1566        assert_eq!(linear.text, "ship it");
1567        assert_eq!(linear.message_id, 10);
1568        assert_eq!(linear.chat_id, 20);
1569        assert_eq!(linear.from_id, 30);
1570        assert_eq!(linear.peer, PeerId::thread(20));
1571        assert_eq!(linear.space_id, Some(40));
1572
1573        let notion = CreateNotionTaskInput::new(1, 2, 3, PeerId::thread(3));
1574        assert_eq!(notion.space_id, 1);
1575        assert_eq!(notion.message_id, 2);
1576        assert_eq!(notion.chat_id, 3);
1577        assert_eq!(notion.peer, PeerId::thread(3));
1578    }
1579
1580    #[test]
1581    fn decodes_success_envelope() {
1582        let result: TestResult =
1583            decode_api_response_text(StatusCode::OK, r#"{"ok":true,"result":{"value":"done"}}"#)
1584                .unwrap();
1585
1586        assert_eq!(
1587            result,
1588            TestResult {
1589                value: "done".to_string()
1590            }
1591        );
1592    }
1593
1594    #[test]
1595    fn preserves_json_api_error_fields() {
1596        let err = decode_api_response_text::<TestResult>(
1597            StatusCode::BAD_REQUEST,
1598            r#"{"ok":false,"error":"INVALID_CODE","error_code":123,"description":"Code is invalid"}"#,
1599        )
1600        .unwrap_err();
1601
1602        match err {
1603            ApiError::Api {
1604                status,
1605                error,
1606                error_code,
1607                description,
1608            } => {
1609                assert_eq!(status, Some(400));
1610                assert_eq!(error, "INVALID_CODE");
1611                assert_eq!(error_code, Some(123));
1612                assert_eq!(description, "Code is invalid");
1613            }
1614            other => panic!("expected api error, got {other:?}"),
1615        }
1616    }
1617
1618    #[test]
1619    fn preserves_nonstandard_ok_false_message() {
1620        let err = decode_api_response_text::<TestResult>(
1621            StatusCode::OK,
1622            r#"{"ok":false,"message":"Not enough permissions"}"#,
1623        )
1624        .unwrap_err();
1625
1626        match err {
1627            ApiError::Api {
1628                status,
1629                error,
1630                error_code,
1631                description,
1632            } => {
1633                assert_eq!(status, Some(200));
1634                assert_eq!(error, "API_ERROR");
1635                assert_eq!(error_code, None);
1636                assert_eq!(description, "Not enough permissions");
1637            }
1638            other => panic!("expected api error, got {other:?}"),
1639        }
1640    }
1641
1642    #[test]
1643    fn preserves_non_json_http_body_preview() {
1644        let err = decode_api_response_text::<TestResult>(
1645            StatusCode::INTERNAL_SERVER_ERROR,
1646            "upstream failed\nwith details",
1647        )
1648        .unwrap_err();
1649
1650        match err {
1651            ApiError::Status {
1652                status,
1653                message,
1654                body,
1655            } => {
1656                assert_eq!(status, 500);
1657                assert_eq!(message, "Internal Server Error");
1658                assert_eq!(body.as_deref(), Some("upstream failed with details"));
1659            }
1660            other => panic!("expected status error, got {other:?}"),
1661        }
1662    }
1663
1664    #[test]
1665    fn auth_metadata_includes_client_identity() {
1666        let mut payload = serde_json::Map::new();
1667        let identity = ClientIdentity::new("cli", "1.2.3");
1668        add_auth_metadata(
1669            &mut payload,
1670            &AuthMetadata::new("device-1", identity).with_device_name("mo-mac"),
1671        );
1672
1673        assert_eq!(payload.get("deviceId"), Some(&json!("device-1")));
1674        assert_eq!(payload.get("clientType"), Some(&json!("cli")));
1675        assert_eq!(payload.get("clientVersion"), Some(&json!("1.2.3")));
1676        assert_eq!(payload.get("deviceName"), Some(&json!("mo-mac")));
1677    }
1678
1679    #[test]
1680    fn auth_metadata_accepts_non_cli_client_identity() {
1681        let mut payload = serde_json::Map::new();
1682        add_auth_metadata(
1683            &mut payload,
1684            &AuthMetadata::new("device-1", ClientIdentity::new("my-agent", "0.1.0")),
1685        );
1686
1687        assert_eq!(payload.get("deviceId"), Some(&json!("device-1")));
1688        assert_eq!(payload.get("clientType"), Some(&json!("my-agent")));
1689        assert_eq!(payload.get("clientVersion"), Some(&json!("0.1.0")));
1690        assert!(payload.get("deviceName").is_none());
1691    }
1692}