Skip to main content

dtrpg_sdk/library/
client.rs

1//! Async HTTP client for DriveThruRPG library endpoints.
2//!
3//! [`LibraryClient`] provides an async, authenticated interface to the DriveThruRPG API's
4//! library-related endpoints, covering ordered products, download preparation, product
5//! lists, and product list items.
6//!
7//! All methods require a valid bearer token and application key, both of which are
8//! captured when the client is constructed. Create a `LibraryClient` via
9//! [`DriveThruRpgSdk::library_client`] to ensure the SDK is both configured and
10//! authenticated before the client is used.
11//!
12//! [`DriveThruRpgSdk::library_client`]: crate::DriveThruRpgSdk::library_client
13
14use super::models::{
15    LibraryItemsParams, OrderProductItemResponse, OrderProductListResponse, PageParams,
16    ProductListCollectionResponse, ProductListItem, ProductListItemCreateRequest,
17    ProductListItemCreateResponse, ProductListItemsResponse,
18};
19use crate::{config::Config, error::SdkError};
20
21/// Maximum number of bytes logged from a failing response body.
22const LOG_PAYLOAD_LIMIT: usize = 2_000;
23
24// ── Error type ────────────────────────────────────────────────────────────────
25
26/// Errors that can be returned by [`LibraryClient`] operations.
27#[derive(Debug)]
28pub enum ClientError {
29    /// The SDK is not configured or not authenticated.
30    ///
31    /// This variant is produced when [`DriveThruRpgSdk::library_client`] is called
32    /// before the SDK has been configured or before a session has been established.
33    ///
34    /// [`DriveThruRpgSdk::library_client`]: crate::DriveThruRpgSdk::library_client
35    Sdk(SdkError),
36    /// An HTTP transport or server error occurred.
37    ///
38    /// This wraps the underlying [`reqwest::Error`] including connection failures,
39    /// timeout errors, and non-success HTTP status codes when `.error_for_status()` is
40    /// used.
41    Http(reqwest::Error),
42    /// The provided email or password was rejected by DriveThruRPG.
43    ///
44    /// Returned by [`credential_login::login_with_credentials`] when
45    /// `validate_login_credentials.php` indicates the credentials are invalid.
46    ///
47    /// [`credential_login::login_with_credentials`]: crate::auth::credential_login::login_with_credentials
48    InvalidCredentials,
49    /// Credentials were accepted but the application key request failed.
50    ///
51    /// Returned by [`credential_login::login_with_credentials`] when credentials
52    /// pass validation but `create_account_app.php` returns a non-success status.
53    ///
54    /// [`credential_login::login_with_credentials`]: crate::auth::credential_login::login_with_credentials
55    ApplicationKeyRequestFailed {
56        /// The status string returned by `create_account_app.php`.
57        status: String,
58    },
59    /// The HTTP response indicated a successful status but the body could not be
60    /// deserialized into the expected type.
61    ///
62    /// The raw response body (truncated to [`LOG_PAYLOAD_LIMIT`] bytes) is preserved so
63    /// callers can log the offending payload for diagnosis.
64    DecodeFailed {
65        /// The URL that was requested.
66        url: String,
67        /// The HTTP status code of the response.
68        status: u16,
69        /// The deserialization error.
70        cause: serde_json::Error,
71        /// Raw response body, UTF-8 lossy, truncated to [`LOG_PAYLOAD_LIMIT`] chars.
72        payload: String,
73    },
74    /// The API returned a non-success status. `message`, when present, is a
75    /// human-readable explanation extracted from the response body (either a
76    /// top-level `message` field or field-keyed validation errors, e.g.
77    /// `{"productId": "Requires a valid Product ID. Invalid value 22654728."}`).
78    ///
79    /// The raw response body (truncated to [`LOG_PAYLOAD_LIMIT`] bytes) is preserved so
80    /// callers can log the offending payload when no `message` could be extracted.
81    ApiError {
82        /// The URL that was requested.
83        url: String,
84        /// The HTTP status code of the response.
85        status: u16,
86        /// A human-readable message extracted from the response body, if any.
87        message: Option<String>,
88        /// Raw response body, UTF-8 lossy, truncated to [`LOG_PAYLOAD_LIMIT`] chars.
89        payload: String,
90        /// The delay specified by the response's `Retry-After` header, if present
91        /// and parseable as a delay-seconds value (RFC 9110 §10.2.3).
92        retry_after: Option<std::time::Duration>,
93    },
94}
95
96impl From<SdkError> for ClientError {
97    fn from(err: SdkError) -> Self {
98        ClientError::Sdk(err)
99    }
100}
101
102impl From<reqwest::Error> for ClientError {
103    fn from(err: reqwest::Error) -> Self {
104        ClientError::Http(err)
105    }
106}
107
108impl core::fmt::Display for ClientError {
109    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
110        match self {
111            Self::Sdk(err) => write!(f, "SDK error: {err}"),
112            Self::Http(err) => write!(f, "HTTP error: {err}"),
113            Self::InvalidCredentials => write!(f, "invalid credentials"),
114            Self::ApplicationKeyRequestFailed { status } => {
115                write!(f, "application key request failed (status: {status})")
116            }
117            Self::DecodeFailed {
118                url, status, cause, ..
119            } => {
120                write!(f, "response decode failed [{url}] (HTTP {status}): {cause}")
121            }
122            Self::ApiError {
123                url,
124                status,
125                message,
126                payload,
127                ..
128            } => {
129                let detail = message.as_deref().unwrap_or(payload.as_str());
130                write!(f, "API request failed [{url}] (HTTP {status}): {detail}")
131            }
132        }
133    }
134}
135
136impl std::error::Error for ClientError {
137    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
138        match self {
139            Self::Sdk(err) => Some(err),
140            Self::Http(err) => Some(err),
141            Self::InvalidCredentials | Self::ApplicationKeyRequestFailed { .. } => None,
142            Self::DecodeFailed { cause, .. } => Some(cause),
143            Self::ApiError { .. } => None,
144        }
145    }
146}
147
148/// Extracts a human-readable error message from a non-success JSON response body.
149///
150/// Recognizes three shapes seen across DriveThruRPG API error responses: a
151/// top-level `message` string (e.g. [`AuthSessionError`]-style payloads); a
152/// nested `{"error": {"message": "..."}}` object (e.g. `product_list_items`
153/// failures); or a flat object keyed by field name whose values are a
154/// validation message string or an array of message strings (e.g.
155/// `{"productId": "Requires a valid Product ID. Invalid value 22654728."}`).
156/// Returns `None` if the body isn't JSON or matches none of these shapes, so
157/// the caller falls back to the raw payload.
158///
159/// [`AuthSessionError`]: https://github.com/pilgrimagesoftware/dtrpg-api
160fn extract_error_message(bytes: &[u8]) -> Option<String> {
161    let value: serde_json::Value = serde_json::from_slice(bytes).ok()?;
162    let obj = value.as_object()?;
163
164    if let Some(message) = obj.get("message").and_then(serde_json::Value::as_str) {
165        return Some(message.to_string());
166    }
167
168    // `{"error": {"message": "...", "code": ..., "status": ...}}` — the shape used by
169    // e.g. `product_list_items` failures.
170    if let Some(message) = obj
171        .get("error")
172        .and_then(serde_json::Value::as_object)
173        .and_then(|error| error.get("message"))
174        .and_then(serde_json::Value::as_str)
175    {
176        return Some(message.to_string());
177    }
178
179    let mut parts = Vec::new();
180    for (field, detail) in obj {
181        match detail {
182            serde_json::Value::String(message) => parts.push(format!("{field}: {message}")),
183            serde_json::Value::Array(messages) => {
184                for message in messages.iter().filter_map(serde_json::Value::as_str) {
185                    parts.push(format!("{field}: {message}"));
186                }
187            }
188            _ => {}
189        }
190    }
191
192    (!parts.is_empty()).then(|| parts.join("; "))
193}
194
195// ── LibraryClient ─────────────────────────────────────────────────────────────
196
197/// An authenticated async HTTP client for DriveThruRPG library endpoints.
198///
199/// `LibraryClient` combines SDK configuration and an active bearer token to authenticate
200/// all outgoing requests. Every method maps to a specific API endpoint and returns a
201/// fully deserialized Rust type.
202///
203/// # Creating a Client
204///
205/// Use [`DriveThruRpgSdk::library_client`] to obtain a client that is guaranteed to have
206/// both valid configuration and an active session:
207///
208/// ```rust,no_run
209/// # use dtrpg_sdk::{Config, DriveThruRpgSdk, AuthTokenResponse};
210/// # let mut sdk = DriveThruRpgSdk::with_config(Config::new("my-app-key"));
211/// # sdk.apply_auth_response(AuthTokenResponse::new("token", "refresh", 9_999_999_999)).unwrap();
212/// let client = sdk.library_client().unwrap();
213/// ```
214///
215/// [`DriveThruRpgSdk::library_client`]: crate::DriveThruRpgSdk::library_client
216pub struct LibraryClient {
217    http: reqwest::Client,
218    config: Config,
219    token: String,
220}
221
222impl LibraryClient {
223    /// Creates a new `LibraryClient` from the given configuration and bearer token.
224    ///
225    /// Prefer [`DriveThruRpgSdk::library_client`] over calling this constructor directly,
226    /// as that method validates both configuration and session state before constructing
227    /// the client.
228    ///
229    /// [`DriveThruRpgSdk::library_client`]: crate::DriveThruRpgSdk::library_client
230    pub fn new(config: Config, token: String) -> Self {
231        Self {
232            http: reqwest::Client::new(),
233            config,
234            token,
235        }
236    }
237
238    /// Builds the full URL for a versioned API path segment.
239    ///
240    /// Combines the configured base URL, API version, and the given resource path
241    /// into a single URL string: `{base_url}/{api_version}/{path}`.
242    fn endpoint(&self, path: &str) -> String {
243        format!(
244            "{}/{}/{}",
245            self.config.base_url(),
246            self.config.api_version(),
247            path
248        )
249    }
250
251    /// Returns the `Authorization` header value for the active session.
252    ///
253    /// The DTRPG API expects the raw JWT token without a `Bearer` prefix.
254    fn auth_header(&self) -> &str {
255        &self.token
256    }
257
258    /// Reads a response body and deserializes it as `T`.
259    ///
260    /// A non-success status is treated as a request failure rather than a decode
261    /// attempt: the body is never deserialized as `T` in that case (`T` describes the
262    /// success schema, so trying to parse an error body against it produces a
263    /// confusing "missing field" decode error instead of the API's actual message).
264    /// Instead a human-readable message is extracted from the body — a top-level
265    /// `message` field, or field-keyed validation errors such as
266    /// `{"productId": "Requires a valid Product ID. Invalid value 22654728."}` — and
267    /// returned via [`ClientError::ApiError`].
268    ///
269    /// On a success status whose body still fails to deserialize as `T`, the raw
270    /// payload is logged at ERROR level (truncated to [`LOG_PAYLOAD_LIMIT`] bytes) and
271    /// a [`ClientError::DecodeFailed`] is returned so callers have both the serde
272    /// cause and the offending payload for diagnosis.
273    async fn decode_response<T: serde::de::DeserializeOwned>(
274        &self,
275        url: &str,
276        response: reqwest::Response,
277    ) -> Result<T, ClientError> {
278        let status = response.status();
279        let status_code = status.as_u16();
280        let retry_after = response
281            .headers()
282            .get(reqwest::header::RETRY_AFTER)
283            .and_then(|value| value.to_str().ok())
284            .and_then(|value| value.trim().parse::<u64>().ok())
285            .map(std::time::Duration::from_secs);
286        let bytes = response.bytes().await.map_err(ClientError::Http)?;
287
288        let truncated_payload = || -> String {
289            let raw = String::from_utf8_lossy(&bytes);
290            if raw.len() > LOG_PAYLOAD_LIMIT {
291                format!("{}… (truncated)", &raw[..LOG_PAYLOAD_LIMIT])
292            } else {
293                raw.into_owned()
294            }
295        };
296
297        if !status.is_success() {
298            let payload = truncated_payload();
299            let message = extract_error_message(&bytes);
300            tracing::error!(
301                url = %url,
302                status = status_code,
303                payload = %payload,
304                message = message.as_deref().unwrap_or(""),
305                "API request failed"
306            );
307            return Err(ClientError::ApiError {
308                url: url.to_string(),
309                status: status_code,
310                message,
311                payload,
312                retry_after,
313            });
314        }
315
316        serde_json::from_slice::<T>(&bytes).map_err(|cause| {
317            let payload = truncated_payload();
318            tracing::error!(
319                url = %url,
320                status = status_code,
321                payload = %payload,
322                error = %cause,
323                "API response decode failed"
324            );
325            ClientError::DecodeFailed {
326                url: url.to_string(),
327                status: status_code,
328                cause,
329                payload,
330            }
331        })
332    }
333
334    // ── Ordered Products ──────────────────────────────────────────────────────
335
336    /// Fetches a paginated list of ordered products from the authenticated user's library.
337    ///
338    /// Maps to `GET /{api_version}/order_products`.
339    ///
340    /// Authentication is supplied via the `Authorization` header containing the raw JWT token.
341    /// All fields of [`LibraryItemsParams`] that are `Some` are included as query parameters.
342    ///
343    /// # Errors
344    ///
345    /// Returns [`ClientError::Http`] on any transport or deserialization failure.
346    ///
347    /// # Examples
348    ///
349    /// ```rust,no_run
350    /// # use dtrpg_sdk::{Config, DriveThruRpgSdk, AuthTokenResponse, LibraryItemsParams};
351    /// # async fn run() -> Result<(), dtrpg_sdk::ClientError> {
352    /// # let mut sdk = DriveThruRpgSdk::with_config(Config::new("app-key"));
353    /// # sdk.apply_auth_response(AuthTokenResponse::new("t", "r", 9_999_999_999)).unwrap();
354    /// let client = sdk.library_client().unwrap();
355    /// let params = LibraryItemsParams {
356    ///     page: Some(1),
357    ///     page_size: Some(25),
358    ///     ..Default::default()
359    /// };
360    /// let products = client.list_order_products(params).await?;
361    /// # Ok(())
362    /// # }
363    /// ```
364    pub async fn list_order_products(
365        &self,
366        params: LibraryItemsParams,
367    ) -> Result<OrderProductListResponse, ClientError> {
368        let url = self.endpoint("order_products");
369
370        let mut query: Vec<(&str, String)> = Vec::new();
371
372        if let Some(page) = params.page {
373            query.push(("page", page.to_string()));
374        }
375        if let Some(page_size) = params.page_size {
376            query.push(("pageSize", page_size.to_string()));
377        }
378        if params.get_checksum == Some(true) {
379            query.push(("getChecksum", "1".to_string()));
380        }
381        if params.get_filters == Some(true) {
382            query.push(("getFilters", "1".to_string()));
383        }
384        if params.library == Some(true) {
385            query.push(("library", "true".to_string()));
386        }
387        if let Some(archived) = params.archived {
388            query.push(("archived", (if archived { "1" } else { "0" }).to_string()));
389        }
390        if let Some(date) = params.updated_date_after {
391            query.push(("updatedDate[after]", date));
392        }
393
394        tracing::debug!(method = "GET", url = %url, "SDK request");
395        let response = self
396            .http
397            .get(&url)
398            .query(&query)
399            .header("Authorization", self.auth_header())
400            .send()
401            .await?;
402        tracing::debug!(url = %url, status = response.status().as_u16(), "SDK response");
403
404        self.decode_response(&url, response).await
405    }
406
407    /// Fetches the details of a single ordered product by its identifier.
408    ///
409    /// Maps to `GET /{api_version}/order_products/{order_product_id}`.
410    ///
411    /// Authentication is supplied via the `Authorization` header containing the raw JWT token.
412    ///
413    /// # Errors
414    ///
415    /// Returns [`ClientError::Http`] on any transport or deserialization failure.
416    ///
417    /// # Examples
418    ///
419    /// ```rust,no_run
420    /// # use dtrpg_sdk::{Config, DriveThruRpgSdk, AuthTokenResponse};
421    /// # async fn run() -> Result<(), dtrpg_sdk::ClientError> {
422    /// # let mut sdk = DriveThruRpgSdk::with_config(Config::new("app-key"));
423    /// # sdk.apply_auth_response(AuthTokenResponse::new("t", "r", 9_999_999_999)).unwrap();
424    /// let client = sdk.library_client().unwrap();
425    /// let product = client.get_order_product(515_276).await?;
426    /// # Ok(())
427    /// # }
428    /// ```
429    pub async fn get_order_product(
430        &self,
431        order_product_id: u64,
432    ) -> Result<OrderProductItemResponse, ClientError> {
433        let url = self.endpoint(&format!("order_products/{order_product_id}"));
434
435        tracing::debug!(method = "GET", url = %url, "SDK request");
436        let response = self
437            .http
438            .get(&url)
439            .header("Authorization", self.auth_header())
440            .send()
441            .await?;
442        tracing::debug!(url = %url, status = response.status().as_u16(), "SDK response");
443
444        self.decode_response(&url, response).await
445    }
446
447    /// Prepares a download for the given ordered product's file and returns the raw API
448    /// response.
449    ///
450    /// Maps to `GET /{api_version}/order_products/{order_product_id}/prepare?index={index}`.
451    /// `index` identifies which file within the ordered product to prepare — it matches
452    /// [`OrderProductFile::index`](crate::library::OrderProductFile) — and is required: the
453    /// API rejects the request with an error if it is omitted.
454    ///
455    /// The response is returned as a [`serde_json::Value`] because the response schema for
456    /// this endpoint has not yet been formally defined by the API contract. The type will be
457    /// tightened in a future change once the API contract matures.
458    ///
459    /// Authentication is supplied via the `Authorization` header containing the raw JWT token.
460    ///
461    /// # Errors
462    ///
463    /// Returns [`ClientError::Http`] on any transport or deserialization failure.
464    ///
465    /// # Examples
466    ///
467    /// ```rust,no_run
468    /// # use dtrpg_sdk::{Config, DriveThruRpgSdk, AuthTokenResponse};
469    /// # async fn run() -> Result<(), dtrpg_sdk::ClientError> {
470    /// # let mut sdk = DriveThruRpgSdk::with_config(Config::new("app-key"));
471    /// # sdk.apply_auth_response(AuthTokenResponse::new("t", "r", 9_999_999_999)).unwrap();
472    /// let client = sdk.library_client().unwrap();
473    /// let download = client.prepare_download(515_276, 0).await?;
474    /// # Ok(())
475    /// # }
476    /// ```
477    pub async fn prepare_download(
478        &self,
479        order_product_id: u64,
480        index: u32,
481    ) -> Result<serde_json::Value, ClientError> {
482        let url = self.endpoint(&format!("order_products/{order_product_id}/prepare"));
483
484        tracing::debug!(method = "GET", url = %url, index, "SDK request");
485        let response = self
486            .http
487            .get(&url)
488            .query(&[("index", index.to_string())])
489            .header("Authorization", self.auth_header())
490            .send()
491            .await?;
492        tracing::debug!(url = %url, status = response.status().as_u16(), "SDK response");
493
494        self.decode_response(&url, response).await
495    }
496
497    // ── Product Lists ─────────────────────────────────────────────────────────
498
499    /// Fetches a paginated list of product lists belonging to the authenticated user.
500    ///
501    /// Maps to `GET /{api_version}/product_lists`.
502    ///
503    /// Authentication is supplied via the `Authorization` header containing the raw JWT token.
504    /// Pagination is controlled via [`PageParams`].
505    ///
506    /// # Errors
507    ///
508    /// Returns [`ClientError::Http`] on any transport or deserialization failure.
509    ///
510    /// # Examples
511    ///
512    /// ```rust,no_run
513    /// # use dtrpg_sdk::{Config, DriveThruRpgSdk, AuthTokenResponse, PageParams};
514    /// # async fn run() -> Result<(), dtrpg_sdk::ClientError> {
515    /// # let mut sdk = DriveThruRpgSdk::with_config(Config::new("app-key"));
516    /// # sdk.apply_auth_response(AuthTokenResponse::new("t", "r", 9_999_999_999)).unwrap();
517    /// let client = sdk.library_client().unwrap();
518    /// let lists = client.list_product_lists(PageParams::default()).await?;
519    /// # Ok(())
520    /// # }
521    /// ```
522    pub async fn list_product_lists(
523        &self,
524        params: PageParams,
525    ) -> Result<ProductListCollectionResponse, ClientError> {
526        let url = self.endpoint("product_lists");
527
528        let mut query: Vec<(&str, String)> = Vec::new();
529
530        if let Some(page) = params.page {
531            query.push(("page", page.to_string()));
532        }
533        if let Some(page_size) = params.page_size {
534            query.push(("pageSize", page_size.to_string()));
535        }
536
537        tracing::debug!(method = "GET", url = %url, "SDK request");
538        let response = self
539            .http
540            .get(&url)
541            .query(&query)
542            .header("Authorization", self.auth_header())
543            .send()
544            .await?;
545        tracing::debug!(url = %url, status = response.status().as_u16(), "SDK response");
546
547        self.decode_response(&url, response).await
548    }
549
550    /// Fetches a paginated list of items within a specific product list.
551    ///
552    /// Maps to `GET /{api_version}/product_list_items?productListId={product_list_id}`.
553    ///
554    /// Authentication is supplied via the `Authorization` header containing the raw JWT token.
555    /// Pagination is controlled via [`PageParams`].
556    ///
557    /// # Errors
558    ///
559    /// Returns [`ClientError::Http`] on any transport or deserialization failure.
560    ///
561    /// # Examples
562    ///
563    /// ```rust,no_run
564    /// # use dtrpg_sdk::{Config, DriveThruRpgSdk, AuthTokenResponse, PageParams};
565    /// # async fn run() -> Result<(), dtrpg_sdk::ClientError> {
566    /// # let mut sdk = DriveThruRpgSdk::with_config(Config::new("app-key"));
567    /// # sdk.apply_auth_response(AuthTokenResponse::new("t", "r", 9_999_999_999)).unwrap();
568    /// let client = sdk.library_client().unwrap();
569    /// let items = client.list_product_list_items(86_151, PageParams::default()).await?;
570    /// # Ok(())
571    /// # }
572    /// ```
573    pub async fn list_product_list_items(
574        &self,
575        product_list_id: u64,
576        params: PageParams,
577    ) -> Result<ProductListItemsResponse, ClientError> {
578        let url = self.endpoint("product_list_items");
579
580        let mut query: Vec<(&str, String)> = vec![("productListId", product_list_id.to_string())];
581
582        if let Some(page) = params.page {
583            query.push(("page", page.to_string()));
584        }
585        if let Some(page_size) = params.page_size {
586            query.push(("pageSize", page_size.to_string()));
587        }
588
589        tracing::debug!(method = "GET", url = %url, "SDK request");
590        let response = self
591            .http
592            .get(&url)
593            .query(&query)
594            .header("Authorization", self.auth_header())
595            .send()
596            .await?;
597        tracing::debug!(url = %url, status = response.status().as_u16(), "SDK response");
598
599        self.decode_response(&url, response).await
600    }
601
602    /// Creates a new product list with the given name.
603    ///
604    /// Maps to `POST /{api_version}/product_lists` with a JSON body `{"name": "<name>"}`.
605    ///
606    /// # Errors
607    ///
608    /// Returns [`ClientError::Http`] on transport failure or [`ClientError::DecodeFailed`]
609    /// if the response cannot be deserialized.
610    ///
611    /// # Examples
612    ///
613    /// ```rust,no_run
614    /// # use dtrpg_sdk::{Config, DriveThruRpgSdk, AuthTokenResponse};
615    /// # async fn run() -> Result<(), dtrpg_sdk::ClientError> {
616    /// # let mut sdk = DriveThruRpgSdk::with_config(Config::new("app-key"));
617    /// # sdk.apply_auth_response(AuthTokenResponse::new("t", "r", 9_999_999_999)).unwrap();
618    /// let client = sdk.library_client().unwrap();
619    /// let list = client.create_product_list("Wishlist").await?;
620    /// # Ok(())
621    /// # }
622    /// ```
623    pub async fn create_product_list(&self, name: &str) -> Result<ProductListItem, ClientError> {
624        #[derive(serde::Deserialize)]
625        struct ProductListEnvelope {
626            data: ProductListItem,
627        }
628
629        let url = self.endpoint("product_lists");
630
631        tracing::debug!(method = "POST", url = %url, "SDK request");
632        let response = self
633            .http
634            .post(&url)
635            .header("Authorization", self.auth_header())
636            .json(&serde_json::json!({ "name": name }))
637            .send()
638            .await?;
639        tracing::debug!(url = %url, status = response.status().as_u16(), "SDK response");
640
641        let envelope: ProductListEnvelope = self.decode_response(&url, response).await?;
642        Ok(envelope.data)
643    }
644
645    /// Deletes a product list by id.
646    ///
647    /// # Errors
648    ///
649    /// Returns [`ClientError`] if the request fails.
650    ///
651    /// # Examples
652    ///
653    /// ```rust,no_run
654    /// # use dtrpg_sdk::{Config, DriveThruRpgSdk, AuthTokenResponse};
655    /// # async fn run() -> Result<(), dtrpg_sdk::ClientError> {
656    /// # let mut sdk = DriveThruRpgSdk::with_config(Config::new("app-key"));
657    /// # sdk.apply_auth_response(AuthTokenResponse::new("t", "r", 9_999_999_999)).unwrap();
658    /// let client = sdk.library_client().unwrap();
659    /// client.delete_product_list(86_151).await?;
660    /// # Ok(())
661    /// # }
662    /// ```
663    pub async fn delete_product_list(&self, id: u64) -> Result<(), ClientError> {
664        let url = self.endpoint(&format!("product_lists/{id}"));
665
666        tracing::debug!(method = "DELETE", url = %url, "SDK request");
667        let response = self
668            .http
669            .delete(&url)
670            .header("Authorization", self.auth_header())
671            .send()
672            .await?;
673        tracing::debug!(url = %url, status = response.status().as_u16(), "SDK response");
674
675        response
676            .error_for_status()
677            .map(|_| ())
678            .map_err(ClientError::Http)
679    }
680
681    /// Adds a product to a product list as a member.
682    ///
683    /// Maps to `POST /{api_version}/product_list_items`.
684    ///
685    /// # Errors
686    ///
687    /// Returns [`ClientError::Http`] on transport failure or [`ClientError::DecodeFailed`]
688    /// if the response cannot be deserialized.
689    ///
690    /// # Examples
691    ///
692    /// ```rust,no_run
693    /// # use dtrpg_sdk::{Config, DriveThruRpgSdk, AuthTokenResponse};
694    /// # async fn run() -> Result<(), dtrpg_sdk::ClientError> {
695    /// # let mut sdk = DriveThruRpgSdk::with_config(Config::new("app-key"));
696    /// # sdk.apply_auth_response(AuthTokenResponse::new("t", "r", 9_999_999_999)).unwrap();
697    /// let client = sdk.library_client().unwrap();
698    /// let item = client.add_product_list_item(86_151, 515_276).await?;
699    /// # Ok(())
700    /// # }
701    /// ```
702    pub async fn add_product_list_item(
703        &self,
704        product_list_id: u64,
705        product_id: u64,
706    ) -> Result<ProductListItemCreateResponse, ClientError> {
707        let url = self.endpoint("product_list_items");
708        let body = ProductListItemCreateRequest {
709            product_id,
710            product_list_id,
711        };
712
713        tracing::debug!(method = "POST", url = %url, "SDK request");
714        let response = self
715            .http
716            .post(&url)
717            .header("Authorization", self.auth_header())
718            .json(&body)
719            .send()
720            .await?;
721        tracing::debug!(url = %url, status = response.status().as_u16(), "SDK response");
722
723        self.decode_response(&url, response).await
724    }
725
726    /// Removes a product list item by its own id (not the product's id).
727    ///
728    /// Maps to `DELETE /{api_version}/product_list_items/{product_list_item_id}`.
729    ///
730    /// # Errors
731    ///
732    /// Returns [`ClientError`] if the request fails.
733    ///
734    /// # Examples
735    ///
736    /// ```rust,no_run
737    /// # use dtrpg_sdk::{Config, DriveThruRpgSdk, AuthTokenResponse};
738    /// # async fn run() -> Result<(), dtrpg_sdk::ClientError> {
739    /// # let mut sdk = DriveThruRpgSdk::with_config(Config::new("app-key"));
740    /// # sdk.apply_auth_response(AuthTokenResponse::new("t", "r", 9_999_999_999)).unwrap();
741    /// let client = sdk.library_client().unwrap();
742    /// client.delete_product_list_item(2_629_321).await?;
743    /// # Ok(())
744    /// # }
745    /// ```
746    pub async fn delete_product_list_item(
747        &self,
748        product_list_item_id: u64,
749    ) -> Result<(), ClientError> {
750        let url = self.endpoint(&format!("product_list_items/{product_list_item_id}"));
751
752        tracing::debug!(method = "DELETE", url = %url, "SDK request");
753        let response = self
754            .http
755            .delete(&url)
756            .header("Authorization", self.auth_header())
757            .send()
758            .await?;
759        tracing::debug!(url = %url, status = response.status().as_u16(), "SDK response");
760
761        response
762            .error_for_status()
763            .map(|_| ())
764            .map_err(ClientError::Http)
765    }
766}
767
768// ── Tests ─────────────────────────────────────────────────────────────────────
769
770#[cfg(test)]
771mod tests {
772    use wiremock::matchers::{body_json, method, path};
773    use wiremock::{Mock, MockServer, ResponseTemplate};
774
775    use super::*;
776
777    fn client_for(server: &MockServer) -> LibraryClient {
778        let config = Config::with_base_url("test-app-key", server.uri());
779        LibraryClient::new(config, "test-token".to_string())
780    }
781
782    #[tokio::test]
783    async fn get_order_product_decodes_sideloaded_included_array() {
784        // Matches the live API's actual shape: the single-item detail endpoint
785        // sideloads Publisher/Product/Order resources under a top-level
786        // `included` array, exactly like the list endpoint.
787        let server = MockServer::start().await;
788
789        Mock::given(method("GET"))
790            .and(path("/vBeta/order_products/22654728"))
791            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
792                "data": {
793                    "id": "/api/vBeta/order_products/22654728",
794                    "type": "order_product",
795                    "attributes": {
796                        "orderId": 7_332_333,
797                        "productId": 144_239,
798                        "royaltyPublisherId": 117,
799                        "name": "Common Places - Free Map #1",
800                        "finalPrice": 0.0,
801                        "quantity": 1,
802                        "bundleId": 0,
803                        "archived": 0,
804                        "orderProductId": 22_654_728,
805                        "customerId": 399_144,
806                        "files": [],
807                    },
808                    "relationships": {
809                        "publisher": {
810                            "data": { "type": "Publisher", "id": "/api/vBeta/publishers/117" },
811                        },
812                    },
813                },
814                "included": [
815                    {
816                        "id": "/api/vBeta/publishers/117",
817                        "type": "Publisher",
818                        "attributes": {
819                            "name": "The Forge Studios",
820                            "publisherId": 117,
821                            "slug": "the-forge-studios",
822                        },
823                    },
824                ],
825            })))
826            .expect(1)
827            .mount(&server)
828            .await;
829
830        let client = client_for(&server);
831        let result = client
832            .get_order_product(22_654_728)
833            .await
834            .expect("decode succeeds");
835
836        let included = result.included.expect("included array is present");
837        assert_eq!(included.len(), 1);
838        let publisher = included[0].as_publisher().expect("decodes as a publisher");
839        assert_eq!(publisher.name, "The Forge Studios");
840    }
841
842    #[tokio::test]
843    async fn create_product_list_decodes_json_api_envelope() {
844        let server = MockServer::start().await;
845
846        Mock::given(method("POST"))
847            .and(path("/vBeta/product_lists"))
848            .respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({
849                "data": {
850                    "id": "/api/vBeta/product_lists/86267",
851                    "type": "ProductList",
852                    "attributes": {
853                        "customerId": 399_144,
854                        "name": "Testing",
855                        "dateCreated": "2026-07-09T00:42:39-05:00",
856                        "productListId": 86_267,
857                        "slug": "testing",
858                        "itemCount": 0,
859                    },
860                },
861            })))
862            .expect(1)
863            .mount(&server)
864            .await;
865
866        let client = client_for(&server);
867        let result = client
868            .create_product_list("Testing")
869            .await
870            .expect("decode succeeds");
871
872        assert_eq!(result.id, "/api/vBeta/product_lists/86267");
873        assert_eq!(result.attributes.product_list_id, 86_267);
874        assert_eq!(result.attributes.name, "Testing");
875    }
876
877    #[tokio::test]
878    async fn add_product_list_item_returns_created_item() {
879        let server = MockServer::start().await;
880
881        Mock::given(method("POST"))
882            .and(path("/vBeta/product_list_items"))
883            .and(body_json(serde_json::json!({
884                "productId": 515_276,
885                "productListId": 86_151,
886            })))
887            .respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({
888                "data": {
889                    "id": "/api/vBeta/product_list_items/2629321",
890                    "type": "ProductListItem",
891                    "attributes": {
892                        "productId": 515_276,
893                        "productListId": 86_151,
894                        "productListItemId": 2_629_321,
895                    },
896                },
897            })))
898            .expect(1)
899            .mount(&server)
900            .await;
901
902        let client = client_for(&server);
903        let result = client.add_product_list_item(86_151, 515_276).await.unwrap();
904
905        assert_eq!(result.product_id, 515_276);
906        assert_eq!(result.product_list_id, 86_151);
907        assert_eq!(result.product_list_item_id, 2_629_321);
908    }
909
910    #[tokio::test]
911    async fn add_product_list_item_returns_api_error_on_failure_status() {
912        let server = MockServer::start().await;
913
914        Mock::given(method("POST"))
915            .and(path("/vBeta/product_list_items"))
916            .respond_with(ResponseTemplate::new(404))
917            .expect(1)
918            .mount(&server)
919            .await;
920
921        let client = client_for(&server);
922        let result = client.add_product_list_item(86_151, 515_276).await;
923
924        assert!(matches!(
925            result,
926            Err(ClientError::ApiError { status: 404, .. })
927        ));
928    }
929
930    #[tokio::test]
931    async fn add_product_list_item_returns_no_retry_after_when_header_absent() {
932        let server = MockServer::start().await;
933
934        Mock::given(method("POST"))
935            .and(path("/vBeta/product_list_items"))
936            .respond_with(ResponseTemplate::new(404))
937            .expect(1)
938            .mount(&server)
939            .await;
940
941        let client = client_for(&server);
942        let result = client.add_product_list_item(86_151, 515_276).await;
943
944        match result {
945            Err(ClientError::ApiError { retry_after, .. }) => {
946                assert_eq!(retry_after, None);
947            }
948            other => panic!("expected ClientError::ApiError, got {other:?}"),
949        }
950    }
951
952    #[tokio::test]
953    async fn add_product_list_item_returns_retry_after_on_429() {
954        let server = MockServer::start().await;
955
956        Mock::given(method("POST"))
957            .and(path("/vBeta/product_list_items"))
958            .respond_with(ResponseTemplate::new(429).insert_header("Retry-After", "30"))
959            .expect(1)
960            .mount(&server)
961            .await;
962
963        let client = client_for(&server);
964        let result = client.add_product_list_item(86_151, 515_276).await;
965
966        match result {
967            Err(ClientError::ApiError {
968                status,
969                retry_after,
970                ..
971            }) => {
972                assert_eq!(status, 429);
973                assert_eq!(retry_after, Some(std::time::Duration::from_secs(30)));
974            }
975            other => panic!("expected ClientError::ApiError, got {other:?}"),
976        }
977    }
978
979    #[tokio::test]
980    async fn add_product_list_item_ignores_unparseable_retry_after() {
981        let server = MockServer::start().await;
982
983        Mock::given(method("POST"))
984            .and(path("/vBeta/product_list_items"))
985            .respond_with(
986                ResponseTemplate::new(429)
987                    .insert_header("Retry-After", "Wed, 21 Oct 2026 07:28:00 GMT"),
988            )
989            .expect(1)
990            .mount(&server)
991            .await;
992
993        let client = client_for(&server);
994        let result = client.add_product_list_item(86_151, 515_276).await;
995
996        match result {
997            Err(ClientError::ApiError {
998                status,
999                retry_after,
1000                ..
1001            }) => {
1002                assert_eq!(status, 429);
1003                assert_eq!(retry_after, None);
1004            }
1005            other => panic!("expected ClientError::ApiError, got {other:?}"),
1006        }
1007    }
1008
1009    #[tokio::test]
1010    async fn add_product_list_item_surfaces_validation_message_on_conflict() {
1011        let server = MockServer::start().await;
1012
1013        Mock::given(method("POST"))
1014            .and(path("/vBeta/product_list_items"))
1015            .respond_with(ResponseTemplate::new(409).set_body_json(serde_json::json!({
1016                "error": {
1017                    "id": "6a4ee5880bfda",
1018                    "message": "productId: Requires a valid Product ID. Invalid value 22654728.",
1019                    "code": 409,
1020                    "status": 409,
1021                },
1022            })))
1023            .expect(1)
1024            .mount(&server)
1025            .await;
1026
1027        let client = client_for(&server);
1028        let result = client.add_product_list_item(86_151, 22_654_728).await;
1029
1030        match result {
1031            Err(ClientError::ApiError {
1032                status, message, ..
1033            }) => {
1034                assert_eq!(status, 409);
1035                assert_eq!(
1036                    message.as_deref(),
1037                    Some("productId: Requires a valid Product ID. Invalid value 22654728.")
1038                );
1039            }
1040            other => panic!("expected ClientError::ApiError, got {other:?}"),
1041        }
1042    }
1043
1044    #[tokio::test]
1045    async fn add_product_list_item_surfaces_duplicate_membership_message_on_conflict() {
1046        let server = MockServer::start().await;
1047
1048        Mock::given(method("POST"))
1049            .and(path("/vBeta/product_list_items"))
1050            .respond_with(ResponseTemplate::new(409).set_body_json(serde_json::json!({
1051                "error": {
1052                    "id": "6a4fc7a3b07db",
1053                    "message": "This product already exists for this list.",
1054                    "code": 409,
1055                    "status": 409,
1056                },
1057            })))
1058            .expect(1)
1059            .mount(&server)
1060            .await;
1061
1062        let client = client_for(&server);
1063        let result = client.add_product_list_item(86_151, 144_239).await;
1064
1065        match result {
1066            Err(ClientError::ApiError {
1067                status, message, ..
1068            }) => {
1069                assert_eq!(status, 409);
1070                assert_eq!(
1071                    message.as_deref(),
1072                    Some("This product already exists for this list.")
1073                );
1074            }
1075            other => panic!("expected ClientError::ApiError, got {other:?}"),
1076        }
1077    }
1078
1079    #[tokio::test]
1080    async fn add_product_list_item_decodes_json_api_envelope_on_success() {
1081        let server = MockServer::start().await;
1082
1083        Mock::given(method("POST"))
1084            .and(path("/vBeta/product_list_items"))
1085            .respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({
1086                "data": {
1087                    "id": "/api/vBeta/product_list_items/2634593",
1088                    "type": "ProductListItem",
1089                    "attributes": {
1090                        "productId": 144_239,
1091                        "productListId": 86_267,
1092                        "productListItemId": 2_634_593,
1093                    },
1094                },
1095            })))
1096            .expect(1)
1097            .mount(&server)
1098            .await;
1099
1100        let client = client_for(&server);
1101        let result = client
1102            .add_product_list_item(86_267, 144_239)
1103            .await
1104            .expect("decode succeeds");
1105
1106        assert_eq!(result.product_id, 144_239);
1107        assert_eq!(result.product_list_id, 86_267);
1108        assert_eq!(result.product_list_item_id, 2_634_593);
1109    }
1110
1111    #[tokio::test]
1112    async fn delete_product_list_item_succeeds_on_no_content() {
1113        let server = MockServer::start().await;
1114
1115        Mock::given(method("DELETE"))
1116            .and(path("/vBeta/product_list_items/2629321"))
1117            .respond_with(ResponseTemplate::new(204))
1118            .expect(1)
1119            .mount(&server)
1120            .await;
1121
1122        let client = client_for(&server);
1123        let result = client.delete_product_list_item(2_629_321).await;
1124
1125        assert!(result.is_ok());
1126    }
1127
1128    #[tokio::test]
1129    async fn delete_product_list_item_returns_http_error_on_failure_status() {
1130        let server = MockServer::start().await;
1131
1132        Mock::given(method("DELETE"))
1133            .and(path("/vBeta/product_list_items/2629321"))
1134            .respond_with(ResponseTemplate::new(404))
1135            .expect(1)
1136            .mount(&server)
1137            .await;
1138
1139        let client = client_for(&server);
1140        let result = client.delete_product_list_item(2_629_321).await;
1141
1142        assert!(matches!(result, Err(ClientError::Http(_))));
1143    }
1144}