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