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