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