sendmux-core 1.0.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/CHANGELOG.md +12 -0
- data/README.md +3 -0
- data/lib/sendmux/core/api_key_surface.rb +10 -0
- data/lib/sendmux/core/auth.rb +48 -0
- data/lib/sendmux/core/errors.rb +103 -0
- data/lib/sendmux/core/headers.rb +26 -0
- data/lib/sendmux/core/pagination.rb +64 -0
- data/lib/sendmux/core/retry.rb +31 -0
- data/lib/sendmux/core/retry_options.rb +39 -0
- data/lib/sendmux/core/version.rb +7 -0
- data/lib/sendmux/core.rb +10 -0
- metadata +87 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 5ec69806267556e76727d816bb6d01054bc5c77434a04596d02975252059ab2a
|
|
4
|
+
data.tar.gz: a1872633a358fbd8c9d63f62e28d1ff4a9ef593ace894586fa3d824b6605c597
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: a0e6497dc16e4b7900822a2f95c773c6ca8d555dc06eb10ac421646622ec9fc9f351768df70d2ade31553ef45711892f8ced4ea5981adc19399d42700a64ef37
|
|
7
|
+
data.tar.gz: 7540b87b27243eb36500d0afb1659162e76afe2aebb3f59f3d48aa7387b2f63203d0377297dc1c01d1412257301fc48d645b6246ba7f9f92675f68e3f522d6df
|
data/CHANGELOG.md
ADDED
data/README.md
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'uri'
|
|
4
|
+
|
|
5
|
+
module Sendmux
|
|
6
|
+
module Core
|
|
7
|
+
class Auth
|
|
8
|
+
ROOT_PREFIX = 'smx_root_'
|
|
9
|
+
MAILBOX_PREFIX = 'smx_mbx_'
|
|
10
|
+
|
|
11
|
+
def self.configure_bearer(configuration, api_key, expected_surface, base_url: nil)
|
|
12
|
+
assert_api_key_surface(api_key, expected_surface)
|
|
13
|
+
unless configuration.respond_to?(:access_token=)
|
|
14
|
+
raise ArgumentError, 'Generated configuration does not support bearer access tokens'
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
configuration.access_token = api_key
|
|
18
|
+
configure_base_url(configuration, base_url) if base_url && !base_url.empty?
|
|
19
|
+
configuration
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def self.assert_api_key_surface(api_key, expected_surface)
|
|
23
|
+
actual = surface_for(api_key)
|
|
24
|
+
raise ArgumentError, "Sendmux API keys must start with #{ROOT_PREFIX} or #{MAILBOX_PREFIX}" unless actual
|
|
25
|
+
return actual if actual == expected_surface
|
|
26
|
+
|
|
27
|
+
raise ArgumentError, "Expected a #{expected_surface} API key, received a #{actual} API key"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def self.surface_for(api_key)
|
|
31
|
+
return ApiKeySurface::ROOT if api_key.start_with?(ROOT_PREFIX)
|
|
32
|
+
return ApiKeySurface::MAILBOX if api_key.start_with?(MAILBOX_PREFIX)
|
|
33
|
+
|
|
34
|
+
nil
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def self.configure_base_url(configuration, base_url)
|
|
38
|
+
uri = URI(base_url)
|
|
39
|
+
raise ArgumentError, 'base_url must include a scheme and host' unless uri.scheme && uri.host
|
|
40
|
+
|
|
41
|
+
configuration.scheme = uri.scheme
|
|
42
|
+
configuration.host = uri.host
|
|
43
|
+
configuration.base_path = uri.path
|
|
44
|
+
configuration.ignore_operation_servers = true if configuration.respond_to?(:ignore_operation_servers=)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module Sendmux
|
|
6
|
+
module Core
|
|
7
|
+
class ApiError < StandardError
|
|
8
|
+
attr_reader :status, :code, :request_id, :param, :retryable, :errors, :response_body, :response_headers
|
|
9
|
+
|
|
10
|
+
def initialize(status:, code:, message:, retryable:, request_id: nil, param: nil, errors: nil,
|
|
11
|
+
response_body: nil, response_headers: {})
|
|
12
|
+
super(message)
|
|
13
|
+
@status = status
|
|
14
|
+
@code = code
|
|
15
|
+
@request_id = request_id
|
|
16
|
+
@param = param
|
|
17
|
+
@retryable = retryable
|
|
18
|
+
@errors = errors
|
|
19
|
+
@response_body = response_body
|
|
20
|
+
@response_headers = response_headers
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
class ErrorMapper
|
|
25
|
+
def self.map(error)
|
|
26
|
+
return error if error.is_a?(ApiError)
|
|
27
|
+
|
|
28
|
+
headers = normalise_headers(error.respond_to?(:response_headers) ? error.response_headers : {})
|
|
29
|
+
body = error.respond_to?(:response_body) ? error.response_body : nil
|
|
30
|
+
payload = payload_from(body)
|
|
31
|
+
detail = hash_at(payload, 'error') || {}
|
|
32
|
+
status = status_from(error)
|
|
33
|
+
|
|
34
|
+
ApiError.new(
|
|
35
|
+
status: status,
|
|
36
|
+
code: string_at(detail, 'code') || 'api_error',
|
|
37
|
+
message: string_at(detail, 'message') || error.message || 'Sendmux API request failed',
|
|
38
|
+
retryable: retryable(detail, status),
|
|
39
|
+
request_id: request_id(payload, headers),
|
|
40
|
+
param: string_at(detail, 'param'),
|
|
41
|
+
errors: detail['errors'],
|
|
42
|
+
response_body: body,
|
|
43
|
+
response_headers: headers
|
|
44
|
+
)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def self.status_from(error)
|
|
48
|
+
return error.code if error.respond_to?(:code) && error.code.is_a?(Integer) && error.code.positive?
|
|
49
|
+
|
|
50
|
+
nil
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def self.payload_from(body)
|
|
54
|
+
return nil unless body.is_a?(String) && !body.empty?
|
|
55
|
+
|
|
56
|
+
JSON.parse(body)
|
|
57
|
+
rescue JSON::ParserError
|
|
58
|
+
nil
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def self.request_id(payload, headers)
|
|
62
|
+
string_at(hash_at(payload, 'meta'), 'request_id') || header(headers, 'x-request-id')
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def self.normalise_headers(headers)
|
|
66
|
+
return {} unless headers.respond_to?(:each)
|
|
67
|
+
|
|
68
|
+
headers.each_with_object({}) do |(key, value), result|
|
|
69
|
+
result[key.to_s] = value.is_a?(Array) ? value.join(', ') : value.to_s
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def self.hash_at(value, key)
|
|
74
|
+
child = value[key] if value.is_a?(Hash)
|
|
75
|
+
child.is_a?(Hash) ? child : nil
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def self.string_at(value, key)
|
|
79
|
+
child = value[key] if value.is_a?(Hash)
|
|
80
|
+
child.is_a?(String) && !child.empty? ? child : nil
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def self.bool_at(value, key)
|
|
84
|
+
child = value[key] if value.is_a?(Hash)
|
|
85
|
+
[true, false].include?(child) ? child : nil
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def self.retryable(detail, status)
|
|
89
|
+
explicit = bool_at(detail, 'retryable')
|
|
90
|
+
explicit.nil? ? default_retryable?(status) : explicit
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def self.header(headers, name)
|
|
94
|
+
pair = headers.find { |key, _value| key.downcase == name }
|
|
95
|
+
pair&.last
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def self.default_retryable?(status)
|
|
99
|
+
status == 429 || (status.is_a?(Integer) && status >= 500)
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Sendmux
|
|
4
|
+
module Core
|
|
5
|
+
module Headers
|
|
6
|
+
def self.idempotency_key(value)
|
|
7
|
+
{ idempotency_key: value }
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def self.if_match(value)
|
|
11
|
+
{ if_match: value }
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def self.if_none_match(value)
|
|
15
|
+
{ if_none_match: value }
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def self.conditional(if_match: nil, if_none_match: nil)
|
|
19
|
+
{}.tap do |headers|
|
|
20
|
+
headers[:if_match] = if_match if if_match
|
|
21
|
+
headers[:if_none_match] = if_none_match if if_none_match
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Sendmux
|
|
4
|
+
module Core
|
|
5
|
+
class CursorPager
|
|
6
|
+
include Enumerable
|
|
7
|
+
|
|
8
|
+
def initialize(fetch_page, cursor_param: :cursor)
|
|
9
|
+
@fetch_page = fetch_page
|
|
10
|
+
@cursor_param = cursor_param
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def each(&block)
|
|
14
|
+
return enum_for(:each) unless block_given?
|
|
15
|
+
|
|
16
|
+
cursor = nil
|
|
17
|
+
loop do
|
|
18
|
+
response = @fetch_page.call(cursor ? { @cursor_param => cursor } : {})
|
|
19
|
+
extract_items(response).each(&block)
|
|
20
|
+
pagination = extract_pagination(response)
|
|
21
|
+
break unless bool_value?(pagination, 'has_more')
|
|
22
|
+
|
|
23
|
+
cursor = value(pagination, 'next_cursor')
|
|
24
|
+
break unless cursor
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def extract_items(response)
|
|
31
|
+
data = value(response, 'data')
|
|
32
|
+
return data if data.is_a?(Array)
|
|
33
|
+
|
|
34
|
+
[]
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def extract_pagination(response)
|
|
38
|
+
meta = value(response, 'meta')
|
|
39
|
+
value(meta, 'pagination') || value(response, 'pagination') || {}
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def bool_value?(object, name)
|
|
43
|
+
value(object, name) == true
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def value(object, name)
|
|
47
|
+
return object[name] || object[name.to_sym] if object.is_a?(Hash)
|
|
48
|
+
return object.public_send(name) if object.respond_to?(name)
|
|
49
|
+
|
|
50
|
+
method = snake_to_camel(name)
|
|
51
|
+
object.public_send(method) if object.respond_to?(method)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def snake_to_camel(name)
|
|
55
|
+
parts = name.to_s.split('_')
|
|
56
|
+
parts.first + parts.drop(1).map(&:capitalize).join
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def self.each_cursor(fetch_page, cursor_param: :cursor)
|
|
61
|
+
CursorPager.new(fetch_page, cursor_param: cursor_param)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'faraday/retry'
|
|
4
|
+
|
|
5
|
+
module Sendmux
|
|
6
|
+
module Core
|
|
7
|
+
module Retry
|
|
8
|
+
SAFE_METHODS = %w[GET HEAD OPTIONS].freeze
|
|
9
|
+
|
|
10
|
+
def self.configure(configuration, retry_options = nil)
|
|
11
|
+
options = retry_options || RetryOptions.new
|
|
12
|
+
configuration.request(:retry, options.to_faraday_options)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def self.retryable_request?(env)
|
|
16
|
+
method = env[:method].to_s.upcase
|
|
17
|
+
return true if SAFE_METHODS.include?(method)
|
|
18
|
+
|
|
19
|
+
method == 'POST' && header(env[:request_headers] || {}, 'Idempotency-Key') && replayable_body?(env[:body])
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def self.header(headers, name)
|
|
23
|
+
headers.find { |key, _value| key.to_s.downcase == name.downcase }&.last
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def self.replayable_body?(body)
|
|
27
|
+
body.nil? || body.is_a?(String) || body.respond_to?(:rewind)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Sendmux
|
|
4
|
+
module Core
|
|
5
|
+
class RetryOptions
|
|
6
|
+
RETRY_STATUSES = [408, 409, 425, 429, 500, 502, 503, 504].freeze
|
|
7
|
+
|
|
8
|
+
attr_reader :max_attempts, :base_delay_seconds, :max_delay_seconds, :jitter
|
|
9
|
+
|
|
10
|
+
def initialize(max_attempts: 3, base_delay_seconds: 0.25, max_delay_seconds: 5.0, jitter: true)
|
|
11
|
+
raise ArgumentError, 'max_attempts must be at least 1' if max_attempts < 1
|
|
12
|
+
if base_delay_seconds.negative? || max_delay_seconds.negative?
|
|
13
|
+
raise ArgumentError,
|
|
14
|
+
'retry delays must be non-negative'
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
@max_attempts = max_attempts
|
|
18
|
+
@base_delay_seconds = base_delay_seconds
|
|
19
|
+
@max_delay_seconds = max_delay_seconds
|
|
20
|
+
@jitter = jitter
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def to_faraday_options
|
|
24
|
+
{
|
|
25
|
+
max: max_attempts - 1,
|
|
26
|
+
interval: base_delay_seconds,
|
|
27
|
+
max_interval: max_delay_seconds,
|
|
28
|
+
interval_randomness: jitter ? 0.5 : 0.0,
|
|
29
|
+
backoff_factor: 2,
|
|
30
|
+
methods: [],
|
|
31
|
+
retry_statuses: RETRY_STATUSES,
|
|
32
|
+
rate_limit_retry_header: 'Retry-After',
|
|
33
|
+
rate_limit_reset_header: 'X-RateLimit-Reset',
|
|
34
|
+
retry_if: ->(env, _exception) { Retry.retryable_request?(env) }
|
|
35
|
+
}
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
data/lib/sendmux/core.rb
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'sendmux/core/api_key_surface'
|
|
4
|
+
require 'sendmux/core/auth'
|
|
5
|
+
require 'sendmux/core/errors'
|
|
6
|
+
require 'sendmux/core/headers'
|
|
7
|
+
require 'sendmux/core/pagination'
|
|
8
|
+
require 'sendmux/core/retry'
|
|
9
|
+
require 'sendmux/core/retry_options'
|
|
10
|
+
require 'sendmux/core/version'
|
metadata
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: sendmux-core
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 1.0.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Sendmux
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 2026-06-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: faraday
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - "~>"
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '2.0'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - "~>"
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '2.0'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: faraday-retry
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - ">="
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '2.4'
|
|
33
|
+
- - "<"
|
|
34
|
+
- !ruby/object:Gem::Version
|
|
35
|
+
version: '3.0'
|
|
36
|
+
type: :runtime
|
|
37
|
+
prerelease: false
|
|
38
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
39
|
+
requirements:
|
|
40
|
+
- - ">="
|
|
41
|
+
- !ruby/object:Gem::Version
|
|
42
|
+
version: '2.4'
|
|
43
|
+
- - "<"
|
|
44
|
+
- !ruby/object:Gem::Version
|
|
45
|
+
version: '3.0'
|
|
46
|
+
email:
|
|
47
|
+
- contact@sendmux.ai
|
|
48
|
+
executables: []
|
|
49
|
+
extensions: []
|
|
50
|
+
extra_rdoc_files: []
|
|
51
|
+
files:
|
|
52
|
+
- CHANGELOG.md
|
|
53
|
+
- README.md
|
|
54
|
+
- lib/sendmux/core.rb
|
|
55
|
+
- lib/sendmux/core/api_key_surface.rb
|
|
56
|
+
- lib/sendmux/core/auth.rb
|
|
57
|
+
- lib/sendmux/core/errors.rb
|
|
58
|
+
- lib/sendmux/core/headers.rb
|
|
59
|
+
- lib/sendmux/core/pagination.rb
|
|
60
|
+
- lib/sendmux/core/retry.rb
|
|
61
|
+
- lib/sendmux/core/retry_options.rb
|
|
62
|
+
- lib/sendmux/core/version.rb
|
|
63
|
+
homepage: https://github.com/Sendmux/sendmux-sdk
|
|
64
|
+
licenses:
|
|
65
|
+
- MIT
|
|
66
|
+
metadata:
|
|
67
|
+
homepage_uri: https://github.com/Sendmux/sendmux-sdk
|
|
68
|
+
source_code_uri: https://github.com/Sendmux/sendmux-sdk/tree/main/packages/ruby/core
|
|
69
|
+
changelog_uri: https://github.com/Sendmux/sendmux-sdk/blob/main/packages/ruby/core/CHANGELOG.md
|
|
70
|
+
rdoc_options: []
|
|
71
|
+
require_paths:
|
|
72
|
+
- lib
|
|
73
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
74
|
+
requirements:
|
|
75
|
+
- - ">="
|
|
76
|
+
- !ruby/object:Gem::Version
|
|
77
|
+
version: '3.1'
|
|
78
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
79
|
+
requirements:
|
|
80
|
+
- - ">="
|
|
81
|
+
- !ruby/object:Gem::Version
|
|
82
|
+
version: '0'
|
|
83
|
+
requirements: []
|
|
84
|
+
rubygems_version: 3.6.2
|
|
85
|
+
specification_version: 4
|
|
86
|
+
summary: Shared core helpers for Sendmux Ruby SDK packages.
|
|
87
|
+
test_files: []
|