melaya 0.1.2 → 0.2.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.
@@ -1,97 +1,226 @@
1
- # frozen_string_literal: true
2
-
3
- require "net/http"
4
- require "uri"
5
- require "json"
6
- require "openssl"
7
-
8
- require_relative "errors"
9
-
10
- module Melaya
11
- # Internal HTTP client. Injects the API key on every call as both
12
- # a query-param (?apiKey=) and Authorization: Bearer header.
13
- class HttpClient
14
- DEFAULT_BASE_URL = "https://api.melaya.org"
15
-
16
- def initialize(api_key:, base_url: DEFAULT_BASE_URL, verify_ssl: true)
17
- @api_key = api_key
18
- @base_uri = URI.parse(base_url.chomp("/"))
19
- @verify_ssl = verify_ssl
20
- end
21
-
22
- def get(path, params = {})
23
- request(:get, path, params: params)
24
- end
25
-
26
- def post(path, body = nil)
27
- request(:post, path, body: body)
28
- end
29
-
30
- def delete(path, params = {})
31
- request(:delete, path, params: params)
32
- end
33
-
34
- private
35
-
36
- def build_uri(path, params = {})
37
- uri = URI.parse("#{@base_uri}#{path}")
38
- query = { "apiKey" => @api_key }
39
- params.each { |k, v| query[k.to_s] = v.to_s unless v.nil? }
40
- uri.query = URI.encode_www_form(query)
41
- uri
42
- end
43
-
44
- def request(method, path, params: {}, body: nil)
45
- uri = build_uri(path, params)
46
-
47
- http = Net::HTTP.new(uri.host, uri.port)
48
- http.use_ssl = uri.scheme == "https"
49
- http.verify_mode = @verify_ssl ? OpenSSL::SSL::VERIFY_PEER : OpenSSL::SSL::VERIFY_NONE
50
- http.open_timeout = 15
51
- http.read_timeout = 60
52
-
53
- req = case method
54
- when :get then Net::HTTP::Get.new(uri)
55
- when :post then Net::HTTP::Post.new(uri)
56
- when :delete then Net::HTTP::Delete.new(uri)
57
- else raise ArgumentError, "Unknown HTTP method: #{method}"
58
- end
59
-
60
- req["Authorization"] = "Bearer #{@api_key}"
61
- req["Accept"] = "application/json"
62
-
63
- if body
64
- req["Content-Type"] = "application/json"
65
- req.body = JSON.generate(body)
66
- end
67
-
68
- resp = http.request(req)
69
- parse(resp)
70
- end
71
-
72
- def parse(resp)
73
- text = resp.body.to_s.strip
74
- data = begin
75
- text.empty? ? nil : JSON.parse(text)
76
- rescue JSON::ParserError
77
- text
78
- end
79
-
80
- if resp.code.to_i >= 400
81
- code = data.is_a?(Hash) ? data["error"] : nil
82
- msg = "Melaya API #{resp.code}" + (code ? " (#{code})" : "")
83
- raise MelayaError.new(msg, status: resp.code.to_i, code: code, body: data)
84
- end
85
-
86
- # The API wraps every payload in { "ok": true/false, ... }.
87
- # ok:false is a request-level failure — raise instead of returning silently.
88
- if data.is_a?(Hash) && data["ok"] == false
89
- code = data["error"]
90
- msg = "Melaya API request failed" + (code ? ": #{code}" : "")
91
- raise MelayaError.new(msg, status: resp.code.to_i, code: code, body: data)
92
- end
93
-
94
- data
95
- end
96
- end
97
- end
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+ require "json"
6
+ require "openssl"
7
+
8
+ require_relative "errors"
9
+
10
+ module Melaya
11
+ # Internal HTTP client. Supports Bearer JWT *and* mk_* platform API key.
12
+ # The credential is sent ONLY via the Authorization header — never in the URL
13
+ # query string, so it cannot leak into access logs or proxies. TLS is
14
+ # enforced by default; never log secrets.
15
+ #
16
+ # Retry policy: bounded exponential back-off with jitter on network errors,
17
+ # 429, and 5xx — but ONLY for idempotent GET requests (max 2 retries).
18
+ # POST/PUT/PATCH/DELETE are never retried. Retry-After header is honoured
19
+ # on 429. Per-request timeout default: 30 seconds (configurable).
20
+ class HttpClient
21
+ DEFAULT_BASE_URL = "https://api.melaya.org"
22
+ DEFAULT_TIMEOUT_MS = 30_000 # milliseconds
23
+
24
+ # Maximum additional retries after the first attempt (2 retries = 3 total
25
+ # attempts) for idempotent GET requests only.
26
+ MAX_GET_RETRIES = 2
27
+ RETRY_STATUSES = [429, 500, 502, 503, 504].freeze
28
+
29
+ # @param api_key [String] mk_* platform key or Bearer JWT
30
+ # @param base_url [String]
31
+ # @param verify_ssl [Boolean]
32
+ # @param timeout_ms [Integer] per-request timeout in milliseconds (default 30 000)
33
+ def initialize(api_key:, base_url: DEFAULT_BASE_URL, verify_ssl: true,
34
+ timeout_ms: DEFAULT_TIMEOUT_MS)
35
+ raise ArgumentError, "Melaya: TLS certificate verification cannot be disabled." unless verify_ssl
36
+ # Never store in a way that could leak to logs accidentally — keep as
37
+ # an opaque token string only accessible through the private accessor.
38
+ @_tok = api_key.freeze
39
+ @base_uri = URI.parse(base_url.chomp("/"))
40
+ @verify_ssl = true
41
+ @timeout_s = (timeout_ms / 1000.0).ceil
42
+ end
43
+
44
+ # ── Public verb helpers ────────────────────────────────────────────────────
45
+
46
+ def get(path, params = {})
47
+ request(:get, path, params: params)
48
+ end
49
+
50
+ def post(path, body = nil)
51
+ request(:post, path, body: body)
52
+ end
53
+
54
+ def put(path, body = nil)
55
+ request(:put, path, body: body)
56
+ end
57
+
58
+ def patch(path, body = nil)
59
+ request(:patch, path, body: body)
60
+ end
61
+
62
+ def delete(path, params = {})
63
+ request(:delete, path, params: params)
64
+ end
65
+
66
+ private
67
+
68
+ def build_uri(path, params = {})
69
+ uri = URI.parse("#{@base_uri}#{path}")
70
+ # SECURITY: the credential is never placed in the query string; it is
71
+ # sent only via the Authorization header (see make_request).
72
+ query = {}
73
+ params.each { |k, v| query[k.to_s] = v.to_s unless v.nil? }
74
+ uri.query = URI.encode_www_form(query) unless query.empty?
75
+ uri
76
+ end
77
+
78
+ def make_request(method, uri, body)
79
+ req = case method
80
+ when :get then Net::HTTP::Get.new(uri)
81
+ when :post then Net::HTTP::Post.new(uri)
82
+ when :put then Net::HTTP::Put.new(uri)
83
+ when :patch then Net::HTTP::Patch.new(uri)
84
+ when :delete then Net::HTTP::Delete.new(uri)
85
+ else raise ArgumentError, "Unknown HTTP method: #{method}"
86
+ end
87
+
88
+ # Authorization: never expose token in error output below
89
+ req["Authorization"] = "Bearer #{@_tok}"
90
+ req["Accept"] = "application/json"
91
+ req["User-Agent"] = "melaya-ruby/#{Melaya::VERSION}"
92
+
93
+ if body
94
+ req["Content-Type"] = "application/json"
95
+ req.body = JSON.generate(body)
96
+ end
97
+
98
+ req
99
+ end
100
+
101
+ def request(method, path, params: {}, body: nil)
102
+ uri = build_uri(path, params)
103
+
104
+ http = Net::HTTP.new(uri.host, uri.port)
105
+ http.use_ssl = uri.scheme == "https"
106
+ http.verify_mode = OpenSSL::SSL::VERIFY_PEER
107
+ http.open_timeout = @timeout_s
108
+ http.read_timeout = @timeout_s
109
+
110
+ # Only GET requests are retried (idempotent); all mutating verbs fail fast.
111
+ retryable = (method == :get)
112
+ attempt = 0
113
+
114
+ retry_after_hdr = nil
115
+ begin
116
+ attempt += 1
117
+ retry_after_hdr = nil # reset on each attempt
118
+ req = make_request(method, uri, body)
119
+ resp = http.request(req)
120
+ # Snapshot Retry-After before parse() consumes the response object,
121
+ # so we can honour the header even after the MelayaError is raised.
122
+ retry_after_hdr = resp["retry-after"] || resp["Retry-After"]
123
+ parse(resp)
124
+ rescue MelayaError => e
125
+ if retryable && RETRY_STATUSES.include?(e.status) && attempt <= MAX_GET_RETRIES
126
+ # Build a minimal resp-like object carrying only the header we need,
127
+ # so _backoff_delay can honour Retry-After without holding the socket.
128
+ hdr_carrier = { "retry-after" => retry_after_hdr }
129
+ delay = _backoff_delay(attempt, e, hdr_carrier)
130
+ sleep(delay)
131
+ retry
132
+ end
133
+ raise
134
+ rescue Errno::ECONNREFUSED, Net::OpenTimeout, Net::ReadTimeout
135
+ raise unless retryable && attempt <= MAX_GET_RETRIES
136
+ sleep(_backoff_delay(attempt, nil))
137
+ retry
138
+ end
139
+ end
140
+
141
+ # Exponential backoff with ±25 % jitter; honours Retry-After on 429.
142
+ # Base: 2^(attempt-1) seconds, capped at 16 s before jitter.
143
+ #
144
+ # Retry-After resolution order (first match wins):
145
+ # 1. HTTP `Retry-After` response header — seconds integer or HTTP-date
146
+ # 2. JSON body `retryAfter` / `retry_after` field (legacy fallback)
147
+ # 3. Exponential back-off
148
+ def _backoff_delay(attempt, err, resp = nil)
149
+ if err.is_a?(MelayaError) && err.status == 429
150
+ # 1. HTTP Retry-After header (preferred, RFC 7231)
151
+ if resp.respond_to?(:[]) && (ra_hdr = resp["retry-after"] || resp["Retry-After"])
152
+ secs = _parse_retry_after_header(ra_hdr)
153
+ return [secs, 0.5].max if secs
154
+ end
155
+
156
+ # 2. JSON body fallback ("retryAfter" or "retry_after")
157
+ if err.respond_to?(:body) && err.body.is_a?(Hash)
158
+ ra = err.body["retryAfter"] || err.body["retry_after"]
159
+ return [ra.to_f, 0.5].max if ra
160
+ end
161
+ end
162
+ base = [2**(attempt - 1), 16].min.to_f
163
+ jitter = base * 0.25 * (rand - 0.5) * 2 # ±25 %
164
+ [base + jitter, 0.1].max
165
+ end
166
+
167
+ # Parse an RFC 7231 Retry-After value: either a delay-seconds integer
168
+ # or an HTTP-date string. Returns seconds as Float, or nil if unparseable.
169
+ def _parse_retry_after_header(value)
170
+ str = value.to_s.strip
171
+ # Delay-seconds: plain non-negative integer
172
+ if str =~ /\A\d+\z/
173
+ return str.to_f
174
+ end
175
+ # HTTP-date (e.g. "Wed, 21 Oct 2099 07:28:00 GMT")
176
+ begin
177
+ require "time"
178
+ target = Time.httpdate(str)
179
+ delay = target - Time.now
180
+ return [delay, 0.0].max
181
+ rescue ArgumentError, TypeError
182
+ nil
183
+ end
184
+ end
185
+
186
+ def parse(resp)
187
+ text = resp.body.to_s.strip
188
+ data = begin
189
+ text.empty? ? nil : JSON.parse(text)
190
+ rescue JSON::ParserError
191
+ text
192
+ end
193
+
194
+ status = resp.code.to_i
195
+ if status >= 400
196
+ # Two error envelope shapes:
197
+ # 1. { error: 'tier_insufficient', tier: '...' } -> 403
198
+ # 2. { error: '...', message: '...', code: '...' }
199
+ # Extract error code safely — never echo raw body in message
200
+ err_code = data.is_a?(Hash) ? data["error"] : nil
201
+
202
+ if status == 403 && err_code == "tier_insufficient"
203
+ raise TierInsufficientError.new(tier: data.is_a?(Hash) ? data["tier"] : nil, body: data)
204
+ end
205
+ if status == 429
206
+ # Raised here; the GET retry loop above may swallow-and-retry it —
207
+ # callers only see it once retries are exhausted.
208
+ ra = _parse_retry_after_header(resp["retry-after"] || resp["Retry-After"])
209
+ raise RateLimitError.new(retry_after: ra, body: data)
210
+ end
211
+
212
+ msg = "Melaya API #{resp.code}" + (err_code ? " (#{err_code})" : "")
213
+ raise MelayaError.new(msg, status: status, code: err_code, body: data)
214
+ end
215
+
216
+ # The API may wrap payload in { "ok": false, ... } for request-level failures.
217
+ if data.is_a?(Hash) && data["ok"] == false
218
+ err_code = data["error"]
219
+ msg = "Melaya API request failed" + (err_code ? ": #{err_code}" : "")
220
+ raise MelayaError.new(msg, status: resp.code.to_i, code: err_code, body: data)
221
+ end
222
+
223
+ data
224
+ end
225
+ end
226
+ end