openhub-bo-core 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. checksums.yaml +7 -0
  2. data/Cargo.lock +738 -0
  3. data/Cargo.toml +5 -0
  4. data/LICENSE +202 -0
  5. data/NOTICE +8 -0
  6. data/README.md +21 -0
  7. data/ext/openhub_bo_core/Cargo.toml +16 -0
  8. data/ext/openhub_bo_core/extconf.rb +6 -0
  9. data/ext/openhub_bo_core/src/lib.rs +29 -0
  10. data/ext/openhub_bo_core/vendor/openhub-bo-core/Cargo.toml +17 -0
  11. data/ext/openhub_bo_core/vendor/openhub-bo-core/src/amount.rs +120 -0
  12. data/ext/openhub_bo_core/vendor/openhub-bo-core/src/config.rs +149 -0
  13. data/ext/openhub_bo_core/vendor/openhub-bo-core/src/envelope.rs +374 -0
  14. data/ext/openhub_bo_core/vendor/openhub-bo-core/src/error.rs +123 -0
  15. data/ext/openhub_bo_core/vendor/openhub-bo-core/src/ffi.rs +178 -0
  16. data/ext/openhub_bo_core/vendor/openhub-bo-core/src/http.rs +40 -0
  17. data/ext/openhub_bo_core/vendor/openhub-bo-core/src/lib.rs +33 -0
  18. data/ext/openhub_bo_core/vendor/openhub-bo-core/src/operation.rs +190 -0
  19. data/ext/openhub_bo_core/vendor/openhub-bo-core/src/status.rs +117 -0
  20. data/ext/openhub_bo_core/vendor/openhub-bo-core/src/token.rs +170 -0
  21. data/ext/openhub_bo_core/vendor/openhub-bo-core/src/validate.rs +87 -0
  22. data/ext/openhub_bo_core/vendor/openhub-bo-core/src/webhook.rs +132 -0
  23. data/lib/openhub_bo/core/errors.rb +111 -0
  24. data/lib/openhub_bo/core/models.rb +102 -0
  25. data/lib/openhub_bo/core/native_bridge.rb +31 -0
  26. data/lib/openhub_bo/core/op.rb +50 -0
  27. data/lib/openhub_bo/core/session.rb +81 -0
  28. data/lib/openhub_bo/core/testing.rb +85 -0
  29. data/lib/openhub_bo/core/transport.rb +55 -0
  30. data/lib/openhub_bo/core/version.rb +9 -0
  31. data/lib/openhub_bo/core.rb +20 -0
  32. metadata +130 -0
