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,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,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenhubBo
4
+ module Core
5
+ VERSION = "0.1.0"
6
+ # Core FFI protocol this Ruby code speaks (see `openhub_bo_core::ffi`).
7
+ SUPPORTED_PROTOCOL = 2
8
+ end
9
+ 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"