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}