@@ -0,0 +1,40 @@
1
+ use std::collections::BTreeMap;
2
+
3
+ use serde::{Deserialize, Serialize};
4
+
5
+ #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
6
+ #[serde(rename_all = "UPPERCASE")]
7
+ pub enum Method {
8
+ Get,
9
+ Post,
10
+ Patch,
11
+ }
12
+
13
+ /// A request the host must send verbatim.
14
+ #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
15
+ pub struct HttpRequest {
16
+ pub method: Method,
17
+ pub url: String,
18
+ pub headers: BTreeMap<String, String>,
19
+ pub body: Option<String>,
20
+ }
21
+
22
+ /// What the host got back. Only status and body matter to the core.
23
+ #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
24
+ pub struct HttpResponse {
25
+ pub status: u16,
26
+ pub body: String,
27
+ }
28
+
29
+ impl HttpResponse {
30
+ pub fn new(status: u16, body: impl Into<String>) -> Self {
31
+ Self {
32
+ status,
33
+ body: body.into(),
34
+ }
35
+ }
36
+
37
+ pub fn is_success(&self) -> bool {
38
+ (200..300).contains(&self.status)
39
+ }
40
+ }
@@ -0,0 +1,33 @@
1
+ //! Sans-IO core for the Red Enlace (ATC) OpenHub payment APIs.
2
+ //!
3
+ //! This crate never performs network I/O. Each endpoint is an [`Operation`]
4
+ //! that builds an [`HttpRequest`] and parses an [`HttpResponse`]; the host
5
+ //! language executes the HTTP call with its own client. Bindings reach the
6
+ //! operations through a [`Registry`] and the JSON boundary in [`ffi`].
7
+
8
+ pub mod amount;
9
+ mod config;
10
+ pub mod envelope;
11
+ mod error;
12
+ pub mod ffi;
13
+ mod http;
14
+ mod operation;
15
+ pub mod status;
16
+ mod token;
17
+ pub mod validate;
18
+ pub mod webhook;
19
+
20
+ pub use config::{Config, Environment};
21
+ pub use error::{ApiFieldError, Error, Result};
22
+ pub use http::{HttpRequest, HttpResponse, Method};
23
+ pub use operation::{Chain, Ctx, Handler, OpInfo, Operation, Registry};
24
+ pub use status::PaymentStatus;
25
+ pub use token::{AccessToken, EXPIRY_SKEW_SECS, Token, TokenInput};
26
+ pub use webhook::{WebhookCall, WebhookTarget};
27
+
28
+ // Operations shared by every product package. Product crates declare their
29
+ // own registry and bindings combine them with [`Chain`].
30
+ registry!(pub CoreOps {
31
+ operations: [Token],
32
+ handlers: [],
33
+ });
@@ -0,0 +1,190 @@
1
+ //! One type per endpoint ([`Operation`]) or pure function ([`Handler`]), and
2
+ //! a [`registry!`](crate::registry) macro that exposes them over the FFI.
3
+ //!
4
+ //! Adding an endpoint means writing one `Operation` impl and listing it in a
5
+ //! registry; bindings pick it up through `call(op, payload)` without changes.
6
+
7
+ use std::collections::BTreeMap;
8
+
9
+ use serde::Serialize;
10
+ use serde::de::DeserializeOwned;
11
+
12
+ use crate::config::Config;
13
+ use crate::error::{Error, Result};
14
+ use crate::ffi::JsonValue;
15
+ use crate::http::{HttpRequest, HttpResponse};
16
+ use crate::token::AccessToken;
17
+
18
+ /// What an operation needs to build its request.
19
+ #[derive(Debug, Clone, Copy)]
20
+ pub struct Ctx<'a> {
21
+ pub config: &'a Config,
22
+ pub token: Option<&'a AccessToken>,
23
+ }
24
+
25
+ impl<'a> Ctx<'a> {
26
+ pub fn new(config: &'a Config, token: Option<&'a AccessToken>) -> Self {
27
+ Self { config, token }
28
+ }
29
+
30
+ pub fn token(&self) -> Result<&'a AccessToken> {
31
+ self.token.ok_or_else(|| {
32
+ Error::validation("token", "an access token is required for this operation")
33
+ })
34
+ }
35
+
36
+ /// Headers for business calls. The gateway (Sensedia) reads the token from
37
+ /// `access_token` (verified in the sandbox: `Authorization: Bearer` alone
38
+ /// gets a 401); the Bearer header is what the docs' tables describe, so
39
+ /// both are sent.
40
+ /// [`Ctx::api_headers`] plus product-specific headers (e.g. `branchCode`).
41
+ pub fn api_headers_with(&self, extra: &[(&str, &str)]) -> Result<BTreeMap<String, String>> {
42
+ let mut headers = self.api_headers()?;
43
+ headers.extend(
44
+ extra
45
+ .iter()
46
+ .map(|(k, v)| ((*k).to_owned(), (*v).to_owned())),
47
+ );
48
+ Ok(headers)
49
+ }
50
+
51
+ pub fn api_headers(&self) -> Result<BTreeMap<String, String>> {
52
+ let token = self.token()?;
53
+ Ok(BTreeMap::from([
54
+ ("Authorization".to_owned(), token.authorization()),
55
+ ("access_token".to_owned(), token.access_token.clone()),
56
+ ("client_id".to_owned(), self.config.client_id.clone()),
57
+ ("Content-Type".to_owned(), "application/json".to_owned()),
58
+ ("Accept".to_owned(), "application/json".to_owned()),
59
+ ]))
60
+ }
61
+ }
62
+
63
+ /// An HTTP endpoint: builds the request and parses the response.
64
+ pub trait Operation {
65
+ /// FFI name, e.g. `qr.generate`. Stages are `<NAME>.build` / `<NAME>.parse`.
66
+ const NAME: &'static str;
67
+ /// Safe to repeat. When false, a lost response leaves the outcome unknown
68
+ /// (e.g. a QR may have been created).
69
+ const IDEMPOTENT: bool;
70
+ /// Client timeout recommended by ATC for this endpoint, if any.
71
+ const TIMEOUT_SECS: Option<u32> = None;
72
+
73
+ type Input: DeserializeOwned;
74
+ type Output: Serialize;
75
+
76
+ fn request(ctx: &Ctx<'_>, input: &Self::Input) -> Result<HttpRequest>;
77
+ fn response(response: &HttpResponse, input: &Self::Input) -> Result<Self::Output>;
78
+ }
79
+
80
+ /// A pure function without HTTP, e.g. webhook parsing.
81
+ pub trait Handler {
82
+ const NAME: &'static str;
83
+
84
+ type Input: DeserializeOwned;
85
+ type Output: Serialize;
86
+
87
+ fn handle(input: Self::Input) -> Result<Self::Output>;
88
+ }
89
+
90
+ #[derive(Debug, Clone, PartialEq, Eq, Serialize)]
91
+ #[serde(tag = "kind", rename_all = "snake_case")]
92
+ pub enum OpInfo {
93
+ Operation {
94
+ name: &'static str,
95
+ idempotent: bool,
96
+ #[serde(skip_serializing_if = "Option::is_none")]
97
+ timeout_secs: Option<u32>,
98
+ },
99
+ Handler {
100
+ name: &'static str,
101
+ },
102
+ }
103
+
104
+ impl OpInfo {
105
+ pub fn operation<O: Operation>() -> Self {
106
+ OpInfo::Operation {
107
+ name: O::NAME,
108
+ idempotent: O::IDEMPOTENT,
109
+ timeout_secs: O::TIMEOUT_SECS,
110
+ }
111
+ }
112
+
113
+ pub fn handler<H: Handler>() -> Self {
114
+ OpInfo::Handler { name: H::NAME }
115
+ }
116
+ }
117
+
118
+ /// A set of operations exposed over the FFI. Implement it with
119
+ /// [`registry!`](crate::registry).
120
+ pub trait Registry {
121
+ fn dispatch(&self, op: &str, payload: &str) -> Option<Result<JsonValue>>;
122
+ fn operations(&self) -> Vec<OpInfo>;
123
+ }
124
+
125
+ /// Two registries exposed as one; `a` wins on name clashes.
126
+ #[derive(Debug, Clone, Copy, Default)]
127
+ pub struct Chain<A, B>(pub A, pub B);
128
+
129
+ impl<A: Registry, B: Registry> Registry for Chain<A, B> {
130
+ fn dispatch(&self, op: &str, payload: &str) -> Option<Result<JsonValue>> {
131
+ self.0
132
+ .dispatch(op, payload)
133
+ .or_else(|| self.1.dispatch(op, payload))
134
+ }
135
+
136
+ fn operations(&self) -> Vec<OpInfo> {
137
+ let mut ops = self.0.operations();
138
+ ops.extend(self.1.operations());
139
+ ops
140
+ }
141
+ }
142
+
143
+ /// Declares a unit struct implementing [`Registry`]:
144
+ ///
145
+ /// ```ignore
146
+ /// openhub_bo_core::registry!(pub OpenHub {
147
+ /// operations: [Token, qr::Generate, qr::Verify],
148
+ /// handlers: [qr::ParseWebhook],
149
+ /// });
150
+ /// ```
151
+ #[macro_export]
152
+ macro_rules! registry {
153
+ ($vis:vis $name:ident {
154
+ operations: [$($op:ty),* $(,)?],
155
+ handlers: [$($handler:ty),* $(,)?] $(,)?
156
+ }) => {
157
+ #[derive(Debug, Clone, Copy, Default)]
158
+ $vis struct $name;
159
+
160
+ impl $crate::Registry for $name {
161
+ fn dispatch(
162
+ &self,
163
+ op: &str,
164
+ payload: &str,
165
+ ) -> ::core::option::Option<$crate::Result<$crate::ffi::JsonValue>> {
166
+ $(
167
+ if let ::core::option::Option::Some(stage) = op
168
+ .strip_prefix(<$op as $crate::Operation>::NAME)
169
+ .and_then(|rest| rest.strip_prefix('.'))
170
+ {
171
+ return ::core::option::Option::Some($crate::ffi::run::<$op>(stage, payload));
172
+ }
173
+ )*
174
+ $(
175
+ if op == <$handler as $crate::Handler>::NAME {
176
+ return ::core::option::Option::Some($crate::ffi::run_handler::<$handler>(payload));
177
+ }
178
+ )*
179
+ ::core::option::Option::None
180
+ }
181
+
182
+ fn operations(&self) -> ::std::vec::Vec<$crate::OpInfo> {
183
+ ::std::vec![
184
+ $($crate::OpInfo::operation::<$op>(),)*
185
+ $($crate::OpInfo::handler::<$handler>(),)*
186
+ ]
187
+ }
188
+ }
189
+ };
190
+ }
@@ -0,0 +1,117 @@
1
+ //! Payment status shared by every product family. Each family maps its own
2
+ //! wire vocabulary through a [`StatusVocabulary`]; callers keep the original
3
+ //! value in `raw_status` for auditing.
4
+
5
+ use serde::{Deserialize, Serialize};
6
+
7
+ #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
8
+ #[serde(rename_all = "snake_case")]
9
+ pub enum PaymentStatus {
10
+ Pending,
11
+ Processing,
12
+ Paid,
13
+ Cancelled,
14
+ Expired,
15
+ Rejected,
16
+ Reversed,
17
+ Error,
18
+ /// Not in any documented vocabulary; see `raw_status`.
19
+ Unknown,
20
+ }
21
+
22
+ impl PaymentStatus {
23
+ /// No further transitions are expected from this state.
24
+ pub fn is_final(self) -> bool {
25
+ !matches!(
26
+ self,
27
+ PaymentStatus::Pending | PaymentStatus::Processing | PaymentStatus::Unknown
28
+ )
29
+ }
30
+ }
31
+
32
+ /// Maps a product family's raw status strings to [`PaymentStatus`].
33
+ pub trait StatusVocabulary {
34
+ fn map(raw: &str) -> PaymentStatus;
35
+ }
36
+
37
+ /// QR Simple / MLD-BCB `estado`.
38
+ pub struct SpanishQr;
39
+
40
+ /// PIX / virtual assets / Binance `codigoRespuesta`.
41
+ pub struct EnglishCodes;
42
+
43
+ /// Synchronous and asynchronous payouts `estado`.
44
+ pub struct Payout;
45
+
46
+ fn normalize(raw: &str) -> String {
47
+ raw.trim().to_ascii_uppercase()
48
+ }
49
+
50
+ impl StatusVocabulary for SpanishQr {
51
+ fn map(raw: &str) -> PaymentStatus {
52
+ match normalize(raw).as_str() {
53
+ "PENDIENTE" => PaymentStatus::Pending,
54
+ "PAGADO" => PaymentStatus::Paid,
55
+ "CANCELADO" => PaymentStatus::Cancelled,
56
+ "EXPIRADO" => PaymentStatus::Expired,
57
+ "ERROR" => PaymentStatus::Error,
58
+ _ => PaymentStatus::Unknown,
59
+ }
60
+ }
61
+ }
62
+
63
+ impl StatusVocabulary for EnglishCodes {
64
+ fn map(raw: &str) -> PaymentStatus {
65
+ match normalize(raw).as_str() {
66
+ "PENDING" => PaymentStatus::Pending,
67
+ "PAID" | "SUCCESS" => PaymentStatus::Paid,
68
+ "CANCELLED" => PaymentStatus::Cancelled,
69
+ "EXPIRED" => PaymentStatus::Expired,
70
+ "ERROR" => PaymentStatus::Error,
71
+ _ => PaymentStatus::Unknown,
72
+ }
73
+ }
74
+ }
75
+
76
+ impl StatusVocabulary for Payout {
77
+ fn map(raw: &str) -> PaymentStatus {
78
+ match normalize(raw).as_str() {
79
+ "PENDIENTE" | "PENDIENTE_PAGO" | "INICIALIZADO" => PaymentStatus::Pending,
80
+ "EN_PROCESO" | "PROCESO" | "ENVIADO" | "TRANSITO" | "PENDIENTE_CONFIRMACION" => {
81
+ PaymentStatus::Processing
82
+ }
83
+ "APROBADA" | "PAGADO" | "COMPLETADO" => PaymentStatus::Paid,
84
+ "RECHAZADA" | "RECHAZADO" => PaymentStatus::Rejected,
85
+ "REVERTIDO" => PaymentStatus::Reversed,
86
+ "CANCELADO" => PaymentStatus::Cancelled,
87
+ "ERROR" => PaymentStatus::Error,
88
+ _ => PaymentStatus::Unknown,
89
+ }
90
+ }
91
+ }
92
+
93
+ #[cfg(test)]
94
+ mod tests {
95
+ use super::*;
96
+
97
+ #[test]
98
+ fn maps_each_vocabulary() {
99
+ assert_eq!(SpanishQr::map(" pagado "), PaymentStatus::Paid);
100
+ assert_eq!(SpanishQr::map("NUEVO"), PaymentStatus::Unknown);
101
+ assert_eq!(EnglishCodes::map("SUCCESS"), PaymentStatus::Paid);
102
+ assert_eq!(EnglishCodes::map("CANCELLED"), PaymentStatus::Cancelled);
103
+ assert_eq!(
104
+ Payout::map("PENDIENTE_CONFIRMACION"),
105
+ PaymentStatus::Processing
106
+ );
107
+ assert_eq!(Payout::map("APROBADA"), PaymentStatus::Paid);
108
+ assert_eq!(Payout::map("REVERTIDO"), PaymentStatus::Reversed);
109
+ }
110
+
111
+ #[test]
112
+ fn finality() {
113
+ assert!(PaymentStatus::Paid.is_final());
114
+ assert!(!PaymentStatus::Processing.is_final());
115
+ assert!(!PaymentStatus::Unknown.is_final());
116
+ }
117
+ }
@@ -0,0 +1,170 @@
1
+ use std::collections::BTreeMap;
2
+ use std::fmt;
3
+
4
+ use serde::{Deserialize, Serialize};
5
+
6
+ use crate::config::Config;
7
+ use crate::envelope::message_from_body;
8
+ use crate::error::{Error, Result};
9
+ use crate::http::{HttpRequest, HttpResponse, Method};
10
+ use crate::operation::{Ctx, Operation};
11
+
12
+ /// Tokens are treated as expired this many seconds early to absorb clock
13
+ /// skew and request latency.
14
+ pub const EXPIRY_SKEW_SECS: i64 = 60;
15
+
16
+ const TOKEN_PATH: &str = "oauth-client-credentials/access-token?grant_type=client_credentials";
17
+
18
+ #[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
19
+ pub struct AccessToken {
20
+ pub access_token: String,
21
+ pub token_type: String,
22
+ /// Unix timestamp (seconds) after which the token must be refreshed.
23
+ pub expires_at: i64,
24
+ pub scope: Option<String>,
25
+ }
26
+
27
+ impl AccessToken {
28
+ pub fn is_valid(&self, now: i64) -> bool {
29
+ now < self.expires_at
30
+ }
31
+
32
+ pub(crate) fn authorization(&self) -> String {
33
+ format!("Bearer {}", self.access_token)
34
+ }
35
+ }
36
+
37
+ impl fmt::Debug for AccessToken {
38
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
39
+ f.debug_struct("AccessToken")
40
+ .field("access_token", &"***")
41
+ .field("token_type", &self.token_type)
42
+ .field("expires_at", &self.expires_at)
43
+ .field("scope", &self.scope)
44
+ .finish()
45
+ }
46
+ }
47
+
48
+ #[derive(Deserialize)]
49
+ struct TokenResponse {
50
+ access_token: String,
51
+ #[serde(default = "default_token_type")]
52
+ token_type: String,
53
+ #[serde(default = "default_expires_in")]
54
+ expires_in: i64,
55
+ #[serde(default)]
56
+ scope: Option<String>,
57
+ }
58
+
59
+ fn default_token_type() -> String {
60
+ "Bearer".to_owned()
61
+ }
62
+
63
+ fn default_expires_in() -> i64 {
64
+ 3600
65
+ }
66
+
67
+ /// OAuth 2.0 client-credentials request.
68
+ fn build_token_request(config: &Config) -> Result<HttpRequest> {
69
+ let headers = BTreeMap::from([
70
+ ("Authorization".to_owned(), config.basic_authorization()?),
71
+ (
72
+ "Content-Type".to_owned(),
73
+ "application/x-www-form-urlencoded".to_owned(),
74
+ ),
75
+ ("Accept".to_owned(), "application/json".to_owned()),
76
+ ]);
77
+ Ok(HttpRequest {
78
+ method: Method::Post,
79
+ url: config.url(TOKEN_PATH),
80
+ headers,
81
+ body: None,
82
+ })
83
+ }
84
+
85
+ fn parse_token_response(response: &HttpResponse, now: i64) -> Result<AccessToken> {
86
+ if !response.is_success() {
87
+ return Err(Error::Authentication {
88
+ status: response.status,
89
+ message: message_from_body(&response.body),
90
+ });
91
+ }
92
+ let parsed: TokenResponse = serde_json::from_str(&response.body)
93
+ .map_err(|e| Error::decode(format!("invalid token response: {e}")))?;
94
+ if parsed.access_token.is_empty() {
95
+ return Err(Error::decode("token response has an empty access_token"));
96
+ }
97
+ let lifetime = (parsed.expires_in - EXPIRY_SKEW_SECS).max(0);
98
+ Ok(AccessToken {
99
+ access_token: parsed.access_token,
100
+ token_type: parsed.token_type,
101
+ expires_at: now + lifetime,
102
+ scope: parsed.scope,
103
+ })
104
+ }
105
+
106
+ /// OAuth 2.0 client credentials. Sandbox answers HTTP 201 with
107
+ /// `token_type: "access_token"` and no `scope`.
108
+ pub struct Token;
109
+
110
+ #[derive(Debug, Clone, Deserialize)]
111
+ pub struct TokenInput {
112
+ /// Current Unix time in seconds, supplied by the host (the core has no clock).
113
+ pub now: i64,
114
+ }
115
+
116
+ impl Operation for Token {
117
+ const NAME: &'static str = "token";
118
+ const IDEMPOTENT: bool = true;
119
+
120
+ type Input = TokenInput;
121
+ type Output = AccessToken;
122
+
123
+ fn request(ctx: &Ctx<'_>, _input: &TokenInput) -> Result<HttpRequest> {
124
+ build_token_request(ctx.config)
125
+ }
126
+
127
+ fn response(response: &HttpResponse, input: &TokenInput) -> Result<AccessToken> {
128
+ parse_token_response(response, input.now)
129
+ }
130
+ }
131
+
132
+ #[cfg(test)]
133
+ mod tests {
134
+ use super::*;
135
+
136
+ const FIXTURE: &str = include_str!("../../../fixtures/openhub/token_response.json");
137
+
138
+ #[test]
139
+ fn builds_client_credentials_request() {
140
+ let request = build_token_request(&Config::new("id", "secret")).unwrap();
141
+ assert_eq!(request.method, Method::Post);
142
+ assert_eq!(
143
+ request.url,
144
+ "https://atcgwapitest.redenlace.com.bo/sandbox/oauth-client-credentials/access-token?grant_type=client_credentials"
145
+ );
146
+ assert_eq!(request.headers["Authorization"], "Basic aWQ6c2VjcmV0");
147
+ }
148
+
149
+ #[test]
150
+ fn parses_token_with_skew() {
151
+ let token = parse_token_response(&HttpResponse::new(200, FIXTURE), 1_000).unwrap();
152
+ assert_eq!(token.expires_at, 1_000 + 3600 - EXPIRY_SKEW_SECS);
153
+ assert_eq!(token.scope.as_deref(), Some("api.qr"));
154
+ assert!(token.is_valid(1_000));
155
+ assert!(!token.is_valid(token.expires_at));
156
+ }
157
+
158
+ #[test]
159
+ fn rejected_credentials_are_authentication_errors() {
160
+ let body = r#"{"error":"invalid_client","error_description":"Bad credentials"}"#;
161
+ let err = parse_token_response(&HttpResponse::new(401, body), 0).unwrap_err();
162
+ assert_eq!(
163
+ err,
164
+ Error::Authentication {
165
+ status: 401,
166
+ message: "Bad credentials".into()
167
+ }
168
+ );
169
+ }
170
+ }
@@ -0,0 +1,87 @@
1
+ //! Input checks shared by product families.
2
+
3
+ use crate::error::{Error, Result};
4
+
5
+ pub fn require_text(field: &str, value: &str) -> Result<()> {
6
+ if value.trim().is_empty() {
7
+ return Err(Error::validation(field, "must not be empty"));
8
+ }
9
+ Ok(())
10
+ }
11
+
12
+ /// OpenHub rejects non-numeric references (`INVALID_FORMAT`), and ATC's own
13
+ /// references are numeric; this also keeps URL paths safe.
14
+ pub fn numeric_reference(field: &str, value: &str) -> Result<()> {
15
+ require_text(field, value)?;
16
+ if !value.chars().all(|c| c.is_ascii_digit()) {
17
+ return Err(Error::validation(field, "must contain only digits"));
18
+ }
19
+ Ok(())
20
+ }
21
+
22
+ pub fn positive(field: &str, value: u64) -> Result<()> {
23
+ if value == 0 {
24
+ return Err(Error::validation(field, "must be greater than zero"));
25
+ }
26
+ Ok(())
27
+ }
28
+
29
+ /// Days since 1970-01-01 for a strict `yyyy-mm-dd` date.
30
+ pub fn iso_date_days(field: &str, value: &str) -> Result<i64> {
31
+ let invalid = || Error::validation(field, "must be a date as yyyy-mm-dd");
32
+ let bytes = value.as_bytes();
33
+ if bytes.len() != 10 || bytes[4] != b'-' || bytes[7] != b'-' {
34
+ return Err(invalid());
35
+ }
36
+ let num = |range: std::ops::Range<usize>| value[range].parse::<i64>().map_err(|_| invalid());
37
+ let (y, m, d) = (num(0..4)?, num(5..7)?, num(8..10)?);
38
+ let leap = (y % 4 == 0 && y % 100 != 0) || y % 400 == 0;
39
+ let month_days = [
40
+ 31,
41
+ if leap { 29 } else { 28 },
42
+ 31,
43
+ 30,
44
+ 31,
45
+ 30,
46
+ 31,
47
+ 31,
48
+ 30,
49
+ 31,
50
+ 30,
51
+ 31,
52
+ ];
53
+ if !(1..=12).contains(&m) || d < 1 || d > month_days[(m - 1) as usize] {
54
+ return Err(invalid());
55
+ }
56
+ // Howard Hinnant's days_from_civil.
57
+ let y = if m <= 2 { y - 1 } else { y };
58
+ let era = y.div_euclid(400);
59
+ let yoe = y - era * 400;
60
+ let mp = (m + 9) % 12;
61
+ let doy = (153 * mp + 2) / 5 + d - 1;
62
+ let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
63
+ Ok(era * 146_097 + doe - 719_468)
64
+ }
65
+
66
+ #[cfg(test)]
67
+ mod tests {
68
+ use super::*;
69
+
70
+ #[test]
71
+ fn strict_dates() {
72
+ assert_eq!(iso_date_days("d", "1970-01-01").unwrap(), 0);
73
+ assert_eq!(
74
+ iso_date_days("d", "2024-03-01").unwrap() - iso_date_days("d", "2024-02-28").unwrap(),
75
+ 2
76
+ );
77
+ for bad in [
78
+ "2026-02-30",
79
+ "2026-13-01",
80
+ "2026/10/06",
81
+ "06-10-2026",
82
+ "2026-1-6",
83
+ ] {
84
+ assert!(iso_date_days("d", bad).is_err(), "{bad}");
85
+ }
86
+ }
87
+ }