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.
- checksums.yaml +7 -0
- data/Cargo.lock +738 -0
- data/Cargo.toml +5 -0
- data/LICENSE +202 -0
- data/NOTICE +8 -0
- data/README.md +21 -0
- data/ext/openhub_bo_core/Cargo.toml +16 -0
- data/ext/openhub_bo_core/extconf.rb +6 -0
- data/ext/openhub_bo_core/src/lib.rs +29 -0
- data/ext/openhub_bo_core/vendor/openhub-bo-core/Cargo.toml +17 -0
- data/ext/openhub_bo_core/vendor/openhub-bo-core/src/amount.rs +120 -0
- data/ext/openhub_bo_core/vendor/openhub-bo-core/src/config.rs +149 -0
- data/ext/openhub_bo_core/vendor/openhub-bo-core/src/envelope.rs +374 -0
- data/ext/openhub_bo_core/vendor/openhub-bo-core/src/error.rs +123 -0
- data/ext/openhub_bo_core/vendor/openhub-bo-core/src/ffi.rs +178 -0
- data/ext/openhub_bo_core/vendor/openhub-bo-core/src/http.rs +40 -0
- data/ext/openhub_bo_core/vendor/openhub-bo-core/src/lib.rs +33 -0
- data/ext/openhub_bo_core/vendor/openhub-bo-core/src/operation.rs +190 -0
- data/ext/openhub_bo_core/vendor/openhub-bo-core/src/status.rs +117 -0
- data/ext/openhub_bo_core/vendor/openhub-bo-core/src/token.rs +170 -0
- data/ext/openhub_bo_core/vendor/openhub-bo-core/src/validate.rs +87 -0
- data/ext/openhub_bo_core/vendor/openhub-bo-core/src/webhook.rs +132 -0
- data/lib/openhub_bo/core/errors.rb +111 -0
- data/lib/openhub_bo/core/models.rb +102 -0
- data/lib/openhub_bo/core/native_bridge.rb +31 -0
- data/lib/openhub_bo/core/op.rb +50 -0
- data/lib/openhub_bo/core/session.rb +81 -0
- data/lib/openhub_bo/core/testing.rb +85 -0
- data/lib/openhub_bo/core/transport.rb +55 -0
- data/lib/openhub_bo/core/version.rb +9 -0
- data/lib/openhub_bo/core.rb +20 -0
- metadata +130 -0
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
//! Webhook pieces shared by every product family. ATC does not sign payloads:
|
|
2
|
+
//! it echoes the static header configured in [`WebhookTarget`]. Payload
|
|
3
|
+
//! parsing is family-specific and lives with each family.
|
|
4
|
+
|
|
5
|
+
use std::collections::BTreeMap;
|
|
6
|
+
|
|
7
|
+
use serde::{Deserialize, Serialize};
|
|
8
|
+
use subtle::ConstantTimeEq;
|
|
9
|
+
|
|
10
|
+
use crate::error::{Error, Result};
|
|
11
|
+
use crate::validate::require_text;
|
|
12
|
+
|
|
13
|
+
/// Where ATC must POST notifications, and the static header it will send so
|
|
14
|
+
/// the integrator can authenticate the call.
|
|
15
|
+
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
16
|
+
pub struct WebhookTarget {
|
|
17
|
+
pub url: String,
|
|
18
|
+
/// Header name, e.g. `x-api-key`.
|
|
19
|
+
pub key: String,
|
|
20
|
+
/// Header value; treat it as a secret.
|
|
21
|
+
pub value: String,
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
impl WebhookTarget {
|
|
25
|
+
pub fn validate(&self) -> Result<()> {
|
|
26
|
+
let url = self.url.trim();
|
|
27
|
+
if !(url.starts_with("https://") || url.starts_with("http://")) {
|
|
28
|
+
return Err(Error::validation("webhook.url", "must be an http(s) URL"));
|
|
29
|
+
}
|
|
30
|
+
require_text("webhook.key", &self.key)?;
|
|
31
|
+
if !self
|
|
32
|
+
.key
|
|
33
|
+
.chars()
|
|
34
|
+
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
|
|
35
|
+
{
|
|
36
|
+
return Err(Error::validation(
|
|
37
|
+
"webhook.key",
|
|
38
|
+
"must be a valid HTTP header name",
|
|
39
|
+
));
|
|
40
|
+
}
|
|
41
|
+
require_text("webhook.value", &self.value)
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/// An incoming webhook call as received by the integrator, plus the header
|
|
46
|
+
/// name/value configured when the payment was created. Input of every
|
|
47
|
+
/// family's webhook handler.
|
|
48
|
+
#[derive(Debug, Clone, Deserialize)]
|
|
49
|
+
pub struct WebhookCall {
|
|
50
|
+
pub headers: BTreeMap<String, String>,
|
|
51
|
+
pub body: String,
|
|
52
|
+
/// Header name and value sent as `webhook.key` / `webhook.value`.
|
|
53
|
+
pub key: String,
|
|
54
|
+
pub value: String,
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
impl WebhookCall {
|
|
58
|
+
pub fn authenticate(&self) -> Result<()> {
|
|
59
|
+
authenticate(&self.headers, &self.key, &self.value)
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/// Checks the auth header of an incoming webhook: name case-insensitive,
|
|
64
|
+
/// value in constant time.
|
|
65
|
+
pub fn authenticate(headers: &BTreeMap<String, String>, key: &str, value: &str) -> Result<()> {
|
|
66
|
+
if value.is_empty() {
|
|
67
|
+
return Err(Error::validation(
|
|
68
|
+
"value",
|
|
69
|
+
"expected webhook header value must not be empty",
|
|
70
|
+
));
|
|
71
|
+
}
|
|
72
|
+
let received = headers
|
|
73
|
+
.iter()
|
|
74
|
+
.find(|(name, _)| name.eq_ignore_ascii_case(key))
|
|
75
|
+
.map(|(_, v)| v.as_str())
|
|
76
|
+
.ok_or_else(|| Error::WebhookAuth {
|
|
77
|
+
message: format!("missing `{key}` header"),
|
|
78
|
+
})?;
|
|
79
|
+
verify_secret(&format!("`{key}` header"), received, value)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/// Constant-time comparison of a received secret (header, query token...).
|
|
83
|
+
pub fn verify_secret(what: &str, received: &str, expected: &str) -> Result<()> {
|
|
84
|
+
if expected.is_empty() {
|
|
85
|
+
return Err(Error::validation(
|
|
86
|
+
"value",
|
|
87
|
+
"expected secret must not be empty",
|
|
88
|
+
));
|
|
89
|
+
}
|
|
90
|
+
if !bool::from(received.as_bytes().ct_eq(expected.as_bytes())) {
|
|
91
|
+
return Err(Error::WebhookAuth {
|
|
92
|
+
message: format!("{what} does not match"),
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
Ok(())
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
#[cfg(test)]
|
|
99
|
+
mod tests {
|
|
100
|
+
use super::*;
|
|
101
|
+
|
|
102
|
+
fn headers(name: &str, value: &str) -> BTreeMap<String, String> {
|
|
103
|
+
BTreeMap::from([(name.to_owned(), value.to_owned())])
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
#[test]
|
|
107
|
+
fn authenticates_case_insensitively() {
|
|
108
|
+
assert!(authenticate(&headers("X-API-KEY", "s3cret"), "x-api-key", "s3cret").is_ok());
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
#[test]
|
|
112
|
+
fn rejects_wrong_or_missing_header() {
|
|
113
|
+
for h in [headers("x-api-key", "nope"), headers("other", "s3cret")] {
|
|
114
|
+
assert!(matches!(
|
|
115
|
+
authenticate(&h, "x-api-key", "s3cret"),
|
|
116
|
+
Err(Error::WebhookAuth { .. })
|
|
117
|
+
));
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
#[test]
|
|
122
|
+
fn validates_target() {
|
|
123
|
+
let target = |url: &str, key: &str| WebhookTarget {
|
|
124
|
+
url: url.into(),
|
|
125
|
+
key: key.into(),
|
|
126
|
+
value: "v".into(),
|
|
127
|
+
};
|
|
128
|
+
assert!(target("https://a.bo/h", "x-api-key").validate().is_ok());
|
|
129
|
+
assert!(target("ftp://a.bo", "x-api-key").validate().is_err());
|
|
130
|
+
assert!(target("https://a.bo", "bad key").validate().is_err());
|
|
131
|
+
}
|
|
132
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenhubBo
|
|
4
|
+
module Core
|
|
5
|
+
ApiFieldError = Data.define(:message, :field, :code)
|
|
6
|
+
|
|
7
|
+
# Base class for every error raised by openhub-bo.
|
|
8
|
+
class Error < StandardError
|
|
9
|
+
# Whether repeating the same call later may succeed.
|
|
10
|
+
def retryable? = false
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# Input rejected locally, before calling OpenHub.
|
|
14
|
+
class ValidationError < Error
|
|
15
|
+
attr_reader :field
|
|
16
|
+
|
|
17
|
+
def initialize(message, field:)
|
|
18
|
+
super("invalid `#{field}`: #{message}")
|
|
19
|
+
@field = field
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Credentials or access token rejected by OpenHub.
|
|
24
|
+
class AuthenticationError < Error
|
|
25
|
+
attr_reader :status
|
|
26
|
+
|
|
27
|
+
def initialize(message, status:)
|
|
28
|
+
super("authentication failed (HTTP #{status}): #{message}")
|
|
29
|
+
@status = status
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# OpenHub answered with an error.
|
|
34
|
+
class ApiError < Error
|
|
35
|
+
attr_reader :status, :errors
|
|
36
|
+
|
|
37
|
+
def initialize(message, status:, errors: [], retryable: false)
|
|
38
|
+
super("OpenHub error (HTTP #{status}): #{message}")
|
|
39
|
+
@status = status
|
|
40
|
+
@errors = errors
|
|
41
|
+
@retryable = retryable
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def retryable? = @retryable
|
|
45
|
+
|
|
46
|
+
# Code of the first field error, e.g. "TRANSACCION_NO_ENCONTRADA".
|
|
47
|
+
def code = errors.map(&:code).compact.first
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# The response could not be understood.
|
|
51
|
+
class DecodeError < Error; end
|
|
52
|
+
|
|
53
|
+
# Incoming webhook did not carry the expected secret.
|
|
54
|
+
class WebhookAuthError < Error; end
|
|
55
|
+
|
|
56
|
+
# Network failure. `maybe_sent` is false when the request never left
|
|
57
|
+
# (connection refused, connect timeout): retrying is then always safe.
|
|
58
|
+
class TransportError < Error
|
|
59
|
+
attr_reader :maybe_sent
|
|
60
|
+
|
|
61
|
+
def initialize(message, maybe_sent:)
|
|
62
|
+
super(message)
|
|
63
|
+
@maybe_sent = maybe_sent
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def retryable? = true
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# A non-idempotent call (generating a QR, paying a payout...) may or may not
|
|
70
|
+
# have been applied: the response was lost, or OpenHub reported the result as
|
|
71
|
+
# unconfirmed (payout codes 94/96). Deliberately not a TransportError so
|
|
72
|
+
# generic retry loops do not repeat it. Query the status before retrying.
|
|
73
|
+
class AmbiguousOutcomeError < Error
|
|
74
|
+
attr_reader :operation, :errors
|
|
75
|
+
|
|
76
|
+
def initialize(operation, cause, errors: [])
|
|
77
|
+
super("outcome of `#{operation}` is unknown: #{cause}")
|
|
78
|
+
@operation = operation
|
|
79
|
+
@errors = errors
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def code = errors.map(&:code).compact.first
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Contract violation between a gem and its native extension (a bug).
|
|
86
|
+
class CoreError < Error; end
|
|
87
|
+
|
|
88
|
+
module_function
|
|
89
|
+
|
|
90
|
+
# Maps an error from the native core; `op` is the native op name
|
|
91
|
+
# (e.g. "payouts.pay.parse").
|
|
92
|
+
def error_from_core(data, op = "")
|
|
93
|
+
kind = data["kind"]
|
|
94
|
+
message = data.fetch("message", "unknown error")
|
|
95
|
+
errors = Array(data["errors"]).map do |e|
|
|
96
|
+
ApiFieldError.new(message: e["message"], field: e["field"], code: e["code"])
|
|
97
|
+
end
|
|
98
|
+
case kind
|
|
99
|
+
when "validation" then ValidationError.new(message, field: data["field"])
|
|
100
|
+
when "authentication" then AuthenticationError.new(message, status: data["status"])
|
|
101
|
+
when "api"
|
|
102
|
+
ApiError.new(message, status: data["status"], errors: errors, retryable: data["retryable"] == true)
|
|
103
|
+
when "ambiguous"
|
|
104
|
+
AmbiguousOutcomeError.new(op.sub(/\.(build|parse)\z/, ""), message, errors: errors)
|
|
105
|
+
when "decode" then DecodeError.new(message)
|
|
106
|
+
when "webhook_auth" then WebhookAuthError.new(message)
|
|
107
|
+
else CoreError.new(message)
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "date"
|
|
4
|
+
require "time"
|
|
5
|
+
|
|
6
|
+
module OpenhubBo
|
|
7
|
+
module Core
|
|
8
|
+
module Environment
|
|
9
|
+
DEVELOPMENT = :development
|
|
10
|
+
SANDBOX = :sandbox
|
|
11
|
+
PRODUCTION = :production
|
|
12
|
+
ALL = [DEVELOPMENT, SANDBOX, PRODUCTION].freeze
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Status shared by every product family (symbols); `raw_status` keeps the original.
|
|
16
|
+
module PaymentStatus
|
|
17
|
+
ALL = %i[pending processing paid cancelled expired rejected reversed error unknown].freeze
|
|
18
|
+
NOT_FINAL = %i[pending processing unknown].freeze
|
|
19
|
+
|
|
20
|
+
def self.final?(status) = !NOT_FINAL.include?(status)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# OpenHub timestamps carry no offset: they are Bolivia time.
|
|
24
|
+
BOLIVIA_OFFSET = "-04:00"
|
|
25
|
+
|
|
26
|
+
# Where ATC must POST notifications. ATC sends `key: value` as a header; it is
|
|
27
|
+
# the only authentication, so use a long random value.
|
|
28
|
+
Webhook = Data.define(:url, :value, :key) do
|
|
29
|
+
def initialize(url:, value:, key: "x-api-key") = super
|
|
30
|
+
|
|
31
|
+
def to_wire = { "url" => url, "key" => key, "value" => value }
|
|
32
|
+
|
|
33
|
+
def inspect = "#<#{self.class.name} url=#{url.inspect} key=#{key.inspect} value=[FILTERED]>"
|
|
34
|
+
alias_method :to_s, :inspect
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
module_function
|
|
38
|
+
|
|
39
|
+
# Amounts cross the native boundary as exact decimal strings.
|
|
40
|
+
def amount(value)
|
|
41
|
+
case value
|
|
42
|
+
when BigDecimal then value.to_s("F")
|
|
43
|
+
when Integer then value.to_s
|
|
44
|
+
when String then value
|
|
45
|
+
else
|
|
46
|
+
raise TypeError,
|
|
47
|
+
"amount must be BigDecimal, Integer or String, not #{value.class} " \
|
|
48
|
+
"(floats are rejected to avoid rounding errors)"
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def seconds(value)
|
|
53
|
+
raise TypeError, "expected Integer seconds, not #{value.class}" unless value.is_a?(Integer)
|
|
54
|
+
|
|
55
|
+
value
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def iso_date(value) = value.is_a?(Date) ? value.iso8601 : value
|
|
59
|
+
|
|
60
|
+
def decimal(value) = value.nil? ? nil : BigDecimal(value)
|
|
61
|
+
|
|
62
|
+
# Parses an OpenHub timestamp, assuming Bolivia time when it has no offset.
|
|
63
|
+
def local_time(value)
|
|
64
|
+
return nil if value.nil? || value.empty?
|
|
65
|
+
|
|
66
|
+
has_offset = value.match?(/(Z|[+-]\d{2}:?\d{2})\z/)
|
|
67
|
+
Time.iso8601(has_offset ? value : value.tr(" ", "T") + BOLIVIA_OFFSET)
|
|
68
|
+
rescue ArgumentError
|
|
69
|
+
nil
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Defines an immutable value class from the core's snake_case JSON.
|
|
73
|
+
#
|
|
74
|
+
# Payer = Core.model(:name, :document_id)
|
|
75
|
+
# Qr = Core.model(:reference, :amount, :payer, casts: { amount: :decimal, payer: Payer })
|
|
76
|
+
#
|
|
77
|
+
# Casts: :decimal (BigDecimal), :symbol, :time (Bolivia time), a model class,
|
|
78
|
+
# or [ModelClass] for lists.
|
|
79
|
+
def model(*fields, casts: {}, &block)
|
|
80
|
+
klass = Data.define(*fields, &block)
|
|
81
|
+
klass.define_singleton_method(:from_h) do |hash|
|
|
82
|
+
return nil if hash.nil?
|
|
83
|
+
|
|
84
|
+
attrs = fields.to_h { |field| [field, Core.cast(hash[field.to_s], casts[field])] }
|
|
85
|
+
new(**attrs)
|
|
86
|
+
end
|
|
87
|
+
klass
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def cast(value, how)
|
|
91
|
+
return value if how.nil? || value.nil?
|
|
92
|
+
|
|
93
|
+
case how
|
|
94
|
+
when :decimal then BigDecimal(value.to_s)
|
|
95
|
+
when :symbol then value.to_sym
|
|
96
|
+
when :time then local_time(value)
|
|
97
|
+
when Array then value.map { |item| how.first.from_h(item) }
|
|
98
|
+
else how.from_h(value)
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenhubBo
|
|
4
|
+
module Core
|
|
5
|
+
# A native extension exposing `call(op, payload) -> String`. Checks the
|
|
6
|
+
# protocol on load and caches the operation catalog from `describe`, so
|
|
7
|
+
# declared operations are validated when the gem is required.
|
|
8
|
+
class NativeBridge
|
|
9
|
+
attr_reader :operations, :version
|
|
10
|
+
|
|
11
|
+
def initialize(mod)
|
|
12
|
+
protocol = mod.protocol_version
|
|
13
|
+
unless protocol == SUPPORTED_PROTOCOL
|
|
14
|
+
raise LoadError,
|
|
15
|
+
"#{mod} speaks core protocol #{protocol}, this gem expects #{SUPPORTED_PROTOCOL}; " \
|
|
16
|
+
"install matching openhub-bo versions"
|
|
17
|
+
end
|
|
18
|
+
@mod = mod
|
|
19
|
+
@version = mod.version
|
|
20
|
+
@operations = invoke("describe", {}).to_h { |entry| [entry["name"], entry] }.freeze
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def invoke(op, payload)
|
|
24
|
+
result = JSON.parse(@mod.call(op, JSON.generate(payload)))
|
|
25
|
+
return result["value"] if result["ok"]
|
|
26
|
+
|
|
27
|
+
raise Core.error_from_core(result["error"], op)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenhubBo
|
|
4
|
+
module Core
|
|
5
|
+
# An HTTP endpoint implemented by a native extension. Declaring one with a
|
|
6
|
+
# name the extension does not know raises LoadError at require time.
|
|
7
|
+
class Op
|
|
8
|
+
attr_reader :native, :name
|
|
9
|
+
|
|
10
|
+
def initialize(native, name, &decode)
|
|
11
|
+
info = native.operations[name]
|
|
12
|
+
raise LoadError, "native extension has no operation `#{name}`" unless info && info["kind"] == "operation"
|
|
13
|
+
|
|
14
|
+
@native = native
|
|
15
|
+
@name = name
|
|
16
|
+
@decode = decode || ->(value) { value }
|
|
17
|
+
@info = info
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def idempotent? = @info["idempotent"] == true
|
|
21
|
+
|
|
22
|
+
# Client timeout (seconds) recommended by ATC for this endpoint, if any.
|
|
23
|
+
def timeout = @info["timeout_secs"]
|
|
24
|
+
|
|
25
|
+
def build(config, token, input)
|
|
26
|
+
request = native.invoke("#{name}.build", { "config" => config, "token" => token, "input" => input })
|
|
27
|
+
request["timeout"] = timeout if timeout
|
|
28
|
+
request
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def parse(input, response)
|
|
32
|
+
@decode.call(native.invoke("#{name}.parse", { "input" => input, "response" => response.to_wire }))
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# A pure native function (no HTTP), e.g. webhook parsing.
|
|
37
|
+
class Handler
|
|
38
|
+
def initialize(native, name, &decode)
|
|
39
|
+
info = native.operations[name]
|
|
40
|
+
raise LoadError, "native extension has no handler `#{name}`" unless info && info["kind"] == "handler"
|
|
41
|
+
|
|
42
|
+
@native = native
|
|
43
|
+
@name = name
|
|
44
|
+
@decode = decode || ->(value) { value }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def call(input) = @decode.call(@native.invoke(@name, input))
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenhubBo
|
|
4
|
+
module Core
|
|
5
|
+
NATIVE = NativeBridge.new(Native)
|
|
6
|
+
TOKEN = Op.new(NATIVE, "token")
|
|
7
|
+
|
|
8
|
+
# Credentials, token cache and execution of operations. Share one session
|
|
9
|
+
# between product clients (QrClient.new(session), PixClient.new(session)...)
|
|
10
|
+
# so they reuse the OAuth token. Thread-safe.
|
|
11
|
+
class Session
|
|
12
|
+
attr_reader :environment
|
|
13
|
+
|
|
14
|
+
def initialize(client_id, client_secret = nil, basic_token: nil, environment: Environment::SANDBOX,
|
|
15
|
+
base_url: nil, timeout: 30, transport: nil)
|
|
16
|
+
@environment = environment.to_sym
|
|
17
|
+
unless Environment::ALL.include?(@environment)
|
|
18
|
+
raise ValidationError.new("must be one of #{Environment::ALL.join(', ')}", field: "environment")
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
@config = {
|
|
22
|
+
"client_id" => client_id,
|
|
23
|
+
"client_secret" => client_secret,
|
|
24
|
+
"basic_token" => basic_token,
|
|
25
|
+
"environment" => @environment.to_s,
|
|
26
|
+
"base_url" => base_url
|
|
27
|
+
}
|
|
28
|
+
@transport = transport || NetHttpTransport.new(timeout: timeout)
|
|
29
|
+
@token = nil
|
|
30
|
+
@lock = Mutex.new
|
|
31
|
+
# Fail fast on missing credentials instead of at the first request.
|
|
32
|
+
TOKEN.build(@config, nil, token_input)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Runs `op`: token, build, send, parse; refreshes a rejected token once.
|
|
36
|
+
def execute(op, input)
|
|
37
|
+
2.times do |attempt|
|
|
38
|
+
token = ensure_token
|
|
39
|
+
response = deliver(op, op.build(@config, token, input))
|
|
40
|
+
begin
|
|
41
|
+
return op.parse(input, response)
|
|
42
|
+
rescue AuthenticationError
|
|
43
|
+
raise if attempt == 1
|
|
44
|
+
|
|
45
|
+
invalidate(token)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def close = @transport.close
|
|
51
|
+
|
|
52
|
+
def inspect = "#<#{self.class.name} client_id=#{@config['client_id'].inspect} environment=#{@environment}>"
|
|
53
|
+
alias_method :to_s, :inspect
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def ensure_token
|
|
58
|
+
@lock.synchronize do
|
|
59
|
+
token = @token
|
|
60
|
+
return token if token && Time.now.to_i < token["expires_at"]
|
|
61
|
+
|
|
62
|
+
@token = TOKEN.parse(token_input, deliver(TOKEN, TOKEN.build(@config, nil, token_input)))
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def invalidate(token)
|
|
67
|
+
@lock.synchronize { @token = nil if @token && @token["access_token"] == token["access_token"] }
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def deliver(op, request)
|
|
71
|
+
@transport.perform(request)
|
|
72
|
+
rescue TransportError => e
|
|
73
|
+
raise AmbiguousOutcomeError.new(op.name, e) if e.maybe_sent && !op.idempotent?
|
|
74
|
+
|
|
75
|
+
raise
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def token_input = { "now" => Time.now.to_i }
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
require "openhub_bo/core"
|
|
5
|
+
|
|
6
|
+
module OpenhubBo
|
|
7
|
+
module Core
|
|
8
|
+
module Testing
|
|
9
|
+
# In-memory fake of the OpenHub gateway (Sensedia): handles the OAuth token
|
|
10
|
+
# and the access_token/client_id check like the real gateway; tests or
|
|
11
|
+
# product gems register routes on top. Use it as a Session transport:
|
|
12
|
+
#
|
|
13
|
+
# gateway = MockGateway.new
|
|
14
|
+
# gateway.route("GET", %r{/qr/simple/v2/verify/(\d+)\z}) { |req, m| Response.new(status: 200, body: "...") }
|
|
15
|
+
# session = gateway.session
|
|
16
|
+
class MockGateway
|
|
17
|
+
BASE_URL = "https://openhub.mock"
|
|
18
|
+
TOKEN_PATH = "/oauth-client-credentials/access-token"
|
|
19
|
+
|
|
20
|
+
attr_reader :client_id, :client_secret, :requests, :token_requests
|
|
21
|
+
|
|
22
|
+
def initialize(client_id: "test-client", client_secret: "test-secret", token_ttl: 3600)
|
|
23
|
+
@client_id = client_id
|
|
24
|
+
@client_secret = client_secret
|
|
25
|
+
@token_ttl = token_ttl
|
|
26
|
+
@requests = []
|
|
27
|
+
@token_requests = 0
|
|
28
|
+
@tokens = []
|
|
29
|
+
@routes = []
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def route(method, pattern, &handler)
|
|
33
|
+
@routes << [method.to_s.upcase, pattern, handler]
|
|
34
|
+
self
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Answers matching requests with a fixed status and body.
|
|
38
|
+
def stub(method, pattern, status:, body:)
|
|
39
|
+
route(method, pattern) { Response.new(status: status, body: body) }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def session(client_secret: @client_secret, **kwargs)
|
|
43
|
+
Session.new(@client_id, client_secret, base_url: BASE_URL, transport: self, **kwargs)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def revoke_tokens = @tokens.clear
|
|
47
|
+
|
|
48
|
+
def perform(request)
|
|
49
|
+
@requests << request
|
|
50
|
+
path = URI(request["url"]).path
|
|
51
|
+
return token_response(request) if path.end_with?(TOKEN_PATH)
|
|
52
|
+
return Response.new(status: 401, body: "Access Token in the request, identified by HEADER access_token, is invalid.") unless authorized?(request)
|
|
53
|
+
|
|
54
|
+
@routes.each do |method, pattern, handler|
|
|
55
|
+
next unless request["method"] == method && (match = pattern.match(path))
|
|
56
|
+
|
|
57
|
+
return handler.call(request, match)
|
|
58
|
+
end
|
|
59
|
+
Response.new(status: 404, body: JSON.generate({ "success" => false, "message" => "Not found", "errors" => [] }))
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def close; end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
def token_response(request)
|
|
67
|
+
@token_requests += 1
|
|
68
|
+
expected = "Basic #{["#{@client_id}:#{@client_secret}"].pack("m0")}"
|
|
69
|
+
unless request["headers"]["Authorization"] == expected
|
|
70
|
+
return Response.new(status: 401, body: JSON.generate({ "error" => "invalid_client", "error_description" => "Bad credentials" }))
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
token = SecureRandom.uuid
|
|
74
|
+
@tokens << token
|
|
75
|
+
# Sandbox answers 201 with token_type "access_token" and no scope.
|
|
76
|
+
Response.new(status: 201, body: JSON.generate({ "access_token" => token, "token_type" => "access_token", "expires_in" => @token_ttl }))
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def authorized?(request)
|
|
80
|
+
@tokens.include?(request["headers"]["access_token"]) && request["headers"]["client_id"] == @client_id
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "openssl"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
module OpenhubBo
|
|
8
|
+
module Core
|
|
9
|
+
Response = Data.define(:status, :body) do
|
|
10
|
+
def to_wire = { "status" => status, "body" => body }
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# Sends requests built by the native core. Any object responding to
|
|
14
|
+
# `perform(request) -> Response` works: inject your own for proxies,
|
|
15
|
+
# logging or fakes. `request` is {"method", "url", "headers", "body",
|
|
16
|
+
# "timeout"?}; raise TransportError on network failures.
|
|
17
|
+
class NetHttpTransport
|
|
18
|
+
# Failures where the request provably never reached the server.
|
|
19
|
+
NOT_SENT = [
|
|
20
|
+
Errno::ECONNREFUSED, Errno::EHOSTUNREACH, Errno::ENETUNREACH, Net::OpenTimeout, SocketError
|
|
21
|
+
].freeze
|
|
22
|
+
MAYBE_SENT = [
|
|
23
|
+
Net::ReadTimeout, Net::WriteTimeout, EOFError, IOError, Errno::ECONNRESET, Errno::EPIPE,
|
|
24
|
+
OpenSSL::SSL::SSLError
|
|
25
|
+
].freeze
|
|
26
|
+
|
|
27
|
+
def initialize(timeout: 30, open_timeout: 10)
|
|
28
|
+
@timeout = timeout
|
|
29
|
+
@open_timeout = open_timeout
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def perform(request)
|
|
33
|
+
uri = URI(request["url"])
|
|
34
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
35
|
+
http.use_ssl = uri.scheme == "https"
|
|
36
|
+
http.open_timeout = @open_timeout
|
|
37
|
+
http.read_timeout = request["timeout"] || @timeout
|
|
38
|
+
http.write_timeout = request["timeout"] || @timeout
|
|
39
|
+
body = request["body"]
|
|
40
|
+
message = Net::HTTPGenericRequest.new(
|
|
41
|
+
request["method"], !body.nil?, true, uri.request_uri, request["headers"]
|
|
42
|
+
)
|
|
43
|
+
message.body = body if body
|
|
44
|
+
response = http.start { |conn| conn.request(message) }
|
|
45
|
+
Response.new(status: response.code.to_i, body: response.body.to_s.dup.force_encoding(Encoding::UTF_8))
|
|
46
|
+
rescue *NOT_SENT => e
|
|
47
|
+
raise TransportError.new("#{e.class}: #{e.message}", maybe_sent: false)
|
|
48
|
+
rescue *MAYBE_SENT => e
|
|
49
|
+
raise TransportError.new("#{e.class}: #{e.message}", maybe_sent: true)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def close; end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Shared foundation of the openhub-bo gems: session, transport, errors and
|
|
4
|
+
# common types for the Red Enlace (ATC) OpenHub APIs.
|
|
5
|
+
require "bigdecimal"
|
|
6
|
+
require "json"
|
|
7
|
+
|
|
8
|
+
require_relative "core/version"
|
|
9
|
+
require_relative "core/errors"
|
|
10
|
+
require_relative "core/models"
|
|
11
|
+
require_relative "core/native_bridge"
|
|
12
|
+
require_relative "core/op"
|
|
13
|
+
require_relative "core/transport"
|
|
14
|
+
begin
|
|
15
|
+
# Precompiled gems ship one binary per Ruby minor version.
|
|
16
|
+
require "openhub_bo/core/#{RUBY_VERSION[/\d+\.\d+/]}/openhub_bo_core"
|
|
17
|
+
rescue LoadError
|
|
18
|
+
require "openhub_bo/core/openhub_bo_core"
|
|
19
|
+
end
|
|
20
|
+
require_relative "core/session"
|