1pub mod address;
61pub mod auth;
62#[cfg(test)]
63mod capabilities_fixture;
64pub mod client;
65pub mod contract_drift;
66pub mod country_code;
67pub mod create_payment;
68pub mod datetime;
69pub mod domain;
70pub mod empty;
71pub mod env;
72pub mod envelope;
73pub mod error;
74pub mod error_catalog;
75pub mod factory;
76pub mod hooks;
77pub mod idempotency;
78pub mod ids;
79pub mod integration;
80pub mod locale;
81pub mod metadata;
82pub mod money;
83pub mod nullable_field;
84pub mod open_enum;
85pub mod operation_safety;
86pub mod pagination;
87pub mod payment_method;
88pub mod phone_number;
89#[cfg(test)]
90mod postman_error_fixtures;
91pub mod provider_enums;
92pub mod response_limits;
93pub mod route_capabilities;
94#[cfg(feature = "app-helpers")]
96pub mod tracing_config;
97pub mod transport;
98pub mod webhook;
99pub mod webhook_verify;
100pub mod write_requests;
101
102#[cfg(test)]
103mod property_tests;
104#[cfg(test)]
105mod secret_leak_tests;
106
107use reqwest::{Client as ReqwestClient, ClientBuilder as ReqwestClientBuilder};
108
109pub use address::{Address, POSTAL_CODE_OPTIONAL_COUNTRIES};
110pub use auth::{ApiKey, BasicAuth, Credential, OAuthAccessToken};
111pub use client::{MollieClient, MollieClientBuilder, DEFAULT_BASE_URL};
112pub use contract_drift::{
113 emit_contract_drift, global_contract_drift_observer, set_global_contract_drift_observer,
114 ContractDriftKind, ContractDriftObserver, ContractDriftScopeGuard, ContractDriftSignal,
115 NoopContractDriftObserver, SharedContractDriftObserver, CONTRACT_DRIFT_DETAIL_MAX_LEN,
116};
117pub use country_code::CountryCode;
118pub use create_payment::{
119 CreatePaymentRequired, PaymentDescription, RedirectUrl, PAYMENT_DESCRIPTION_MAX_LEN,
120};
121pub use datetime::{Date, DateTime};
122pub use domain::{
123 CapturesApi, ConnectBalanceTransfersApi, MandatesApi, OAuthApi, PaymentLinksApi, PaymentsApi,
124 PayoutsApi, RefundsApi, SessionsApi, SubscriptionsApi, TerminalsApi, TransferClientSignature,
125 TransfersApi, UnmatchedCreditTransfersApi, VerifyPayeeApi, WebhooksApi,
126};
127pub use empty::EmptyResponse;
128pub use env::{
129 load_dotenv, load_dotenv_from, var, var_optional, var_os, MOLLIE_API_KEY_ENV,
130 MOLLIE_BASE_URL_ENV, MOLLIE_OAUTH_ACCESS_TOKEN_ENV, MOLLIE_OAUTH_CLIENT_ID_ENV,
131 MOLLIE_OAUTH_CLIENT_SECRET_ENV,
132};
133pub use envelope::{
134 GeneratedMollieResult, IntoMollieFuture, IntoMollieResult, MollieEnvelope, MollieResponse,
135 ResponseEnvelope, ResponseValueExt,
136};
137pub use error::{MollieError, MollieResult};
138pub use error_catalog::{
139 MollieErrorCatalogEntry, MollieErrorCode, MollieErrorEnvelope, MollieErrorKey,
140 MollieSuccessCatalogEntry, MollieSuccessCode, MollieSuccessEnvelope, MollieSuccessKey,
141};
142pub use hooks::{NoopHook, RequestContext, RequestHook, SharedRequestHook};
143pub use idempotency::{IdempotencyKey, IDEMPOTENCY_KEY_MAX_LEN};
144pub use ids::{
145 BalanceId, CaptureId, ChargebackId, CustomerId, MandateId, PaymentId, PaymentLinkId, ProfileId,
146 RefundId, SalesInvoiceId, SettlementId, SubscriptionId, TerminalId,
147};
148pub use integration::{
149 ClaimResult, EventStoreReplayAdapter, PaymentStateRefetcher, WebhookDispatcher,
150 WebhookEventStore, WebhookReplayStore,
151};
152pub use locale::Locale;
153pub use metadata::{ErrorResponseContext, ResponseMetadata, MAX_RETAINED_BODY_BYTES};
154pub use money::{
155 AmountValue, ApplicationFee, ApplicationFeeDescription, Currency, Money,
156 APPLICATION_FEE_DESCRIPTION_MAX_LEN,
157};
158pub use nullable_field::{is_omitted as nullable_field_is_omitted, NullableField};
159pub use open_enum::{OpenEnum, OpenEnumError, OPEN_ENUM_MAX_RAW_LEN};
160pub use operation_safety::{
161 all_operation_safety_profiles, high_risk_coverage, operation_safety_profile, AuthClass,
162 IdempotencyClass, MutationClass, OperationExposure, OperationRisk, OperationSafetyProfile,
163 PaginationPolicy, ProfileScope, TestmodePolicy, HIGH_RISK_WRITE_OPERATION_IDS,
164 PAYMENT_CAPABILITY_MUTATION_OPERATION_IDS,
165};
166pub use pagination::{
167 AsyncPaginator, ItemStream, Page, PageCursor, PaginationGuard, DEFAULT_PAGE_LIMIT,
168 MAX_PAGE_LIMIT,
169};
170pub use payment_method::PaymentMethod;
171pub use phone_number::PhoneNumber;
172pub use provider_enums::{
173 parse_payment_status, payment_status_from_generated, payment_status_to_generated,
174 PaymentStatusKnown, PaymentStatusValue,
175};
176pub use response_limits::{ResponseLimits, DEFAULT_MAX_ERROR_BODY_BYTES, DEFAULT_MAX_JSON_BYTES};
177pub use route_capabilities::{
178 retry_class_for_operation, route_capability, RouteAccess, RouteCapability, ROUTE_CAPABILITIES,
179};
180#[cfg(feature = "app-helpers")]
181pub use tracing_config::{
182 init_tracing, init_tracing_with_filter, try_init_tracing, try_init_tracing_with_filter,
183};
184pub use transport::{compute_backoff, DeliveryOutcome, RetryClass, RetryPolicy};
185pub use webhook::{WebhookNotification, WebhookUrl};
186pub use webhook_verify::{
187 compute_mollie_signature_hex, VerifiedWebhook, WebhookSigningSecret, WebhookVerifier,
188 WebhookVerifyFailure, DEFAULT_MAX_WEBHOOK_BODY_BYTES, MOLLIE_SIGNATURE_HEADER,
189};
190pub use write_requests::{
191 ConnectBalanceTransferParty, CreateCaptureRequired, CreateConnectBalanceTransferRequired,
192 CreatePaymentLinkRequired, CreatePayoutRequired, CreateRefundRequired,
193 CreateSepaMandateRequired, CreateSubscriptionRequired, CreateTransferRequired,
194 UpdatePaymentRequired, VerifyPayeeRequired,
195};
196
197pub use tracing;
199#[cfg(feature = "app-helpers")]
203pub use tracing_subscriber;
204
205use progenitor_client::ClientHooks;
206#[allow(unused_imports)]
207pub use progenitor_client::{ByteStream, ClientInfo, Error, ResponseValue};
208
209pub mod routes;
211pub mod types;
213
214#[derive(Clone)]
233pub struct Client {
234 pub(crate) baseurl: String,
236 pub(crate) client: ReqwestClient,
238 pub(crate) idempotency_key: Option<String>,
247 pub(crate) testmode: Option<bool>,
253 pub(crate) profile_id: Option<String>,
259 pub(crate) retry_policy: transport::RetryPolicy,
261 pub(crate) request_hook: Option<hooks::SharedRequestHook>,
263 pub(crate) contract_drift_observer: Option<contract_drift::SharedContractDriftObserver>,
265 pub(crate) timeout: std::time::Duration,
267 pub(crate) connect_timeout: std::time::Duration,
269 pub(crate) user_agent: Option<String>,
271 pub(crate) response_limits: ResponseLimits,
273}
274
275impl std::fmt::Debug for Client {
276 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
277 f.debug_struct("Client")
278 .field("baseurl", &self.baseurl)
279 .field(
280 "idempotency_key",
281 &self.idempotency_key.as_ref().map(|_| "<redacted>"),
282 )
283 .field("testmode", &self.testmode)
284 .field("profile_id", &self.profile_id)
285 .field("retry_policy", &self.retry_policy)
286 .field(
287 "request_hook",
288 &self.request_hook.as_ref().map(|_| "<hook>"),
289 )
290 .field(
291 "contract_drift_observer",
292 &self
293 .contract_drift_observer
294 .as_ref()
295 .map(|_| "<contract_drift_observer>"),
296 )
297 .field("timeout", &self.timeout)
298 .field("connect_timeout", &self.connect_timeout)
299 .field("user_agent", &self.user_agent)
300 .field("response_limits", &self.response_limits)
301 .finish_non_exhaustive()
302 }
303}
304
305impl Client {
307 pub fn new(baseurl: &str) -> Result<Self, reqwest::Error> {
328 #[cfg(not(target_arch = "wasm32"))]
329 let client: ReqwestClientBuilder = {
330 let dur: std::time::Duration = ::std::time::Duration::from_secs(15u64);
331 ReqwestClientBuilder::new()
332 .connect_timeout(dur)
333 .timeout(dur)
334 };
335 #[cfg(target_arch = "wasm32")]
336 let client = ReqwestClientBuilder::new();
337 match client.build() {
338 Ok(http_client) => Ok(Self::new_with_client(baseurl, http_client)),
339 Err(error) => Err(error),
340 }
341 }
342
343 pub fn new_with_client(baseurl: &str, client: ReqwestClient) -> Self {
360 Self {
361 baseurl: baseurl.to_string(),
362 client,
363 idempotency_key: None,
364 testmode: None,
365 profile_id: None,
366 retry_policy: transport::RetryPolicy::disabled(),
367 request_hook: None,
368 contract_drift_observer: None,
369 timeout: std::time::Duration::from_secs(15),
371 connect_timeout: std::time::Duration::from_secs(15),
372 user_agent: None,
373 response_limits: ResponseLimits::default(),
374 }
375 }
376
377 pub(crate) fn with_transport_timeouts(
379 mut self,
380 timeout: std::time::Duration,
381 connect_timeout: std::time::Duration,
382 ) -> Self {
383 self.timeout = timeout;
384 self.connect_timeout = connect_timeout;
385 self
386 }
387
388 pub(crate) fn with_user_agent_string(mut self, user_agent: impl Into<String>) -> Self {
390 self.user_agent = Some(user_agent.into());
391 self
392 }
393
394 pub fn timeout(&self) -> std::time::Duration {
396 self.timeout
397 }
398
399 pub fn connect_timeout(&self) -> std::time::Duration {
401 self.connect_timeout
402 }
403
404 pub fn user_agent(&self) -> Option<&str> {
406 self.user_agent.as_deref()
407 }
408
409 pub fn response_limits(&self) -> ResponseLimits {
411 self.response_limits
412 }
413
414 pub fn with_response_limits(mut self, limits: ResponseLimits) -> Self {
416 self.response_limits = limits;
417 self
418 }
419
420 pub fn with_retry_policy(mut self, policy: transport::RetryPolicy) -> Self {
422 self.retry_policy = policy;
423 self
424 }
425
426 pub fn retry_policy(&self) -> &transport::RetryPolicy {
428 &self.retry_policy
429 }
430
431 pub fn with_idempotency_key(mut self, key: impl Into<String>) -> Self {
456 self.idempotency_key = Some(key.into());
457 self
458 }
459
460 pub fn with_idempotency_key_ref(&self, key: impl Into<String>) -> Self {
474 self.clone().with_idempotency_key(key)
475 }
476
477 pub fn clear_idempotency_key(mut self) -> Self {
491 self.idempotency_key = None;
492 self
493 }
494
495 pub fn idempotency_key(&self) -> Option<&str> {
501 self.idempotency_key.as_deref()
502 }
503
504 pub fn with_testmode(mut self, testmode: bool) -> Self {
520 self.testmode = Some(testmode);
521 self
522 }
523
524 pub fn with_testmode_ref(&self, testmode: bool) -> Self {
537 self.clone().with_testmode(testmode)
538 }
539
540 pub fn clear_testmode(mut self) -> Self {
553 self.testmode = None;
554 self
555 }
556
557 pub fn testmode(&self) -> Option<bool> {
562 self.testmode
563 }
564
565 pub fn with_profile_id(mut self, profile_id: impl Into<String>) -> Self {
571 self.profile_id = Some(profile_id.into());
572 self
573 }
574
575 pub fn clear_profile_id(mut self) -> Self {
577 self.profile_id = None;
578 self
579 }
580
581 pub fn profile_id(&self) -> Option<&str> {
583 self.profile_id.as_deref()
584 }
585
586 pub fn with_request_hook(mut self, hook: hooks::SharedRequestHook) -> Self {
588 self.request_hook = Some(hook);
589 self
590 }
591
592 pub fn request_hook(&self) -> Option<&hooks::SharedRequestHook> {
594 self.request_hook.as_ref()
595 }
596
597 pub fn with_contract_drift_observer(
599 mut self,
600 observer: contract_drift::SharedContractDriftObserver,
601 ) -> Self {
602 self.contract_drift_observer = Some(observer);
603 self
604 }
605
606 pub fn contract_drift_observer(&self) -> Option<&contract_drift::SharedContractDriftObserver> {
608 self.contract_drift_observer.as_ref()
609 }
610
611 #[allow(clippy::result_large_err)]
619 pub(crate) fn reject_testmode_for(
620 &self,
621 operation: &str,
622 ) -> Result<(), Error<types::ErrorResponse>> {
623 if self.testmode.is_some() {
624 return Err(Error::InvalidRequest(format!(
625 "testmode is not supported for the `{operation}` operation"
626 )));
627 }
628
629 Ok(())
630 }
631
632 pub fn baseurl(&self) -> &str {
643 &self.baseurl
644 }
645
646 pub fn http_client(&self) -> &ReqwestClient {
657 &self.client
658 }
659
660 pub(crate) fn endpoint(&self, path: impl ::std::fmt::Display) -> String {
670 let path = path.to_string();
671 if path.starts_with("/oauth2/") {
674 if let Ok(mut url) = ::reqwest::Url::parse(&self.baseurl) {
675 url.set_path(&path);
676 url.set_query(None);
677 url.set_fragment(None);
678 return url.to_string();
679 }
680 }
681 format!("{}{}", self.baseurl, path)
682 }
683
684 pub(crate) fn request(
706 &self,
707 method: ::reqwest::Method,
708 url: String,
709 ) -> Result<(::reqwest::RequestBuilder, String), ::reqwest::header::InvalidHeaderValue> {
710 let resolved_key: String = match self.idempotency_key.as_deref() {
711 Some(value) if !value.is_empty() => value.to_string(),
712 _ => uuid::Uuid::new_v4().to_string(),
713 };
714
715 let mut headers: reqwest::header::HeaderMap =
716 ::reqwest::header::HeaderMap::with_capacity(2usize);
717 headers.append(
718 ::reqwest::header::HeaderName::from_static("api-version"),
719 ::reqwest::header::HeaderValue::from_static(Self::api_version()),
720 );
721 headers.append("idempotency-key", resolved_key.as_str().try_into()?);
722
723 Ok((
724 self.client
725 .request(method, url)
726 .header(
727 ::reqwest::header::ACCEPT,
728 ::reqwest::header::HeaderValue::from_static("application/json"),
729 )
730 .headers(headers),
731 resolved_key,
732 ))
733 }
734
735 pub(crate) async fn send<E>(
762 &self,
763 mut request: ::reqwest::Request,
764 operation: routes::Operation,
765 ) -> Result<::reqwest::Response, Error<E>> {
766 let info = operation.info();
767 self.pre(&mut request, &info).await?;
768
769 let policy = &self.retry_policy;
770 let class = route_capabilities::retry_class_for_operation(
772 operation.id(),
773 request.method().as_str(),
774 );
775 let has_sticky = self
776 .idempotency_key
777 .as_ref()
778 .is_some_and(|key| !key.is_empty());
779 let may_retry = policy.allows(class, has_sticky);
780 let max_attempts = if may_retry {
781 policy.max_attempts.max(1)
782 } else {
783 1
784 };
785 let started = std::time::Instant::now();
786 let retry_budget = policy.retry_budget();
787
788 for attempt in 1..=max_attempts {
789 if attempt > 1 && started.elapsed() >= retry_budget {
792 return Err(Error::InvalidRequest(format!(
793 "retry budget exhausted for operation `{}` (no further attempt sent)",
794 operation.id()
795 )));
796 }
797
798 let method = request.method().as_str().to_string();
799 let url_redacted = redact_url_for_hooks(request.url());
800 let hook_ctx = hooks::RequestContext {
801 operation: operation.id(),
802 method,
803 url_redacted,
804 attempt,
805 has_sticky_idempotency: has_sticky,
806 profile_id: self.profile_id.clone(),
807 testmode: self.testmode,
808 };
809 let _drift_scope = contract_drift::ContractDriftScopeGuard::enter(
810 operation.id(),
811 self.contract_drift_observer.clone(),
812 );
813 if let Some(hook) = self.request_hook.as_ref() {
814 hook.before_request(&hook_ctx, &mut request);
815 }
816
817 let is_last = attempt == max_attempts;
818 let request_for_attempt = if is_last {
819 std::mem::replace(
820 &mut request,
821 ::reqwest::Request::new(
822 ::reqwest::Method::GET,
823 ::reqwest::Url::parse("http://127.0.0.1/").expect("static url"),
824 ),
825 )
826 } else if let Some(cloned) = request.try_clone() {
827 cloned
828 } else {
829 let result = self.exec(request, &info).await;
831 self.post(&result, &info).await?;
832 return Ok(result?);
833 };
834
835 let result = self.exec(request_for_attempt, &info).await;
836
837 if !is_last {
838 let should_retry = match &result {
843 Ok(response) => {
844 let outcome = transport::classify_http_status(response.status());
845 transport::should_auto_retry(outcome, class, has_sticky, policy)
846 && transport::is_transient_http_status(response.status())
847 }
848 Err(err) => {
849 let outcome = transport::classify_reqwest_error(err);
850 transport::should_auto_retry(outcome, class, has_sticky, policy)
851 }
852 };
853
854 if should_retry {
855 let retry_after = result.as_ref().ok().and_then(|response| {
856 response
857 .headers()
858 .get("retry-after")
859 .and_then(|v| v.to_str().ok())
860 .and_then(|s| s.parse::<u64>().ok())
861 .map(std::time::Duration::from_secs)
862 });
863 let delay = transport::compute_backoff(policy, attempt + 1, retry_after);
864 if started.elapsed().saturating_add(delay) >= retry_budget {
867 tracing::debug!(
868 attempt,
869 operation = operation.id(),
870 budget_ms = retry_budget.as_millis() as u64,
871 "retry budget exhausted before backoff; returning last attempt"
872 );
873 self.post(&result, &info).await?;
874 return Ok(result?);
875 }
876 tracing::debug!(
877 attempt,
878 max_attempts,
879 delay_ms = delay.as_millis() as u64,
880 has_sticky_idempotency = has_sticky,
881 operation = operation.id(),
882 retry_class = ?class,
883 "retrying Mollie HTTP request"
884 );
885 let _ = result;
886 tokio::time::sleep(delay).await;
887 continue;
888 }
889 }
890
891 if let (Some(hook), Ok(response)) = (self.request_hook.as_ref(), result.as_ref()) {
892 let metadata = crate::metadata::ResponseMetadata::from_status_and_headers(
893 response.status(),
894 response.headers(),
895 )
896 .with_attempt(attempt);
897 hook.after_response(&hook_ctx, &metadata);
898 }
899
900 self.post(&result, &info).await?;
901 return Ok(result?);
902 }
903
904 Err(Error::InvalidRequest(format!(
905 "retry budget exhausted for operation `{}`",
906 operation.id()
907 )))
908 }
909}
910
911fn redact_url_for_hooks(url: &reqwest::Url) -> String {
916 let mut redacted = url.clone();
917 if redacted.query().is_some() {
918 redacted.set_query(Some("<redacted>"));
919 }
920 redacted.to_string()
921}
922
923impl ClientInfo<()> for Client {
925 fn api_version() -> &'static str {
927 "1.0.0"
928 }
929
930 fn baseurl(&self) -> &str {
932 self.baseurl.as_str()
933 }
934
935 fn client(&self) -> &reqwest::Client {
937 &self.client
938 }
939
940 fn inner(&self) -> &() {
942 &()
943 }
944}
945
946impl ClientHooks<()> for &Client {}
948
949pub mod prelude {
954 #[allow(unused_imports)]
955 pub use super::{
956 load_dotenv, load_dotenv_from, var, var_optional, Address, AmountValue, ApiKey,
957 ApplicationFee, ApplicationFeeDescription, BasicAuth, Client, CountryCode,
958 CreatePaymentRequired, Credential, Currency, Date, DateTime, GeneratedMollieResult,
959 IntoMollieFuture, IntoMollieResult, Locale, MollieClient, MollieClientBuilder,
960 MollieEnvelope, MollieError, MollieErrorCatalogEntry, MollieErrorEnvelope, MollieErrorKey,
961 MollieResponse, MollieResult, MollieSuccessEnvelope, MollieSuccessKey, Money,
962 OAuthAccessToken, PaymentDescription, PaymentId, PaymentMethod, PhoneNumber, ProfileId,
963 RedirectUrl, ResponseEnvelope, ResponseMetadata, ResponseValueExt, WebhookNotification,
964 WebhookUrl, APPLICATION_FEE_DESCRIPTION_MAX_LEN, DEFAULT_BASE_URL, MOLLIE_API_KEY_ENV,
965 MOLLIE_BASE_URL_ENV, MOLLIE_OAUTH_ACCESS_TOKEN_ENV, MOLLIE_OAUTH_CLIENT_ID_ENV,
966 MOLLIE_OAUTH_CLIENT_SECRET_ENV,
967 };
968
969 #[cfg(feature = "app-helpers")]
970 #[allow(unused_imports)]
971 pub use super::{
972 init_tracing, init_tracing_with_filter, try_init_tracing, try_init_tracing_with_filter,
973 };
974}
975
976#[cfg(test)]
978mod tests {
979 use super::{
980 Client, Error, ResponseEnvelope, ResponseValue, ResponseValueExt, DEFAULT_BASE_URL,
981 };
982 use reqwest::StatusCode;
983
984 fn assert_generated_uuid_v4_idempotency_key(key: &str, forbidden: &[&str]) {
986 assert!(
987 !key.is_empty(),
988 "resolved idempotency key must be non-empty"
989 );
990 for bad in forbidden {
991 assert_ne!(
992 key, *bad,
993 "resolved key must not reuse invalid sticky value {bad:?}"
994 );
995 }
996 let parsed = uuid::Uuid::parse_str(key)
997 .unwrap_or_else(|err| panic!("expected UUID, got {key:?}: {err}"));
998 assert_eq!(
999 parsed.get_version(),
1000 Some(uuid::Version::Random),
1001 "expected UUID v4 (random), got version {:?} for {key}",
1002 parsed.get_version()
1003 );
1004 }
1005
1006 #[test]
1009 fn request_generates_uuid_v4_for_missing_or_blank_idempotency_keys() {
1010 let none_client = Client::new(DEFAULT_BASE_URL).expect("default client");
1012 assert!(none_client.idempotency_key().is_none());
1013 let (_builder, key_none) = none_client
1014 .request(reqwest::Method::GET, none_client.endpoint("/payments"))
1015 .expect("request should build");
1016 assert_generated_uuid_v4_idempotency_key(&key_none, &["", "None", "null"]);
1017
1018 let empty_client = Client::new(DEFAULT_BASE_URL)
1020 .expect("default client")
1021 .with_idempotency_key("");
1022 assert_eq!(empty_client.idempotency_key(), Some(""));
1023 let (_builder, key_empty) = empty_client
1024 .request(reqwest::Method::POST, empty_client.endpoint("/payments"))
1025 .expect("request should build");
1026 assert_generated_uuid_v4_idempotency_key(&key_empty, &["", "None", "null"]);
1027 assert_ne!(
1028 key_empty, key_none,
1029 "auto-generated keys should not collide across independent requests"
1030 );
1031
1032 let empty_ref_client: Client = Client::new(DEFAULT_BASE_URL)
1034 .expect("default client")
1035 .with_idempotency_key_ref("");
1036 assert_eq!(empty_ref_client.idempotency_key(), Some(""));
1037 let (_builder, key_empty_ref) = empty_ref_client
1038 .request(reqwest::Method::GET, empty_ref_client.endpoint("/payments"))
1039 .expect("request should build");
1040 assert_generated_uuid_v4_idempotency_key(&key_empty_ref, &[""]);
1041
1042 let cleared_client: Client = Client::new(DEFAULT_BASE_URL)
1044 .expect("default client")
1045 .with_idempotency_key("sticky-should-not-be-used")
1046 .clear_idempotency_key();
1047 assert!(cleared_client.idempotency_key().is_none());
1048 let (_builder, key_cleared) = cleared_client
1049 .request(reqwest::Method::GET, cleared_client.endpoint("/payments"))
1050 .expect("request should build");
1051 assert_generated_uuid_v4_idempotency_key(
1052 &key_cleared,
1053 &["", "sticky-should-not-be-used", "None", "null"],
1054 );
1055
1056 let (_builder, key_again) = empty_client
1058 .request(reqwest::Method::GET, empty_client.endpoint("/payments"))
1059 .expect("request should build");
1060 assert_generated_uuid_v4_idempotency_key(&key_again, &[""]);
1061 assert_ne!(
1062 key_empty, key_again,
1063 "blank sticky key must generate a new UUID per request, not reuse a fixed empty value"
1064 );
1065 }
1066
1067 #[test]
1069 fn request_preserves_sticky_idempotency_key() {
1070 let expected: &str = "6f7ef3e6-8c2f-4d1c-9f08-5ab7adf56c91";
1071 let client: Client = Client::new(DEFAULT_BASE_URL)
1072 .expect("default client")
1073 .with_idempotency_key(expected);
1074 let (_builder, key) = client
1075 .request(reqwest::Method::POST, client.endpoint("/payments"))
1076 .expect("request should build");
1077 assert_eq!(key, expected);
1078 }
1079
1080 #[test]
1082 fn with_testmode_sets_sticky_flag() {
1083 let client: Client = Client::new(DEFAULT_BASE_URL)
1084 .expect("default client")
1085 .with_testmode(true);
1086 assert_eq!(client.testmode(), Some(true));
1087 let client: Client = client.with_testmode(false);
1088 assert_eq!(client.testmode(), Some(false));
1089 }
1090
1091 #[test]
1093 fn clear_testmode_restores_none() {
1094 let client: Client = Client::new(DEFAULT_BASE_URL)
1095 .expect("default client")
1096 .with_testmode(true)
1097 .clear_testmode();
1098 assert!(client.testmode().is_none());
1099 }
1100
1101 #[test]
1103 fn with_testmode_ref_leaves_original_unchanged() {
1104 let client: Client = Client::new(DEFAULT_BASE_URL).expect("default client");
1105 let scoped: Client = client.with_testmode_ref(true);
1106 assert!(client.testmode().is_none());
1107 assert_eq!(scoped.testmode(), Some(true));
1108 }
1109
1110 #[test]
1112 fn rejects_testmode_for_live_only_operations() {
1113 let client: Client = Client::new(DEFAULT_BASE_URL)
1114 .expect("default client")
1115 .with_testmode(false);
1116 let error = client
1117 .reject_testmode_for("list_settlements")
1118 .expect_err("live-only routes must reject configured testmode");
1119
1120 assert!(matches!(error, Error::InvalidRequest(message) if message.contains("testmode")));
1121 assert!(client
1122 .clear_testmode()
1123 .reject_testmode_for("list_settlements")
1124 .is_ok());
1125 }
1126
1127 #[test]
1129 fn response_envelope_exposes_idempotency_key_from_headers() {
1130 let mut headers: reqwest::header::HeaderMap = reqwest::header::HeaderMap::new();
1131 headers.insert(
1132 "idempotency-key",
1133 "6f7ef3e6-8c2f-4d1c-9f08-5ab7adf56c91"
1134 .parse()
1135 .expect("static header value"),
1136 );
1137 let response: ResponseValue<&str> = ResponseValue::new("ok", StatusCode::OK, headers);
1138 assert_eq!(
1139 response.idempotency_key(),
1140 Some("6f7ef3e6-8c2f-4d1c-9f08-5ab7adf56c91")
1141 );
1142 let envelope: ResponseEnvelope<&str> = ResponseEnvelope::from_response_value(response);
1143 assert_eq!(
1144 envelope.idempotency_key(),
1145 Some("6f7ef3e6-8c2f-4d1c-9f08-5ab7adf56c91")
1146 );
1147 }
1148
1149 #[test]
1152 fn list_count_deserializes_zero() {
1153 let count: crate::types::ListCount =
1154 serde_json::from_str("0").expect("count 0 must decode");
1155 assert_eq!(*count, 0);
1156 assert_eq!(count.0, 0);
1157
1158 let nonempty: crate::types::ListCount =
1159 serde_json::from_str("5").expect("count 5 must decode");
1160 assert_eq!(*nonempty, 5);
1161 }
1162
1163 #[test]
1166 fn entity_chargeback_links_allow_missing_documentation() {
1167 let json = r#"{
1168 "self": { "href": "https://api.mollie.com/v2/payments/tr_x/chargebacks/chb_x", "type": "application/hal+json" },
1169 "payment": { "href": "https://api.mollie.com/v2/payments/tr_x", "type": "application/hal+json" }
1170 }"#;
1171 let links: crate::types::EntityChargebackLinks =
1172 serde_json::from_str(json).expect("chargeback links without documentation");
1173 assert!(links.documentation.is_none());
1174 assert_eq!(
1175 links.payment.href,
1176 "https://api.mollie.com/v2/payments/tr_x"
1177 );
1178 }
1179
1180 #[test]
1182 fn entity_refund_links_allow_missing_documentation() {
1183 let json = r#"{
1184 "self": { "href": "https://api.mollie.com/v2/payments/tr_x/refunds/re_x", "type": "application/hal+json" },
1185 "payment": { "href": "https://api.mollie.com/v2/payments/tr_x", "type": "application/hal+json" }
1186 }"#;
1187 let links: crate::types::EntityRefundLinks =
1188 serde_json::from_str(json).expect("refund links without documentation");
1189 assert!(links.documentation.is_none());
1190 }
1191
1192 #[test]
1194 fn entity_refund_allows_null_metadata() {
1195 let json = r#"{
1196 "resource": "refund",
1197 "id": "re_test",
1198 "mode": "live",
1199 "amount": { "value": "1.00", "currency": "EUR" },
1200 "status": "refunded",
1201 "createdAt": "2026-01-22T10:39:23+00:00",
1202 "description": "Credit",
1203 "metadata": null,
1204 "paymentId": "tr_test",
1205 "_links": {
1206 "self": { "href": "https://api.mollie.com/v2/payments/tr_test/refunds/re_test", "type": "application/hal+json" },
1207 "payment": { "href": "https://api.mollie.com/v2/payments/tr_test", "type": "application/hal+json" }
1208 }
1209 }"#;
1210 let refund: crate::types::EntityRefund =
1211 serde_json::from_str(json).expect("refund with null metadata");
1212 assert!(refund.metadata.is_none());
1213 }
1214}