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 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
@@ -0,0 +1,12 @@
1
+ # Changelog
2
+
3
+ ## 1.0.0 (2026-06-02)
4
+
5
+
6
+ ### Features
7
+
8
+ * **ruby:** add generated SDK packages ([f097fdf](https://github.com/Sendmux/sendmux-sdk/commit/f097fdf6afcbc2a048d9fdb1c9a669fff2a7ca4f))
9
+
10
+ ## 1.0.0
11
+
12
+ - Initial Ruby core helper package.
data/README.md ADDED
@@ -0,0 +1,3 @@
1
+ # sendmux-core
2
+
3
+ Shared authentication, retry, pagination, header, and error helpers for the Sendmux Ruby SDK packages.
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sendmux
4
+ module Core
5
+ module ApiKeySurface
6
+ ROOT = 'root'
7
+ MAILBOX = 'mailbox'
8
+ end
9
+ end
10
+ end
@@ -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
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sendmux
4
+ module Core
5
+ VERSION = '1.0.0'
6
+ end
7
+ end
@@ -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: []