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);
18
19#[derive(Error)]
21#[non_exhaustive]
22pub enum ApiError {
23 #[error("invalid API base URL: {message}")]
25 InvalidBaseUrl {
26 url: String,
28 message: String,
30 },
31 #[error("http error: {0}")]
33 Http(#[from] reqwest::Error),
34 #[error("io error: {0}")]
36 Io(#[from] std::io::Error),
37 #[error("json error: {0}")]
39 Json(#[from] serde_json::Error),
40 #[error("invalid input: {message}")]
42 InvalidInput {
43 message: String,
45 },
46 #[error("api request failed with HTTP {status}: {message}")]
48 Status {
49 status: u16,
51 message: String,
53 body: Option<String>,
55 },
56 #[error("api error: {error}: {description}")]
58 Api {
59 status: Option<u16>,
61 error: String,
63 error_code: Option<i32>,
65 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#[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#[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 pub fn builder(base_url: impl Into<String>) -> ApiClientBuilder {
157 ApiClientBuilder::new(base_url)
158 }
159
160 pub fn try_new(base_url: impl Into<String>) -> Result<Self, ApiError> {
162 Self::builder(base_url).build()
163 }
164
165 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 pub fn base_url(&self) -> &str {
175 &self.base_url
176 }
177
178 pub fn http_client(&self) -> &Client {
180 &self.http
181 }
182
183 pub fn request_timeout(&self) -> Option<Duration> {
187 self.request_timeout
188 }
189}
190
191impl ApiClientBuilder {
192 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 pub fn identity(mut self, identity: ClientIdentity) -> Self {
204 self.identity = identity;
205 self
206 }
207
208 pub fn http_client(mut self, http: Client) -> Self {
214 self.http = Some(http);
215 self
216 }
217
218 pub fn request_timeout(mut self, timeout: Duration) -> Self {
220 self.request_timeout = Some(timeout);
221 self
222 }
223
224 pub fn without_request_timeout(mut self) -> Self {
226 self.request_timeout = None;
227 self
228 }
229
230 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 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 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 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 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 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 pub async fn upload_file(
345 &self,
346 token: &str,
347 input: UploadFileInput,
348 ) -> Result<UploadFileResult, ApiError> {
349 validate_bearer_token(token)?;
350 validate_upload_file_input(&input)?;
351 let UploadFileInput {
352 path,
353 file_name,
354 mime_type,
355 file_type,
356 video_metadata,
357 } = input;
358 let bytes = fs::read(&path)?;
359 self.upload_file_bytes(
360 token,
361 UploadFileBytesInput {
362 bytes,
363 file_name,
364 mime_type,
365 file_type,
366 video_metadata,
367 },
368 )
369 .await
370 }
371
372 pub async fn upload_file_bytes(
374 &self,
375 token: &str,
376 input: UploadFileBytesInput,
377 ) -> Result<UploadFileResult, ApiError> {
378 validate_bearer_token(token)?;
379 validate_upload_file_bytes_input(&input)?;
380 let url = format!("{}/uploadFile", self.base_url);
381 log::debug!(
382 target: "inline_sdk::api",
383 "uploading file bytes type={} size_bytes={} has_mime_type={} has_video_metadata={}",
384 input.file_type,
385 input.bytes.len(),
386 input.mime_type.is_some(),
387 input.video_metadata.is_some()
388 );
389 let mut form = reqwest::multipart::Form::new().text("type", input.file_type.as_str());
390 let mut file_part = reqwest::multipart::Part::bytes(input.bytes);
391 file_part = file_part.file_name(input.file_name);
392 if let Some(mime) = input.mime_type {
393 file_part = file_part.mime_str(&mime)?;
394 }
395 form = form.part("file", file_part);
396
397 if let Some(video) = input.video_metadata {
398 form = form
399 .text("width", video.width.to_string())
400 .text("height", video.height.to_string())
401 .text("duration", video.duration.to_string());
402 }
403
404 let response = self
405 .http
406 .post(url)
407 .bearer_auth(token)
408 .multipart(form)
409 .send()
410 .await?;
411 log::trace!(
412 target: "inline_sdk::api",
413 "uploadFile response status={}",
414 response.status()
415 );
416 decode_api_response(response).await
417 }
418
419 pub async fn read_messages(
421 &self,
422 token: &str,
423 input: ReadMessagesInput,
424 ) -> Result<ReadMessagesResult, ApiError> {
425 validate_bearer_token(token)?;
426 validate_peer_id(input.peer)?;
427 if let Some(max_id) = input.max_id {
428 validate_positive_id("max_id", max_id)?;
429 }
430 let url = format!("{}/readMessages", self.base_url);
431 let mut payload = serde_json::Map::new();
432 add_peer_selector_fields(&mut payload, input.peer);
433 if let Some(max_id) = input.max_id {
434 payload.insert("maxId".to_string(), json!(max_id));
435 }
436 self.post_with_token(url, token, payload).await
437 }
438
439 pub async fn create_private_chat(
441 &self,
442 token: &str,
443 user_id: i64,
444 ) -> Result<CreatePrivateChatResult, ApiError> {
445 validate_bearer_token(token)?;
446 validate_positive_id("user_id", user_id)?;
447 let url = format!("{}/createPrivateChat", self.base_url);
448 let mut payload = serde_json::Map::new();
449 payload.insert("userId".to_string(), json!(user_id));
450 self.post_with_token(url, token, payload).await
451 }
452
453 pub async fn create_linear_issue(
455 &self,
456 token: &str,
457 input: CreateLinearIssueInput,
458 ) -> Result<CreateLinearIssueResult, ApiError> {
459 validate_bearer_token(token)?;
460 validate_create_linear_issue_input(&input)?;
461 let url = format!("{}/createLinearIssue", self.base_url);
462 let mut payload = serde_json::Map::new();
463 payload.insert("text".to_string(), json!(input.text));
464 payload.insert("messageId".to_string(), json!(input.message_id));
465 payload.insert("chatId".to_string(), json!(input.chat_id));
466 payload.insert("fromId".to_string(), json!(input.from_id));
467
468 payload.insert("peerId".to_string(), json!(peer_id_object(input.peer)));
469
470 if let Some(space_id) = input.space_id {
471 payload.insert("spaceId".to_string(), json!(space_id));
472 }
473
474 self.post_with_token(url, token, payload).await
475 }
476
477 pub async fn create_notion_task(
479 &self,
480 token: &str,
481 input: CreateNotionTaskInput,
482 ) -> Result<CreateNotionTaskResult, ApiError> {
483 validate_bearer_token(token)?;
484 validate_create_notion_task_input(&input)?;
485 let url = format!("{}/createNotionTask", self.base_url);
486 let mut payload = serde_json::Map::new();
487 payload.insert("spaceId".to_string(), json!(input.space_id));
488 payload.insert("messageId".to_string(), json!(input.message_id));
489 payload.insert("chatId".to_string(), json!(input.chat_id));
490
491 payload.insert("peerId".to_string(), json!(peer_id_object(input.peer)));
492
493 self.post_with_token(url, token, payload).await
494 }
495
496 async fn post<T: for<'de> Deserialize<'de>>(
497 &self,
498 url: String,
499 payload: serde_json::Map<String, serde_json::Value>,
500 ) -> Result<T, ApiError> {
501 log::trace!(
502 target: "inline_sdk::api",
503 "POST {}",
504 api_url_path_for_log(&url)
505 );
506 let response = self.http.post(url).json(&payload).send().await?;
507 log::trace!(
508 target: "inline_sdk::api",
509 "API response status={}",
510 response.status()
511 );
512 decode_api_response(response).await
513 }
514
515 async fn post_with_token<T: for<'de> Deserialize<'de>>(
516 &self,
517 url: String,
518 token: &str,
519 payload: serde_json::Map<String, serde_json::Value>,
520 ) -> Result<T, ApiError> {
521 log::trace!(
522 target: "inline_sdk::api",
523 "POST {} with bearer auth",
524 api_url_path_for_log(&url)
525 );
526 let response = self
527 .http
528 .post(url)
529 .bearer_auth(token)
530 .json(&payload)
531 .send()
532 .await?;
533 log::trace!(
534 target: "inline_sdk::api",
535 "API response status={}",
536 response.status()
537 );
538 decode_api_response(response).await
539 }
540}
541
542#[derive(Clone, Deserialize, Serialize, PartialEq, Eq)]
544#[serde(rename_all = "camelCase")]
545pub struct SendCodeResult {
546 pub existing_user: bool,
548 pub needs_invite_code: bool,
550 pub challenge_token: Option<String>,
552}
553
554impl fmt::Debug for SendCodeResult {
555 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
556 f.debug_struct("SendCodeResult")
557 .field("existing_user", &self.existing_user)
558 .field("needs_invite_code", &self.needs_invite_code)
559 .field(
560 "challenge_token",
561 &self.challenge_token.as_ref().map(|_| "<redacted>"),
562 )
563 .finish()
564 }
565}
566
567#[derive(Clone, Deserialize, Serialize, PartialEq, Eq)]
569#[serde(rename_all = "camelCase")]
570pub struct VerifyCodeResult {
571 pub user_id: i64,
573 pub token: String,
575}
576
577impl fmt::Debug for VerifyCodeResult {
578 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
579 f.debug_struct("VerifyCodeResult")
580 .field("user_id", &self.user_id)
581 .field("token", &"<redacted>")
582 .finish()
583 }
584}
585
586#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, Hash)]
588#[non_exhaustive]
589#[serde(rename_all = "lowercase")]
590pub enum UploadFileType {
591 Photo,
593 Video,
595 Document,
597}
598
599impl UploadFileType {
600 pub fn as_str(&self) -> &'static str {
602 match self {
603 UploadFileType::Photo => "photo",
604 UploadFileType::Video => "video",
605 UploadFileType::Document => "document",
606 }
607 }
608}
609
610impl fmt::Display for UploadFileType {
611 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
612 f.write_str(self.as_str())
613 }
614}
615
616impl FromStr for UploadFileType {
617 type Err = UploadFileTypeParseError;
618
619 fn from_str(value: &str) -> Result<Self, Self::Err> {
620 match value.trim().to_ascii_lowercase().as_str() {
621 "photo" => Ok(UploadFileType::Photo),
622 "video" => Ok(UploadFileType::Video),
623 "document" => Ok(UploadFileType::Document),
624 _ => Err(UploadFileTypeParseError {
625 value: value.to_string(),
626 }),
627 }
628 }
629}
630
631#[derive(Debug, Clone, Error, PartialEq, Eq)]
633#[error("unknown upload file type `{value}`")]
634pub struct UploadFileTypeParseError {
635 pub value: String,
637}
638
639#[must_use]
641#[derive(Debug, Clone, Copy, PartialEq, Eq)]
642pub struct UploadVideoMetadata {
643 pub width: i32,
645 pub height: i32,
647 pub duration: i32,
649}
650
651impl UploadVideoMetadata {
652 pub fn new(width: i32, height: i32, duration: i32) -> Self {
654 Self {
655 width,
656 height,
657 duration,
658 }
659 }
660}
661
662#[must_use]
664#[derive(Clone, PartialEq, Eq)]
665pub struct UploadFileInput {
666 pub path: PathBuf,
668 pub file_name: String,
670 pub mime_type: Option<String>,
672 pub file_type: UploadFileType,
674 pub video_metadata: Option<UploadVideoMetadata>,
676}
677
678impl fmt::Debug for UploadFileInput {
679 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
680 f.debug_struct("UploadFileInput")
681 .field("path", &"<redacted>")
682 .field("file_name", &self.file_name)
683 .field("mime_type", &self.mime_type)
684 .field("file_type", &self.file_type)
685 .field("video_metadata", &self.video_metadata)
686 .finish()
687 }
688}
689
690impl UploadFileInput {
691 pub fn new(
693 path: impl Into<PathBuf>,
694 file_name: impl Into<String>,
695 file_type: UploadFileType,
696 ) -> Self {
697 Self {
698 path: path.into(),
699 file_name: file_name.into(),
700 mime_type: None,
701 file_type,
702 video_metadata: None,
703 }
704 }
705
706 pub fn photo(path: impl Into<PathBuf>, file_name: impl Into<String>) -> Self {
708 Self::new(path, file_name, UploadFileType::Photo)
709 }
710
711 pub fn video(
713 path: impl Into<PathBuf>,
714 file_name: impl Into<String>,
715 metadata: UploadVideoMetadata,
716 ) -> Self {
717 Self::new(path, file_name, UploadFileType::Video).with_video_metadata(metadata)
718 }
719
720 pub fn document(path: impl Into<PathBuf>, file_name: impl Into<String>) -> Self {
722 Self::new(path, file_name, UploadFileType::Document)
723 }
724
725 pub fn with_mime_type(mut self, mime_type: impl Into<String>) -> Self {
727 let mime_type = mime_type.into().trim().to_string();
728 if !mime_type.is_empty() {
729 self.mime_type = Some(mime_type);
730 }
731 self
732 }
733
734 pub fn with_video_metadata(mut self, metadata: UploadVideoMetadata) -> Self {
736 self.video_metadata = Some(metadata);
737 self
738 }
739}
740
741#[must_use]
743#[derive(Clone, PartialEq, Eq)]
744pub struct UploadFileBytesInput {
745 pub bytes: Vec<u8>,
747 pub file_name: String,
749 pub mime_type: Option<String>,
751 pub file_type: UploadFileType,
753 pub video_metadata: Option<UploadVideoMetadata>,
755}
756
757impl fmt::Debug for UploadFileBytesInput {
758 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
759 f.debug_struct("UploadFileBytesInput")
760 .field("bytes_len", &self.bytes.len())
761 .field("file_name", &self.file_name)
762 .field("mime_type", &self.mime_type)
763 .field("file_type", &self.file_type)
764 .field("video_metadata", &self.video_metadata)
765 .finish()
766 }
767}
768
769impl UploadFileBytesInput {
770 pub fn new(
772 bytes: impl Into<Vec<u8>>,
773 file_name: impl Into<String>,
774 file_type: UploadFileType,
775 ) -> Self {
776 Self {
777 bytes: bytes.into(),
778 file_name: file_name.into(),
779 mime_type: None,
780 file_type,
781 video_metadata: None,
782 }
783 }
784
785 pub fn photo(bytes: impl Into<Vec<u8>>, file_name: impl Into<String>) -> Self {
787 Self::new(bytes, file_name, UploadFileType::Photo)
788 }
789
790 pub fn video(
792 bytes: impl Into<Vec<u8>>,
793 file_name: impl Into<String>,
794 metadata: UploadVideoMetadata,
795 ) -> Self {
796 Self::new(bytes, file_name, UploadFileType::Video).with_video_metadata(metadata)
797 }
798
799 pub fn document(bytes: impl Into<Vec<u8>>, file_name: impl Into<String>) -> Self {
801 Self::new(bytes, file_name, UploadFileType::Document)
802 }
803
804 pub fn with_mime_type(mut self, mime_type: impl Into<String>) -> Self {
806 let mime_type = mime_type.into().trim().to_string();
807 if !mime_type.is_empty() {
808 self.mime_type = Some(mime_type);
809 }
810 self
811 }
812
813 pub fn with_video_metadata(mut self, metadata: UploadVideoMetadata) -> Self {
815 self.video_metadata = Some(metadata);
816 self
817 }
818}
819
820#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)]
822#[serde(rename_all = "camelCase")]
823pub struct UploadFileResult {
824 pub file_unique_id: String,
826 pub photo_id: Option<i64>,
828 pub video_id: Option<i64>,
830 pub document_id: Option<i64>,
832}
833
834#[must_use]
836#[derive(Debug, Clone, Copy, PartialEq, Eq)]
837pub struct ReadMessagesInput {
838 pub peer: PeerId,
840 pub max_id: Option<i64>,
842}
843
844impl ReadMessagesInput {
845 pub fn new(peer: PeerId) -> Self {
847 Self { peer, max_id: None }
848 }
849
850 pub fn with_max_id(mut self, max_id: i64) -> Self {
852 self.max_id = Some(max_id);
853 self
854 }
855}
856
857#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)]
859#[serde(rename_all = "camelCase")]
860pub struct ReadMessagesResult {}
861
862#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
864#[non_exhaustive]
865pub enum PeerId {
866 User(i64),
868 Thread(i64),
870}
871
872impl PeerId {
873 pub fn user(user_id: i64) -> Self {
875 Self::User(user_id)
876 }
877
878 pub fn thread(thread_id: i64) -> Self {
880 Self::Thread(thread_id)
881 }
882
883 pub fn user_id(self) -> Option<i64> {
885 match self {
886 Self::User(user_id) => Some(user_id),
887 Self::Thread(_) => None,
888 }
889 }
890
891 pub fn thread_id(self) -> Option<i64> {
893 match self {
894 Self::User(_) => None,
895 Self::Thread(thread_id) => Some(thread_id),
896 }
897 }
898}
899
900#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
902#[serde(rename_all = "camelCase")]
903pub struct CreatePrivateChatResult {
904 pub chat: Value,
906 pub dialog: Value,
908 pub user: Value,
910}
911
912#[must_use]
914#[derive(Debug, Clone, PartialEq, Eq)]
915pub struct CreateLinearIssueInput {
916 pub text: String,
918 pub message_id: i64,
920 pub chat_id: i64,
922 pub from_id: i64,
924 pub peer: PeerId,
926 pub space_id: Option<i64>,
928}
929
930impl CreateLinearIssueInput {
931 pub fn new(
933 text: impl Into<String>,
934 message_id: i64,
935 chat_id: i64,
936 from_id: i64,
937 peer: PeerId,
938 ) -> Self {
939 Self {
940 text: text.into(),
941 message_id,
942 chat_id,
943 from_id,
944 peer,
945 space_id: None,
946 }
947 }
948
949 pub fn with_space_id(mut self, space_id: i64) -> Self {
951 self.space_id = Some(space_id);
952 self
953 }
954}
955
956#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)]
958#[serde(rename_all = "camelCase")]
959pub struct CreateLinearIssueResult {
960 pub link: Option<String>,
962}
963
964#[must_use]
966#[derive(Debug, Clone, PartialEq, Eq)]
967pub struct CreateNotionTaskInput {
968 pub space_id: i64,
970 pub message_id: i64,
972 pub chat_id: i64,
974 pub peer: PeerId,
976}
977
978impl CreateNotionTaskInput {
979 pub fn new(space_id: i64, message_id: i64, chat_id: i64, peer: PeerId) -> Self {
981 Self {
982 space_id,
983 message_id,
984 chat_id,
985 peer,
986 }
987 }
988}
989
990#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)]
992#[serde(rename_all = "camelCase")]
993pub struct CreateNotionTaskResult {
994 pub url: String,
996 pub task_title: Option<String>,
998}
999
1000#[derive(Debug, Deserialize)]
1001#[serde(untagged, rename_all = "camelCase")]
1002enum ApiResponse<T> {
1003 Ok {
1004 #[serde(rename = "ok")]
1005 _ok: bool,
1006 result: T,
1007 },
1008 Err {
1009 #[serde(rename = "ok")]
1010 _ok: bool,
1011 error: String,
1012 #[serde(rename = "errorCode", alias = "error_code")]
1013 _error_code: Option<i32>,
1014 description: Option<String>,
1015 },
1016}
1017
1018fn normalize_api_base_url(base_url: impl Into<String>) -> Result<String, ApiError> {
1019 let original = base_url.into();
1020 let normalized = original.trim().trim_end_matches('/').to_string();
1021 if normalized.is_empty() {
1022 return Err(ApiError::InvalidBaseUrl {
1023 url: original,
1024 message: "base URL cannot be empty".to_string(),
1025 });
1026 }
1027
1028 let parsed = Url::parse(&normalized).map_err(|err| ApiError::InvalidBaseUrl {
1029 url: normalized.clone(),
1030 message: err.to_string(),
1031 })?;
1032
1033 if !matches!(parsed.scheme(), "http" | "https") {
1034 return Err(ApiError::InvalidBaseUrl {
1035 url: normalized,
1036 message: "scheme must be http or https".to_string(),
1037 });
1038 }
1039
1040 if parsed.host_str().is_none() {
1041 return Err(ApiError::InvalidBaseUrl {
1042 url: normalized,
1043 message: "host is required".to_string(),
1044 });
1045 }
1046
1047 if !parsed.username().is_empty() || parsed.password().is_some() {
1048 return Err(ApiError::InvalidBaseUrl {
1049 url: normalized,
1050 message: "credentials are not valid in the API base URL".to_string(),
1051 });
1052 }
1053
1054 if parsed.query().is_some() || parsed.fragment().is_some() {
1055 return Err(ApiError::InvalidBaseUrl {
1056 url: normalized,
1057 message: "query strings and fragments are not valid in the API base URL".to_string(),
1058 });
1059 }
1060
1061 Ok(normalized)
1062}
1063
1064fn api_url_path_for_log(url: &str) -> String {
1065 Url::parse(url)
1066 .map(|url| url.path().to_string())
1067 .unwrap_or_else(|_| "<invalid-url>".to_string())
1068}
1069
1070fn api_base_url_for_debug(raw_url: &str) -> String {
1071 Url::parse(raw_url.trim())
1072 .map(|url| {
1073 let host = url.host_str().unwrap_or("<missing-host>");
1074 let port = url
1075 .port()
1076 .map(|port| format!(":{port}"))
1077 .unwrap_or_default();
1078 let path = url.path().trim_end_matches('/');
1079 format!("{}://{}{}{}", url.scheme(), host, port, path)
1080 })
1081 .unwrap_or_else(|_| "<invalid>".to_string())
1082}
1083
1084async fn decode_api_response<T: for<'de> Deserialize<'de>>(
1085 response: reqwest::Response,
1086) -> Result<T, ApiError> {
1087 let status = response.status();
1088 let text = response.text().await?;
1089 decode_api_response_text(status, &text)
1090}
1091
1092fn decode_api_response_text<T: for<'de> Deserialize<'de>>(
1093 status: StatusCode,
1094 text: &str,
1095) -> Result<T, ApiError> {
1096 let value: Value = serde_json::from_str(text).map_err(|err| {
1097 if status.is_success() {
1098 ApiError::Json(err)
1099 } else {
1100 ApiError::Status {
1101 status: status.as_u16(),
1102 message: status
1103 .canonical_reason()
1104 .unwrap_or("HTTP error")
1105 .to_string(),
1106 body: body_preview(text),
1107 }
1108 }
1109 })?;
1110
1111 if !status.is_success() {
1112 return Err(
1113 api_error_from_value(status, &value).unwrap_or_else(|| ApiError::Status {
1114 status: status.as_u16(),
1115 message: status
1116 .canonical_reason()
1117 .unwrap_or("HTTP error")
1118 .to_string(),
1119 body: body_preview(text),
1120 }),
1121 );
1122 }
1123
1124 if value.get("ok").and_then(Value::as_bool) == Some(false) {
1125 return Err(
1126 api_error_from_value(status, &value).unwrap_or_else(|| ApiError::Api {
1127 status: None,
1128 error: "API_ERROR".to_string(),
1129 error_code: None,
1130 description: "The server returned ok=false without an error description."
1131 .to_string(),
1132 }),
1133 );
1134 }
1135
1136 let api_response: ApiResponse<T> = serde_json::from_value(value)?;
1137 match api_response {
1138 ApiResponse::Ok { result, .. } => Ok(result),
1139 ApiResponse::Err {
1140 error,
1141 _error_code,
1142 description,
1143 ..
1144 } => Err(ApiError::Api {
1145 status: None,
1146 error,
1147 error_code: _error_code,
1148 description: description.unwrap_or_else(|| "Unknown error".to_string()),
1149 }),
1150 }
1151}
1152
1153fn api_error_from_value(status: StatusCode, value: &Value) -> Option<ApiError> {
1154 let object = value.as_object()?;
1155 let error = string_field(value, "error")
1156 .or_else(|| string_field(value, "code"))
1157 .or_else(|| string_field(value, "name"))
1158 .unwrap_or_else(|| {
1159 if value.get("ok").and_then(Value::as_bool) == Some(false) && status.is_success() {
1160 return "API_ERROR".to_string();
1161 }
1162 status
1163 .canonical_reason()
1164 .unwrap_or("HTTP error")
1165 .to_string()
1166 });
1167 let description = string_field(value, "description")
1168 .or_else(|| string_field(value, "message"))
1169 .or_else(|| string_field(value, "detail"))
1170 .unwrap_or_else(|| "No server error description was provided.".to_string());
1171 let error_code = object
1172 .get("error_code")
1173 .or_else(|| object.get("errorCode"))
1174 .and_then(|value| value.as_i64())
1175 .and_then(|value| i32::try_from(value).ok());
1176
1177 Some(ApiError::Api {
1178 status: Some(status.as_u16()),
1179 error,
1180 error_code,
1181 description,
1182 })
1183}
1184
1185fn string_field(value: &Value, key: &str) -> Option<String> {
1186 value
1187 .get(key)
1188 .and_then(Value::as_str)
1189 .map(str::trim)
1190 .filter(|value| !value.is_empty())
1191 .map(ToString::to_string)
1192}
1193
1194fn body_preview(text: &str) -> Option<String> {
1195 let normalized = text.split_whitespace().collect::<Vec<_>>().join(" ");
1196 if normalized.is_empty() {
1197 return None;
1198 }
1199 const MAX_BODY_PREVIEW_BYTES: usize = 500;
1200 if normalized.len() <= MAX_BODY_PREVIEW_BYTES {
1201 return Some(normalized);
1202 }
1203
1204 let mut end = MAX_BODY_PREVIEW_BYTES;
1205 while !normalized.is_char_boundary(end) {
1206 end -= 1;
1207 }
1208 Some(format!("{}...", &normalized[..end]))
1209}
1210
1211fn validate_required_str(field: &'static str, value: &str) -> Result<(), ApiError> {
1212 if value.trim().is_empty() {
1213 return Err(ApiError::InvalidInput {
1214 message: format!("{field} cannot be empty"),
1215 });
1216 }
1217 Ok(())
1218}
1219
1220fn validate_bearer_token(token: &str) -> Result<(), ApiError> {
1221 validate_required_str("bearer token", token)
1222}
1223
1224fn validate_auth_metadata(metadata: &AuthMetadata) -> Result<(), ApiError> {
1225 validate_required_str("device id", metadata.device_id())
1226}
1227
1228fn validate_upload_file_input(input: &UploadFileInput) -> Result<(), ApiError> {
1229 validate_upload_file_metadata(&input.file_name, input.file_type, input.video_metadata)
1230}
1231
1232fn validate_upload_file_bytes_input(input: &UploadFileBytesInput) -> Result<(), ApiError> {
1233 validate_upload_file_metadata(&input.file_name, input.file_type, input.video_metadata)?;
1234 if input.bytes.is_empty() {
1235 return Err(ApiError::InvalidInput {
1236 message: "upload file bytes cannot be empty".to_string(),
1237 });
1238 }
1239 Ok(())
1240}
1241
1242fn validate_upload_file_metadata(
1243 file_name: &str,
1244 file_type: UploadFileType,
1245 video_metadata: Option<UploadVideoMetadata>,
1246) -> Result<(), ApiError> {
1247 if file_name.trim().is_empty() {
1248 return Err(ApiError::InvalidInput {
1249 message: "upload file name cannot be empty".to_string(),
1250 });
1251 }
1252
1253 match (file_type, video_metadata) {
1254 (UploadFileType::Video, Some(metadata)) => validate_upload_video_metadata(metadata),
1255 (UploadFileType::Video, None) => Err(ApiError::InvalidInput {
1256 message: "video uploads require video metadata".to_string(),
1257 }),
1258 (_, Some(_)) => Err(ApiError::InvalidInput {
1259 message: "video metadata can only be used with video uploads".to_string(),
1260 }),
1261 (_, None) => Ok(()),
1262 }
1263}
1264
1265fn validate_upload_video_metadata(metadata: UploadVideoMetadata) -> Result<(), ApiError> {
1266 if metadata.width <= 0 || metadata.height <= 0 || metadata.duration <= 0 {
1267 return Err(ApiError::InvalidInput {
1268 message: "video metadata width, height, and duration must be positive".to_string(),
1269 });
1270 }
1271 Ok(())
1272}
1273
1274fn validate_positive_id(field: &'static str, value: i64) -> Result<(), ApiError> {
1275 if value <= 0 {
1276 return Err(ApiError::InvalidInput {
1277 message: format!("{field} must be positive"),
1278 });
1279 }
1280 Ok(())
1281}
1282
1283fn validate_peer_id(peer: PeerId) -> Result<(), ApiError> {
1284 match peer {
1285 PeerId::User(user_id) => validate_positive_id("peer user id", user_id),
1286 PeerId::Thread(thread_id) => validate_positive_id("peer thread id", thread_id),
1287 }
1288}
1289
1290fn validate_create_linear_issue_input(input: &CreateLinearIssueInput) -> Result<(), ApiError> {
1291 validate_required_str("Linear issue text", &input.text)?;
1292 validate_positive_id("message id", input.message_id)?;
1293 validate_positive_id("chat id", input.chat_id)?;
1294 validate_positive_id("sender user id", input.from_id)?;
1295 validate_peer_id(input.peer)?;
1296 if let Some(space_id) = input.space_id {
1297 validate_positive_id("space id", space_id)?;
1298 }
1299 Ok(())
1300}
1301
1302fn validate_create_notion_task_input(input: &CreateNotionTaskInput) -> Result<(), ApiError> {
1303 validate_positive_id("space id", input.space_id)?;
1304 validate_positive_id("message id", input.message_id)?;
1305 validate_positive_id("chat id", input.chat_id)?;
1306 validate_peer_id(input.peer)
1307}
1308
1309fn add_auth_metadata(
1310 payload: &mut serde_json::Map<String, serde_json::Value>,
1311 metadata: &AuthMetadata,
1312) {
1313 payload.insert("deviceId".to_string(), json!(metadata.device_id()));
1314 payload.insert(
1315 "clientType".to_string(),
1316 json!(metadata.client().client_type()),
1317 );
1318 payload.insert(
1319 "clientVersion".to_string(),
1320 json!(metadata.client().client_version()),
1321 );
1322 if let Some(device_name) = metadata.device_name() {
1323 payload.insert("deviceName".to_string(), json!(device_name));
1324 }
1325}
1326
1327fn add_peer_selector_fields(
1328 payload: &mut serde_json::Map<String, serde_json::Value>,
1329 peer: PeerId,
1330) {
1331 match peer {
1332 PeerId::User(user_id) => {
1333 payload.insert("peerUserId".to_string(), json!(user_id));
1334 }
1335 PeerId::Thread(thread_id) => {
1336 payload.insert("peerThreadId".to_string(), json!(thread_id));
1337 }
1338 }
1339}
1340
1341fn peer_id_object(peer: PeerId) -> serde_json::Map<String, serde_json::Value> {
1342 let mut peer_id = serde_json::Map::new();
1343 match peer {
1344 PeerId::User(user_id) => {
1345 peer_id.insert("userId".to_string(), json!(user_id));
1346 }
1347 PeerId::Thread(thread_id) => {
1348 peer_id.insert("threadId".to_string(), json!(thread_id));
1349 }
1350 }
1351 peer_id
1352}
1353
1354#[cfg(test)]
1355mod tests {
1356 use super::*;
1357
1358 #[derive(Debug, Deserialize, PartialEq)]
1359 #[serde(rename_all = "camelCase")]
1360 struct TestResult {
1361 value: String,
1362 }
1363
1364 #[test]
1365 fn api_client_builder_normalizes_base_url() {
1366 let client = ApiClient::try_new(" https://api.inline.chat/v1/ ").unwrap();
1367 assert_eq!(client.base_url(), "https://api.inline.chat/v1");
1368 }
1369
1370 #[test]
1371 fn api_client_builder_uses_default_request_timeout() {
1372 let client = ApiClient::try_new("https://api.inline.chat/v1").unwrap();
1373 assert_eq!(client.request_timeout(), Some(DEFAULT_API_TIMEOUT));
1374 }
1375
1376 #[test]
1377 fn api_client_builder_can_override_or_disable_request_timeout() {
1378 let client = ApiClient::builder("https://api.inline.chat/v1")
1379 .request_timeout(Duration::from_secs(5))
1380 .build()
1381 .unwrap();
1382 assert_eq!(client.request_timeout(), Some(Duration::from_secs(5)));
1383
1384 let client = ApiClient::builder("https://api.inline.chat/v1")
1385 .without_request_timeout()
1386 .build()
1387 .unwrap();
1388 assert_eq!(client.request_timeout(), None);
1389 }
1390
1391 #[test]
1392 fn custom_http_clients_own_their_timeout_policy() {
1393 let http = reqwest::Client::builder()
1394 .timeout(Duration::from_secs(10))
1395 .build()
1396 .unwrap();
1397 let client = ApiClient::try_with_http_client("https://api.inline.chat/v1", http).unwrap();
1398 assert_eq!(client.request_timeout(), None);
1399
1400 let http = reqwest::Client::builder()
1401 .timeout(Duration::from_secs(10))
1402 .build()
1403 .unwrap();
1404 let client = ApiClient::builder("https://api.inline.chat/v1")
1405 .request_timeout(Duration::from_secs(5))
1406 .http_client(http)
1407 .build()
1408 .unwrap();
1409 assert_eq!(client.request_timeout(), None);
1410 }
1411
1412 #[test]
1413 fn api_client_builder_rejects_invalid_base_url() {
1414 let err = match ApiClient::try_new("inline.test") {
1415 Ok(_) => panic!("expected invalid base URL"),
1416 Err(err) => err,
1417 };
1418 match err {
1419 ApiError::InvalidBaseUrl { url, message } => {
1420 assert_eq!(url, "inline.test");
1421 assert!(message.contains("relative URL without a base"));
1422 }
1423 other => panic!("expected invalid base URL, got {other:?}"),
1424 }
1425
1426 let err = match ApiClient::try_new("wss://api.inline.chat/v1") {
1427 Ok(_) => panic!("expected invalid base URL"),
1428 Err(err) => err,
1429 };
1430 match err {
1431 ApiError::InvalidBaseUrl { message, .. } => {
1432 assert_eq!(message, "scheme must be http or https");
1433 }
1434 other => panic!("expected invalid base URL, got {other:?}"),
1435 }
1436
1437 let err = match ApiClient::try_new("https://user:secret@api.inline.chat/v1") {
1438 Ok(_) => panic!("expected invalid base URL"),
1439 Err(err) => err,
1440 };
1441 match &err {
1442 ApiError::InvalidBaseUrl { message, .. } => {
1443 assert_eq!(message, "credentials are not valid in the API base URL");
1444 assert!(!err.to_string().contains("secret"));
1445 }
1446 other => panic!("expected invalid base URL, got {other:?}"),
1447 }
1448
1449 let err = match ApiClient::try_new("https://api.inline.chat/v1?debug=true") {
1450 Ok(_) => panic!("expected invalid base URL"),
1451 Err(err) => err,
1452 };
1453 match err {
1454 ApiError::InvalidBaseUrl { message, .. } => {
1455 assert_eq!(
1456 message,
1457 "query strings and fragments are not valid in the API base URL"
1458 );
1459 }
1460 other => panic!("expected invalid base URL, got {other:?}"),
1461 }
1462 }
1463
1464 #[test]
1465 fn api_debug_output_redacts_unsafe_url_parts() {
1466 let raw_url = "https://user:url-secret@api.inline.chat/v1?token=query-secret#frag";
1467 let builder = ApiClient::builder(raw_url);
1468 let builder_debug = format!("{builder:?}");
1469
1470 assert!(builder_debug.contains("https://api.inline.chat/v1"));
1471 assert!(!builder_debug.contains("url-secret"));
1472 assert!(!builder_debug.contains("query-secret"));
1473
1474 let err = ApiClient::try_new(raw_url).unwrap_err();
1475 let err_debug = format!("{err:?}");
1476
1477 assert!(err_debug.contains("https://api.inline.chat/v1"));
1478 assert!(!err_debug.contains("url-secret"));
1479 assert!(!err_debug.contains("query-secret"));
1480 }
1481
1482 #[test]
1483 fn api_url_path_for_log_omits_origin_query_and_fragment() {
1484 assert_eq!(
1485 api_url_path_for_log("https://api.inline.chat/v1/getMe?token=secret#frag"),
1486 "/v1/getMe"
1487 );
1488 }
1489
1490 #[test]
1491 fn upload_file_type_parses_and_displays_wire_values() {
1492 assert_eq!(
1493 "photo".parse::<UploadFileType>().unwrap(),
1494 UploadFileType::Photo
1495 );
1496 assert_eq!(
1497 "VIDEO".parse::<UploadFileType>().unwrap(),
1498 UploadFileType::Video
1499 );
1500 assert_eq!(UploadFileType::Document.to_string(), "document");
1501
1502 let err = "avatar".parse::<UploadFileType>().unwrap_err();
1503 assert_eq!(err.value, "avatar");
1504 }
1505
1506 #[test]
1507 fn upload_file_type_serializes_as_wire_values() {
1508 assert_eq!(
1509 serde_json::to_string(&UploadFileType::Photo).unwrap(),
1510 r#""photo""#
1511 );
1512 assert_eq!(
1513 serde_json::from_str::<UploadFileType>(r#""video""#).unwrap(),
1514 UploadFileType::Video
1515 );
1516 }
1517
1518 #[test]
1519 fn upload_input_constructors_set_expected_fields() {
1520 let metadata = UploadVideoMetadata::new(1920, 1080, 12);
1521 let video =
1522 UploadFileInput::video("clip.mp4", "clip.mp4", metadata).with_mime_type(" video/mp4 ");
1523
1524 assert_eq!(video.path, PathBuf::from("clip.mp4"));
1525 assert_eq!(video.file_name, "clip.mp4");
1526 assert_eq!(video.file_type, UploadFileType::Video);
1527 assert_eq!(video.mime_type.as_deref(), Some("video/mp4"));
1528 assert_eq!(video.video_metadata, Some(metadata));
1529
1530 let document = UploadFileInput::document("notes.txt", "notes.txt").with_mime_type(" ");
1531 assert_eq!(document.file_type, UploadFileType::Document);
1532 assert!(document.mime_type.is_none());
1533 assert!(document.video_metadata.is_none());
1534 }
1535
1536 #[test]
1537 fn upload_bytes_input_constructors_set_expected_fields() {
1538 let metadata = UploadVideoMetadata::new(1920, 1080, 12);
1539 let video = UploadFileBytesInput::video(vec![1, 2, 3], "clip.mp4", metadata)
1540 .with_mime_type(" video/mp4 ");
1541
1542 assert_eq!(video.bytes, vec![1, 2, 3]);
1543 assert_eq!(video.file_name, "clip.mp4");
1544 assert_eq!(video.file_type, UploadFileType::Video);
1545 assert_eq!(video.mime_type.as_deref(), Some("video/mp4"));
1546 assert_eq!(video.video_metadata, Some(metadata));
1547
1548 let document = UploadFileBytesInput::document(vec![1], "notes.txt").with_mime_type(" ");
1549 assert_eq!(document.file_type, UploadFileType::Document);
1550 assert!(document.mime_type.is_none());
1551 assert!(document.video_metadata.is_none());
1552 }
1553
1554 #[test]
1555 fn upload_input_debug_redacts_local_path() {
1556 let input = UploadFileInput::document("/home/alice/private/report.pdf", "report.pdf")
1557 .with_mime_type("application/pdf");
1558 let debug = format!("{input:?}");
1559
1560 assert!(debug.contains("report.pdf"));
1561 assert!(debug.contains("<redacted>"));
1562 assert!(!debug.contains("/home/alice/private"));
1563 }
1564
1565 #[test]
1566 fn upload_bytes_input_debug_redacts_contents() {
1567 let input = UploadFileBytesInput::document(vec![115, 101, 99, 114, 101, 116], "report.pdf")
1568 .with_mime_type("application/pdf");
1569 let debug = format!("{input:?}");
1570
1571 assert!(debug.contains("report.pdf"));
1572 assert!(debug.contains("bytes_len"));
1573 assert!(!debug.contains("secret"));
1574 }
1575
1576 #[test]
1577 fn upload_input_validation_rejects_invalid_video_metadata_shape() {
1578 let missing_metadata = UploadFileInput::new("clip.mp4", "clip.mp4", UploadFileType::Video);
1579 match validate_upload_file_input(&missing_metadata).unwrap_err() {
1580 ApiError::InvalidInput { message } => {
1581 assert_eq!(message, "video uploads require video metadata");
1582 }
1583 other => panic!("expected invalid input, got {other:?}"),
1584 }
1585
1586 let document_with_video = UploadFileInput::document("notes.txt", "notes.txt")
1587 .with_video_metadata(UploadVideoMetadata::new(1, 1, 1));
1588 match validate_upload_file_input(&document_with_video).unwrap_err() {
1589 ApiError::InvalidInput { message } => {
1590 assert_eq!(
1591 message,
1592 "video metadata can only be used with video uploads"
1593 );
1594 }
1595 other => panic!("expected invalid input, got {other:?}"),
1596 }
1597
1598 let bad_dimensions = UploadFileInput::video(
1599 "clip.mp4",
1600 "clip.mp4",
1601 UploadVideoMetadata::new(0, 1080, 12),
1602 );
1603 match validate_upload_file_input(&bad_dimensions).unwrap_err() {
1604 ApiError::InvalidInput { message } => {
1605 assert_eq!(
1606 message,
1607 "video metadata width, height, and duration must be positive"
1608 );
1609 }
1610 other => panic!("expected invalid input, got {other:?}"),
1611 }
1612 }
1613
1614 #[test]
1615 fn upload_input_validation_rejects_empty_file_name() {
1616 let input = UploadFileInput::document("notes.txt", " ");
1617 match validate_upload_file_input(&input).unwrap_err() {
1618 ApiError::InvalidInput { message } => {
1619 assert_eq!(message, "upload file name cannot be empty");
1620 }
1621 other => panic!("expected invalid input, got {other:?}"),
1622 }
1623 }
1624
1625 #[test]
1626 fn upload_bytes_input_validation_rejects_empty_bytes() {
1627 let input = UploadFileBytesInput::document(Vec::new(), "notes.txt");
1628 match validate_upload_file_bytes_input(&input).unwrap_err() {
1629 ApiError::InvalidInput { message } => {
1630 assert_eq!(message, "upload file bytes cannot be empty");
1631 }
1632 other => panic!("expected invalid input, got {other:?}"),
1633 }
1634 }
1635
1636 #[test]
1637 fn auth_and_token_validation_reject_blank_required_fields() {
1638 match validate_required_str("email", " ").unwrap_err() {
1639 ApiError::InvalidInput { message } => {
1640 assert_eq!(message, "email cannot be empty");
1641 }
1642 other => panic!("expected invalid input, got {other:?}"),
1643 }
1644
1645 match validate_bearer_token("").unwrap_err() {
1646 ApiError::InvalidInput { message } => {
1647 assert_eq!(message, "bearer token cannot be empty");
1648 }
1649 other => panic!("expected invalid input, got {other:?}"),
1650 }
1651
1652 match validate_auth_metadata(&AuthMetadata::sdk(" ")).unwrap_err() {
1653 ApiError::InvalidInput { message } => {
1654 assert_eq!(message, "device id cannot be empty");
1655 }
1656 other => panic!("expected invalid input, got {other:?}"),
1657 }
1658 }
1659
1660 #[test]
1661 fn auth_result_debug_redacts_tokens() {
1662 let send_code = SendCodeResult {
1663 existing_user: true,
1664 needs_invite_code: false,
1665 challenge_token: Some("challenge-secret".to_string()),
1666 };
1667 let verify_code = VerifyCodeResult {
1668 user_id: 42,
1669 token: "bearer-secret".to_string(),
1670 };
1671
1672 let send_debug = format!("{send_code:?}");
1673 let verify_debug = format!("{verify_code:?}");
1674
1675 assert!(send_debug.contains("<redacted>"));
1676 assert!(!send_debug.contains("challenge-secret"));
1677 assert!(verify_debug.contains("<redacted>"));
1678 assert!(!verify_debug.contains("bearer-secret"));
1679 }
1680
1681 #[test]
1682 fn api_input_validation_rejects_non_positive_ids() {
1683 match validate_peer_id(PeerId::thread(0)).unwrap_err() {
1684 ApiError::InvalidInput { message } => {
1685 assert_eq!(message, "peer thread id must be positive");
1686 }
1687 other => panic!("expected invalid input, got {other:?}"),
1688 }
1689
1690 let linear = CreateLinearIssueInput::new("ship it", 0, 20, 30, PeerId::thread(20));
1691 match validate_create_linear_issue_input(&linear).unwrap_err() {
1692 ApiError::InvalidInput { message } => {
1693 assert_eq!(message, "message id must be positive");
1694 }
1695 other => panic!("expected invalid input, got {other:?}"),
1696 }
1697
1698 let notion = CreateNotionTaskInput::new(1, 2, 0, PeerId::thread(3));
1699 match validate_create_notion_task_input(¬ion).unwrap_err() {
1700 ApiError::InvalidInput { message } => {
1701 assert_eq!(message, "chat id must be positive");
1702 }
1703 other => panic!("expected invalid input, got {other:?}"),
1704 }
1705 }
1706
1707 #[test]
1708 fn peer_id_helpers_encode_user_and_thread_peers() {
1709 assert_eq!(PeerId::user(42).user_id(), Some(42));
1710 assert_eq!(PeerId::user(42).thread_id(), None);
1711 assert_eq!(PeerId::thread(99).thread_id(), Some(99));
1712 assert_eq!(PeerId::thread(99).user_id(), None);
1713
1714 let mut payload = serde_json::Map::new();
1715 add_peer_selector_fields(&mut payload, PeerId::user(42));
1716 assert_eq!(payload.get("peerUserId"), Some(&json!(42)));
1717 assert!(payload.get("peerThreadId").is_none());
1718
1719 let peer_id = peer_id_object(PeerId::thread(99));
1720 assert_eq!(peer_id.get("threadId"), Some(&json!(99)));
1721 assert!(peer_id.get("userId").is_none());
1722 }
1723
1724 #[test]
1725 fn api_input_constructors_keep_required_fields_explicit() {
1726 let read = ReadMessagesInput::new(PeerId::user(42)).with_max_id(99);
1727 assert_eq!(read.peer, PeerId::user(42));
1728 assert_eq!(read.max_id, Some(99));
1729
1730 let linear = CreateLinearIssueInput::new("ship it", 10, 20, 30, PeerId::thread(20))
1731 .with_space_id(40);
1732 assert_eq!(linear.text, "ship it");
1733 assert_eq!(linear.message_id, 10);
1734 assert_eq!(linear.chat_id, 20);
1735 assert_eq!(linear.from_id, 30);
1736 assert_eq!(linear.peer, PeerId::thread(20));
1737 assert_eq!(linear.space_id, Some(40));
1738
1739 let notion = CreateNotionTaskInput::new(1, 2, 3, PeerId::thread(3));
1740 assert_eq!(notion.space_id, 1);
1741 assert_eq!(notion.message_id, 2);
1742 assert_eq!(notion.chat_id, 3);
1743 assert_eq!(notion.peer, PeerId::thread(3));
1744 }
1745
1746 #[test]
1747 fn decodes_success_envelope() {
1748 let result: TestResult =
1749 decode_api_response_text(StatusCode::OK, r#"{"ok":true,"result":{"value":"done"}}"#)
1750 .unwrap();
1751
1752 assert_eq!(
1753 result,
1754 TestResult {
1755 value: "done".to_string()
1756 }
1757 );
1758 }
1759
1760 #[test]
1761 fn preserves_json_api_error_fields() {
1762 let err = decode_api_response_text::<TestResult>(
1763 StatusCode::BAD_REQUEST,
1764 r#"{"ok":false,"error":"INVALID_CODE","error_code":123,"description":"Code is invalid"}"#,
1765 )
1766 .unwrap_err();
1767
1768 match err {
1769 ApiError::Api {
1770 status,
1771 error,
1772 error_code,
1773 description,
1774 } => {
1775 assert_eq!(status, Some(400));
1776 assert_eq!(error, "INVALID_CODE");
1777 assert_eq!(error_code, Some(123));
1778 assert_eq!(description, "Code is invalid");
1779 }
1780 other => panic!("expected api error, got {other:?}"),
1781 }
1782 }
1783
1784 #[test]
1785 fn preserves_nonstandard_ok_false_message() {
1786 let err = decode_api_response_text::<TestResult>(
1787 StatusCode::OK,
1788 r#"{"ok":false,"message":"Not enough permissions"}"#,
1789 )
1790 .unwrap_err();
1791
1792 match err {
1793 ApiError::Api {
1794 status,
1795 error,
1796 error_code,
1797 description,
1798 } => {
1799 assert_eq!(status, Some(200));
1800 assert_eq!(error, "API_ERROR");
1801 assert_eq!(error_code, None);
1802 assert_eq!(description, "Not enough permissions");
1803 }
1804 other => panic!("expected api error, got {other:?}"),
1805 }
1806 }
1807
1808 #[test]
1809 fn preserves_non_json_http_body_preview() {
1810 let err = decode_api_response_text::<TestResult>(
1811 StatusCode::INTERNAL_SERVER_ERROR,
1812 "upstream failed\nwith details",
1813 )
1814 .unwrap_err();
1815
1816 match err {
1817 ApiError::Status {
1818 status,
1819 message,
1820 body,
1821 } => {
1822 assert_eq!(status, 500);
1823 assert_eq!(message, "Internal Server Error");
1824 assert_eq!(body.as_deref(), Some("upstream failed with details"));
1825 }
1826 other => panic!("expected status error, got {other:?}"),
1827 }
1828 }
1829
1830 #[test]
1831 fn auth_metadata_includes_client_identity() {
1832 let mut payload = serde_json::Map::new();
1833 let identity = ClientIdentity::new("cli", "1.2.3");
1834 add_auth_metadata(
1835 &mut payload,
1836 &AuthMetadata::new("device-1", identity).with_device_name("mo-mac"),
1837 );
1838
1839 assert_eq!(payload.get("deviceId"), Some(&json!("device-1")));
1840 assert_eq!(payload.get("clientType"), Some(&json!("cli")));
1841 assert_eq!(payload.get("clientVersion"), Some(&json!("1.2.3")));
1842 assert_eq!(payload.get("deviceName"), Some(&json!("mo-mac")));
1843 }
1844
1845 #[test]
1846 fn auth_metadata_accepts_non_cli_client_identity() {
1847 let mut payload = serde_json::Map::new();
1848 add_auth_metadata(
1849 &mut payload,
1850 &AuthMetadata::new("device-1", ClientIdentity::new("my-agent", "0.1.0")),
1851 );
1852
1853 assert_eq!(payload.get("deviceId"), Some(&json!("device-1")));
1854 assert_eq!(payload.get("clientType"), Some(&json!("my-agent")));
1855 assert_eq!(payload.get("clientVersion"), Some(&json!("0.1.0")));
1856 assert!(payload.get("deviceName").is_none());
1857 }
1858}