pushizo 0.1.0 → 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.
- checksums.yaml +4 -4
- data/README.md +158 -170
- data/lib/pushizo/base_resource.rb +14 -14
- data/lib/pushizo/client.rb +264 -264
- data/lib/pushizo/errors.rb +96 -96
- data/lib/pushizo/file_upload.rb +30 -30
- data/lib/pushizo/generated/models.rb +2681 -159
- data/lib/pushizo/generated/resources.rb +1629 -52
- data/lib/pushizo/identity_token.rb +40 -40
- data/lib/pushizo/model.rb +117 -117
- data/lib/pushizo/transport.rb +26 -26
- data/lib/pushizo/version.rb +5 -5
- data/lib/pushizo/webhook.rb +92 -92
- data/lib/pushizo.rb +27 -27
- metadata +3 -7
data/lib/pushizo/client.rb
CHANGED
|
@@ -1,264 +1,264 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "erb"
|
|
4
|
-
require "json"
|
|
5
|
-
require "openssl"
|
|
6
|
-
require "securerandom"
|
|
7
|
-
require "time"
|
|
8
|
-
|
|
9
|
-
module Pushizo
|
|
10
|
-
# Pushizo API istemcisi. Kaynak erişimcileri (notifications, users, segments …) api/openapi.yaml'dan üretilir.
|
|
11
|
-
#
|
|
12
|
-
# hs = Pushizo::Client.new(api_key: ENV.fetch("HS_API_KEY"), app_id: "app_…")
|
|
13
|
-
# hs.segments.list_all(limit: 50).each { |segment| puts segment.name }
|
|
14
|
-
class Client
|
|
15
|
-
include GeneratedResources
|
|
16
|
-
|
|
17
|
-
DEFAULT_BASE_URL = "https://api.pushizo.com"
|
|
18
|
-
BACKOFF = [0.3, 1.0, 3.0].freeze
|
|
19
|
-
NETWORK_ERRORS = [
|
|
20
|
-
IOError, EOFError, SocketError, SystemCallError, Timeout::Error, OpenSSL::SSL::SSLError,
|
|
21
|
-
Net::OpenTimeout, Net::ReadTimeout, Net::HTTPBadResponse
|
|
22
|
-
].freeze
|
|
23
|
-
|
|
24
|
-
# Sunucuda tekrarlanması ek yan etki doğurmayan HTTP metotları; 5xx ve ağ hatalarında serbestçe yeniden denenir.
|
|
25
|
-
IDEMPOTENT_METHODS = %w[GET HEAD OPTIONS PUT DELETE].freeze
|
|
26
|
-
|
|
27
|
-
# Bağlantı kurulmadan oluşan hatalar (DNS çözümlenemedi, bağlantı reddi, ağ/sunucu erişilemez, bağlantı zaman
|
|
28
|
-
# aşımı): istek sunucuya hiç gönderilmemiştir.
|
|
29
|
-
NOT_SENT_ERRORS = [
|
|
30
|
-
SocketError, Errno::ECONNREFUSED, Errno::EHOSTUNREACH, Errno::ENETUNREACH, Net::OpenTimeout
|
|
31
|
-
].freeze
|
|
32
|
-
|
|
33
|
-
attr_reader :app_id, :base_url
|
|
34
|
-
|
|
35
|
-
# api_key:: hs_live_… API anahtarı (Bearer).
|
|
36
|
-
# app_id:: Çağrılarda app_id: verilmezse kullanılacak varsayılan app.
|
|
37
|
-
# base_url:: API adresi; yerel geliştirmede http://localhost:4000.
|
|
38
|
-
# timeout:: Deneme başına zaman aşımı (sn).
|
|
39
|
-
# max_retries:: 429/5xx ve ağ hatalarında en fazla yeniden deneme.
|
|
40
|
-
# auto_idempotency_key:: Idempotency-Key destekleyen çağrılarda anahtar verilmezse UUID üret.
|
|
41
|
-
# transport:: Özel taşıma (#call); varsayılan NetHttpTransport.
|
|
42
|
-
# sleeper:: Yeniden deneme beklemesi (testlerde değiştirilebilir).
|
|
43
|
-
def initialize(api_key:, app_id: nil, base_url: DEFAULT_BASE_URL, timeout: 30, max_retries: 3,
|
|
44
|
-
auto_idempotency_key: true, user_agent: nil, transport: nil, sleeper: nil)
|
|
45
|
-
raise ArgumentError, "Pushizo: api_key gerekli." if api_key.nil? || api_key.to_s.empty?
|
|
46
|
-
|
|
47
|
-
@api_key = api_key.to_s
|
|
48
|
-
@app_id = app_id
|
|
49
|
-
@base_url = base_url.to_s.sub(%r{/+\z}, "")
|
|
50
|
-
@timeout = timeout
|
|
51
|
-
@max_retries = max_retries
|
|
52
|
-
@auto_idempotency_key = auto_idempotency_key
|
|
53
|
-
@user_agent = user_agent || "pushizo-ruby/#{VERSION}"
|
|
54
|
-
@transport = transport || NetHttpTransport.new
|
|
55
|
-
@sleeper = sleeper || ->(seconds) { sleep(seconds) }
|
|
56
|
-
end
|
|
57
|
-
|
|
58
|
-
# API anahtarı inspect çıktısında görünmez.
|
|
59
|
-
def inspect
|
|
60
|
-
"#<#{self.class.name} base_url=#{@base_url.inspect} app_id=#{@app_id.inspect}>"
|
|
61
|
-
end
|
|
62
|
-
|
|
63
|
-
# SDK identity verification için identity_hash = HMAC-SHA256(sdk_secret, external_id) (hex).
|
|
64
|
-
def self.identity_hash(sdk_secret, external_id)
|
|
65
|
-
Pushizo.identity_hash(sdk_secret, external_id)
|
|
66
|
-
end
|
|
67
|
-
|
|
68
|
-
# Düşük seviyeli istek (SDK'da metodu olmayan uçlar veya format=csv gibi JSON dışı yanıtlar için).
|
|
69
|
-
# path "/v1/…" biçimindedir; {appId} varsa app kimliğiyle doldurulur. JSON yanıt Hash, diğerleri String döner.
|
|
70
|
-
def request(method, path, query: nil, body: nil, app_id: nil, request_options: nil)
|
|
71
|
-
call(method, path, path_params: {}, query: query, body: body, app_id: app_id, request_options: request_options)
|
|
72
|
-
end
|
|
73
|
-
|
|
74
|
-
# İç API: üretilmiş kaynakların çağırdığı yürütücü.
|
|
75
|
-
def call(method, path, path_params: {}, query: nil, body: nil, form: nil, app_id: nil, query_app_id: false,
|
|
76
|
-
body_app_id: false, idempotent: false, idempotency_key: nil, response: nil, request_options: nil)
|
|
77
|
-
opts = request_options || {}
|
|
78
|
-
url_path = path.dup
|
|
79
|
-
url_path = url_path.gsub("{appId}", escape(resolve_app_id(app_id))) if url_path.include?("{appId}")
|
|
80
|
-
path_params.each do |name, value|
|
|
81
|
-
raise ArgumentError, "Pushizo: '#{name}' yol parametresi boş olamaz." if value.nil? || value.to_s.empty?
|
|
82
|
-
|
|
83
|
-
url_path = url_path.gsub("{#{name}}", escape(value.to_s))
|
|
84
|
-
end
|
|
85
|
-
|
|
86
|
-
items = []
|
|
87
|
-
items << ["app_id", resolve_app_id(app_id)] if query_app_id
|
|
88
|
-
(query || {}).each do |key, value|
|
|
89
|
-
next if value.nil?
|
|
90
|
-
|
|
91
|
-
Array(value.is_a?(Hash) ? [value] : value).each { |v| items << [key.to_s, scalar(v)] unless v.nil? }
|
|
92
|
-
end
|
|
93
|
-
url = "#{@base_url}#{url_path}"
|
|
94
|
-
url += "?#{items.map { |k, v| "#{escape(k)}=#{escape(v)}" }.join('&')}" unless items.empty?
|
|
95
|
-
|
|
96
|
-
key = idempotency_key || opts[:idempotency_key]
|
|
97
|
-
headers = {
|
|
98
|
-
"Authorization" => "Bearer #{@api_key}",
|
|
99
|
-
"Accept" => "application/json",
|
|
100
|
-
"User-Agent" => @user_agent
|
|
101
|
-
}
|
|
102
|
-
payload = nil
|
|
103
|
-
if form
|
|
104
|
-
boundary = "----pushizo#{SecureRandom.hex(16)}"
|
|
105
|
-
payload = multipart(form, boundary)
|
|
106
|
-
headers["Content-Type"] = "multipart/form-data; boundary=#{boundary}"
|
|
107
|
-
elsif !body.nil?
|
|
108
|
-
data = Types.serialize(body)
|
|
109
|
-
if data.is_a?(Hash)
|
|
110
|
-
data = data.merge("app_id" => resolve_app_id(app_id)) if body_app_id && data["app_id"].to_s.empty?
|
|
111
|
-
key ||= data["idempotency_key"] if idempotent && data["idempotency_key"].is_a?(String)
|
|
112
|
-
end
|
|
113
|
-
payload = JSON.generate(data)
|
|
114
|
-
headers["Content-Type"] = "application/json"
|
|
115
|
-
end
|
|
116
|
-
key = SecureRandom.uuid if (key.nil? || key.to_s.empty?) && idempotent && @auto_idempotency_key
|
|
117
|
-
headers["Idempotency-Key"] = key.to_s if key && !key.to_s.empty?
|
|
118
|
-
(opts[:headers] || {}).each { |k, v| headers[k.to_s] = v.to_s }
|
|
119
|
-
|
|
120
|
-
raw = send_with_retries(method, url, headers, payload, opts)
|
|
121
|
-
response ? Types.coerce(raw, response) : raw
|
|
122
|
-
end
|
|
123
|
-
|
|
124
|
-
# İç API: data / next_cursor / has_more zarfını sayfa sayfa dolaşan Enumerator.
|
|
125
|
-
def paginate(cursor)
|
|
126
|
-
Enumerator.new do |yielder|
|
|
127
|
-
next_cursor = cursor
|
|
128
|
-
loop do
|
|
129
|
-
page = yield(next_cursor)
|
|
130
|
-
data = page.is_a?(BaseModel) ? page["data"] : (page || {})["data"]
|
|
131
|
-
(data || []).each { |item| yielder << item }
|
|
132
|
-
next_cursor = page["next_cursor"]
|
|
133
|
-
break unless page["has_more"] && next_cursor && !next_cursor.to_s.empty?
|
|
134
|
-
end
|
|
135
|
-
end
|
|
136
|
-
end
|
|
137
|
-
|
|
138
|
-
private
|
|
139
|
-
|
|
140
|
-
def resolve_app_id(app_id)
|
|
141
|
-
id = app_id || @app_id
|
|
142
|
-
raise ArgumentError, "Pushizo: app_id verilmedi (istemci seçeneği veya çağrı parametresi)." if id.nil? || id.to_s.empty?
|
|
143
|
-
|
|
144
|
-
id.to_s
|
|
145
|
-
end
|
|
146
|
-
|
|
147
|
-
def escape(value)
|
|
148
|
-
ERB::Util.url_encode(value.to_s)
|
|
149
|
-
end
|
|
150
|
-
|
|
151
|
-
def scalar(value)
|
|
152
|
-
case value
|
|
153
|
-
when String then value
|
|
154
|
-
when Symbol then value.to_s
|
|
155
|
-
when true, false, Numeric then value.to_s
|
|
156
|
-
else JSON.generate(Types.serialize(value))
|
|
157
|
-
end
|
|
158
|
-
end
|
|
159
|
-
|
|
160
|
-
def send_with_retries(method, url, headers, payload, opts)
|
|
161
|
-
timeout = opts[:timeout] || @timeout
|
|
162
|
-
max_retries = opts.key?(:max_retries) ? opts[:max_retries] : @max_retries
|
|
163
|
-
has_key = headers.any? { |k, v| k.to_s.casecmp?("idempotency-key") && !v.to_s.empty? }
|
|
164
|
-
attempt = 0
|
|
165
|
-
loop do
|
|
166
|
-
error = nil
|
|
167
|
-
not_sent = false
|
|
168
|
-
begin
|
|
169
|
-
status, response_headers, response_body = @transport.call(method, url, headers, payload, timeout)
|
|
170
|
-
return parse_body(response_headers, response_body) if status.between?(200, 299)
|
|
171
|
-
|
|
172
|
-
error = error_from(status, response_headers, response_body)
|
|
173
|
-
rescue *NETWORK_ERRORS => e
|
|
174
|
-
error = NetworkError.new(0, ErrorCode::NETWORK, e.message.to_s.empty? ? e.class.name : e.message)
|
|
175
|
-
not_sent = NOT_SENT_ERRORS.any? { |klass| e.is_a?(klass) }
|
|
176
|
-
end
|
|
177
|
-
raise error if attempt >= max_retries || !retry?(method, error, has_key, not_sent)
|
|
178
|
-
|
|
179
|
-
delay = error.retry_after && error.retry_after.positive? ? error.retry_after : BACKOFF[[attempt, BACKOFF.size - 1].min]
|
|
180
|
-
@sleeper.call(delay)
|
|
181
|
-
attempt += 1
|
|
182
|
-
end
|
|
183
|
-
end
|
|
184
|
-
|
|
185
|
-
# Yeniden deneme politikası: 429 her metotta (istek işlenmeden reddedilmiştir); 5xx ve ağ hataları idempotent
|
|
186
|
-
# metotlarda, POST/PATCH'te yalnızca Idempotency-Key varsa ya da istek sunucuya hiç gönderilmediyse. Aksi hâlde
|
|
187
|
-
# sunucu isteği işlemiş olabilir ve tekrar çift kayıt üretebilir.
|
|
188
|
-
def retry?(method, error, has_idempotency_key, not_sent)
|
|
189
|
-
return true if error.status == 429
|
|
190
|
-
return false unless error.retryable?
|
|
191
|
-
|
|
192
|
-
IDEMPOTENT_METHODS.include?(method.to_s.upcase) || has_idempotency_key || not_sent
|
|
193
|
-
end
|
|
194
|
-
|
|
195
|
-
def parse_body(headers, body)
|
|
196
|
-
return nil if body.nil? || body.empty?
|
|
197
|
-
|
|
198
|
-
content_type = headers["content-type"].to_s
|
|
199
|
-
return body unless content_type.empty? || content_type.include?("json")
|
|
200
|
-
|
|
201
|
-
JSON.parse(body)
|
|
202
|
-
rescue JSON::ParserError
|
|
203
|
-
body
|
|
204
|
-
end
|
|
205
|
-
|
|
206
|
-
def error_from(status, headers, body)
|
|
207
|
-
envelope = begin
|
|
208
|
-
parsed = JSON.parse(body.to_s)
|
|
209
|
-
parsed.is_a?(Hash) && parsed["error"].is_a?(Hash) ? parsed["error"] : {}
|
|
210
|
-
rescue JSON::ParserError
|
|
211
|
-
{}
|
|
212
|
-
end
|
|
213
|
-
details = envelope["details"].is_a?(Hash) ? envelope["details"] : nil
|
|
214
|
-
Error.class_for(status).new(
|
|
215
|
-
status,
|
|
216
|
-
(envelope["code"] || "HTTP_#{status}").to_s,
|
|
217
|
-
(envelope["message"] || "HTTP #{status}").to_s,
|
|
218
|
-
request_id: envelope["request_id"] || headers["x-request-id"],
|
|
219
|
-
details: details,
|
|
220
|
-
retry_after: parse_retry_after(headers["retry-after"])
|
|
221
|
-
)
|
|
222
|
-
end
|
|
223
|
-
|
|
224
|
-
# Retry-After: saniye veya HTTP tarihi.
|
|
225
|
-
def parse_retry_after(value)
|
|
226
|
-
return nil if value.nil? || value.to_s.strip.empty?
|
|
227
|
-
|
|
228
|
-
text = value.to_s.strip
|
|
229
|
-
if text.match?(/\A\d+(\.\d+)?\z/)
|
|
230
|
-
seconds = text.to_f
|
|
231
|
-
return seconds.positive? ? seconds : nil
|
|
232
|
-
end
|
|
233
|
-
delta = Time.httpdate(text) - Time.now
|
|
234
|
-
delta.positive? ? delta : nil
|
|
235
|
-
rescue ArgumentError
|
|
236
|
-
nil
|
|
237
|
-
end
|
|
238
|
-
|
|
239
|
-
def multipart(form, boundary)
|
|
240
|
-
out = +"".b
|
|
241
|
-
form.each do |name, value|
|
|
242
|
-
next if value.nil?
|
|
243
|
-
|
|
244
|
-
out << "--#{boundary}\r\n".b
|
|
245
|
-
if value.is_a?(FileUpload) || value.respond_to?(:read)
|
|
246
|
-
file = FileUpload.wrap(value)
|
|
247
|
-
out << "Content-Disposition: form-data; name=\"#{quote(name)}\"; filename=\"#{quote(file.filename)}\"\r\n".b
|
|
248
|
-
out << "Content-Type: #{file.content_type}\r\n\r\n".b
|
|
249
|
-
out << file.content
|
|
250
|
-
else
|
|
251
|
-
out << "Content-Disposition: form-data; name=\"#{quote(name)}\"\r\n\r\n".b
|
|
252
|
-
out << scalar(value).b
|
|
253
|
-
end
|
|
254
|
-
out << "\r\n".b
|
|
255
|
-
end
|
|
256
|
-
out << "--#{boundary}--\r\n".b
|
|
257
|
-
out
|
|
258
|
-
end
|
|
259
|
-
|
|
260
|
-
def quote(value)
|
|
261
|
-
value.to_s.gsub("\\", "\\\\\\\\").gsub('"', '\\"').delete("\r\n")
|
|
262
|
-
end
|
|
263
|
-
end
|
|
264
|
-
end
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "erb"
|
|
4
|
+
require "json"
|
|
5
|
+
require "openssl"
|
|
6
|
+
require "securerandom"
|
|
7
|
+
require "time"
|
|
8
|
+
|
|
9
|
+
module Pushizo
|
|
10
|
+
# Pushizo API istemcisi. Kaynak erişimcileri (notifications, users, segments …) api/openapi.yaml'dan üretilir.
|
|
11
|
+
#
|
|
12
|
+
# hs = Pushizo::Client.new(api_key: ENV.fetch("HS_API_KEY"), app_id: "app_…")
|
|
13
|
+
# hs.segments.list_all(limit: 50).each { |segment| puts segment.name }
|
|
14
|
+
class Client
|
|
15
|
+
include GeneratedResources
|
|
16
|
+
|
|
17
|
+
DEFAULT_BASE_URL = "https://api.pushizo.com"
|
|
18
|
+
BACKOFF = [0.3, 1.0, 3.0].freeze
|
|
19
|
+
NETWORK_ERRORS = [
|
|
20
|
+
IOError, EOFError, SocketError, SystemCallError, Timeout::Error, OpenSSL::SSL::SSLError,
|
|
21
|
+
Net::OpenTimeout, Net::ReadTimeout, Net::HTTPBadResponse
|
|
22
|
+
].freeze
|
|
23
|
+
|
|
24
|
+
# Sunucuda tekrarlanması ek yan etki doğurmayan HTTP metotları; 5xx ve ağ hatalarında serbestçe yeniden denenir.
|
|
25
|
+
IDEMPOTENT_METHODS = %w[GET HEAD OPTIONS PUT DELETE].freeze
|
|
26
|
+
|
|
27
|
+
# Bağlantı kurulmadan oluşan hatalar (DNS çözümlenemedi, bağlantı reddi, ağ/sunucu erişilemez, bağlantı zaman
|
|
28
|
+
# aşımı): istek sunucuya hiç gönderilmemiştir.
|
|
29
|
+
NOT_SENT_ERRORS = [
|
|
30
|
+
SocketError, Errno::ECONNREFUSED, Errno::EHOSTUNREACH, Errno::ENETUNREACH, Net::OpenTimeout
|
|
31
|
+
].freeze
|
|
32
|
+
|
|
33
|
+
attr_reader :app_id, :base_url
|
|
34
|
+
|
|
35
|
+
# api_key:: hs_live_… API anahtarı (Bearer).
|
|
36
|
+
# app_id:: Çağrılarda app_id: verilmezse kullanılacak varsayılan app.
|
|
37
|
+
# base_url:: API adresi; yerel geliştirmede http://localhost:4000.
|
|
38
|
+
# timeout:: Deneme başına zaman aşımı (sn).
|
|
39
|
+
# max_retries:: 429/5xx ve ağ hatalarında en fazla yeniden deneme.
|
|
40
|
+
# auto_idempotency_key:: Idempotency-Key destekleyen çağrılarda anahtar verilmezse UUID üret.
|
|
41
|
+
# transport:: Özel taşıma (#call); varsayılan NetHttpTransport.
|
|
42
|
+
# sleeper:: Yeniden deneme beklemesi (testlerde değiştirilebilir).
|
|
43
|
+
def initialize(api_key:, app_id: nil, base_url: DEFAULT_BASE_URL, timeout: 30, max_retries: 3,
|
|
44
|
+
auto_idempotency_key: true, user_agent: nil, transport: nil, sleeper: nil)
|
|
45
|
+
raise ArgumentError, "Pushizo: api_key gerekli." if api_key.nil? || api_key.to_s.empty?
|
|
46
|
+
|
|
47
|
+
@api_key = api_key.to_s
|
|
48
|
+
@app_id = app_id
|
|
49
|
+
@base_url = base_url.to_s.sub(%r{/+\z}, "")
|
|
50
|
+
@timeout = timeout
|
|
51
|
+
@max_retries = max_retries
|
|
52
|
+
@auto_idempotency_key = auto_idempotency_key
|
|
53
|
+
@user_agent = user_agent || "pushizo-ruby/#{VERSION}"
|
|
54
|
+
@transport = transport || NetHttpTransport.new
|
|
55
|
+
@sleeper = sleeper || ->(seconds) { sleep(seconds) }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# API anahtarı inspect çıktısında görünmez.
|
|
59
|
+
def inspect
|
|
60
|
+
"#<#{self.class.name} base_url=#{@base_url.inspect} app_id=#{@app_id.inspect}>"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# SDK identity verification için identity_hash = HMAC-SHA256(sdk_secret, external_id) (hex).
|
|
64
|
+
def self.identity_hash(sdk_secret, external_id)
|
|
65
|
+
Pushizo.identity_hash(sdk_secret, external_id)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Düşük seviyeli istek (SDK'da metodu olmayan uçlar veya format=csv gibi JSON dışı yanıtlar için).
|
|
69
|
+
# path "/v1/…" biçimindedir; {appId} varsa app kimliğiyle doldurulur. JSON yanıt Hash, diğerleri String döner.
|
|
70
|
+
def request(method, path, query: nil, body: nil, app_id: nil, request_options: nil)
|
|
71
|
+
call(method, path, path_params: {}, query: query, body: body, app_id: app_id, request_options: request_options)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# İç API: üretilmiş kaynakların çağırdığı yürütücü.
|
|
75
|
+
def call(method, path, path_params: {}, query: nil, body: nil, form: nil, app_id: nil, query_app_id: false,
|
|
76
|
+
body_app_id: false, idempotent: false, idempotency_key: nil, response: nil, request_options: nil)
|
|
77
|
+
opts = request_options || {}
|
|
78
|
+
url_path = path.dup
|
|
79
|
+
url_path = url_path.gsub("{appId}", escape(resolve_app_id(app_id))) if url_path.include?("{appId}")
|
|
80
|
+
path_params.each do |name, value|
|
|
81
|
+
raise ArgumentError, "Pushizo: '#{name}' yol parametresi boş olamaz." if value.nil? || value.to_s.empty?
|
|
82
|
+
|
|
83
|
+
url_path = url_path.gsub("{#{name}}", escape(value.to_s))
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
items = []
|
|
87
|
+
items << ["app_id", resolve_app_id(app_id)] if query_app_id
|
|
88
|
+
(query || {}).each do |key, value|
|
|
89
|
+
next if value.nil?
|
|
90
|
+
|
|
91
|
+
Array(value.is_a?(Hash) ? [value] : value).each { |v| items << [key.to_s, scalar(v)] unless v.nil? }
|
|
92
|
+
end
|
|
93
|
+
url = "#{@base_url}#{url_path}"
|
|
94
|
+
url += "?#{items.map { |k, v| "#{escape(k)}=#{escape(v)}" }.join('&')}" unless items.empty?
|
|
95
|
+
|
|
96
|
+
key = idempotency_key || opts[:idempotency_key]
|
|
97
|
+
headers = {
|
|
98
|
+
"Authorization" => "Bearer #{@api_key}",
|
|
99
|
+
"Accept" => "application/json",
|
|
100
|
+
"User-Agent" => @user_agent
|
|
101
|
+
}
|
|
102
|
+
payload = nil
|
|
103
|
+
if form
|
|
104
|
+
boundary = "----pushizo#{SecureRandom.hex(16)}"
|
|
105
|
+
payload = multipart(form, boundary)
|
|
106
|
+
headers["Content-Type"] = "multipart/form-data; boundary=#{boundary}"
|
|
107
|
+
elsif !body.nil?
|
|
108
|
+
data = Types.serialize(body)
|
|
109
|
+
if data.is_a?(Hash)
|
|
110
|
+
data = data.merge("app_id" => resolve_app_id(app_id)) if body_app_id && data["app_id"].to_s.empty?
|
|
111
|
+
key ||= data["idempotency_key"] if idempotent && data["idempotency_key"].is_a?(String)
|
|
112
|
+
end
|
|
113
|
+
payload = JSON.generate(data)
|
|
114
|
+
headers["Content-Type"] = "application/json"
|
|
115
|
+
end
|
|
116
|
+
key = SecureRandom.uuid if (key.nil? || key.to_s.empty?) && idempotent && @auto_idempotency_key
|
|
117
|
+
headers["Idempotency-Key"] = key.to_s if key && !key.to_s.empty?
|
|
118
|
+
(opts[:headers] || {}).each { |k, v| headers[k.to_s] = v.to_s }
|
|
119
|
+
|
|
120
|
+
raw = send_with_retries(method, url, headers, payload, opts)
|
|
121
|
+
response ? Types.coerce(raw, response) : raw
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# İç API: data / next_cursor / has_more zarfını sayfa sayfa dolaşan Enumerator.
|
|
125
|
+
def paginate(cursor)
|
|
126
|
+
Enumerator.new do |yielder|
|
|
127
|
+
next_cursor = cursor
|
|
128
|
+
loop do
|
|
129
|
+
page = yield(next_cursor)
|
|
130
|
+
data = page.is_a?(BaseModel) ? page["data"] : (page || {})["data"]
|
|
131
|
+
(data || []).each { |item| yielder << item }
|
|
132
|
+
next_cursor = page["next_cursor"]
|
|
133
|
+
break unless page["has_more"] && next_cursor && !next_cursor.to_s.empty?
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
private
|
|
139
|
+
|
|
140
|
+
def resolve_app_id(app_id)
|
|
141
|
+
id = app_id || @app_id
|
|
142
|
+
raise ArgumentError, "Pushizo: app_id verilmedi (istemci seçeneği veya çağrı parametresi)." if id.nil? || id.to_s.empty?
|
|
143
|
+
|
|
144
|
+
id.to_s
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def escape(value)
|
|
148
|
+
ERB::Util.url_encode(value.to_s)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def scalar(value)
|
|
152
|
+
case value
|
|
153
|
+
when String then value
|
|
154
|
+
when Symbol then value.to_s
|
|
155
|
+
when true, false, Numeric then value.to_s
|
|
156
|
+
else JSON.generate(Types.serialize(value))
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def send_with_retries(method, url, headers, payload, opts)
|
|
161
|
+
timeout = opts[:timeout] || @timeout
|
|
162
|
+
max_retries = opts.key?(:max_retries) ? opts[:max_retries] : @max_retries
|
|
163
|
+
has_key = headers.any? { |k, v| k.to_s.casecmp?("idempotency-key") && !v.to_s.empty? }
|
|
164
|
+
attempt = 0
|
|
165
|
+
loop do
|
|
166
|
+
error = nil
|
|
167
|
+
not_sent = false
|
|
168
|
+
begin
|
|
169
|
+
status, response_headers, response_body = @transport.call(method, url, headers, payload, timeout)
|
|
170
|
+
return parse_body(response_headers, response_body) if status.between?(200, 299)
|
|
171
|
+
|
|
172
|
+
error = error_from(status, response_headers, response_body)
|
|
173
|
+
rescue *NETWORK_ERRORS => e
|
|
174
|
+
error = NetworkError.new(0, ErrorCode::NETWORK, e.message.to_s.empty? ? e.class.name : e.message)
|
|
175
|
+
not_sent = NOT_SENT_ERRORS.any? { |klass| e.is_a?(klass) }
|
|
176
|
+
end
|
|
177
|
+
raise error if attempt >= max_retries || !retry?(method, error, has_key, not_sent)
|
|
178
|
+
|
|
179
|
+
delay = error.retry_after && error.retry_after.positive? ? error.retry_after : BACKOFF[[attempt, BACKOFF.size - 1].min]
|
|
180
|
+
@sleeper.call(delay)
|
|
181
|
+
attempt += 1
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# Yeniden deneme politikası: 429 her metotta (istek işlenmeden reddedilmiştir); 5xx ve ağ hataları idempotent
|
|
186
|
+
# metotlarda, POST/PATCH'te yalnızca Idempotency-Key varsa ya da istek sunucuya hiç gönderilmediyse. Aksi hâlde
|
|
187
|
+
# sunucu isteği işlemiş olabilir ve tekrar çift kayıt üretebilir.
|
|
188
|
+
def retry?(method, error, has_idempotency_key, not_sent)
|
|
189
|
+
return true if error.status == 429
|
|
190
|
+
return false unless error.retryable?
|
|
191
|
+
|
|
192
|
+
IDEMPOTENT_METHODS.include?(method.to_s.upcase) || has_idempotency_key || not_sent
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def parse_body(headers, body)
|
|
196
|
+
return nil if body.nil? || body.empty?
|
|
197
|
+
|
|
198
|
+
content_type = headers["content-type"].to_s
|
|
199
|
+
return body unless content_type.empty? || content_type.include?("json")
|
|
200
|
+
|
|
201
|
+
JSON.parse(body)
|
|
202
|
+
rescue JSON::ParserError
|
|
203
|
+
body
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def error_from(status, headers, body)
|
|
207
|
+
envelope = begin
|
|
208
|
+
parsed = JSON.parse(body.to_s)
|
|
209
|
+
parsed.is_a?(Hash) && parsed["error"].is_a?(Hash) ? parsed["error"] : {}
|
|
210
|
+
rescue JSON::ParserError
|
|
211
|
+
{}
|
|
212
|
+
end
|
|
213
|
+
details = envelope["details"].is_a?(Hash) ? envelope["details"] : nil
|
|
214
|
+
Error.class_for(status).new(
|
|
215
|
+
status,
|
|
216
|
+
(envelope["code"] || "HTTP_#{status}").to_s,
|
|
217
|
+
(envelope["message"] || "HTTP #{status}").to_s,
|
|
218
|
+
request_id: envelope["request_id"] || headers["x-request-id"],
|
|
219
|
+
details: details,
|
|
220
|
+
retry_after: parse_retry_after(headers["retry-after"])
|
|
221
|
+
)
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# Retry-After: saniye veya HTTP tarihi.
|
|
225
|
+
def parse_retry_after(value)
|
|
226
|
+
return nil if value.nil? || value.to_s.strip.empty?
|
|
227
|
+
|
|
228
|
+
text = value.to_s.strip
|
|
229
|
+
if text.match?(/\A\d+(\.\d+)?\z/)
|
|
230
|
+
seconds = text.to_f
|
|
231
|
+
return seconds.positive? ? seconds : nil
|
|
232
|
+
end
|
|
233
|
+
delta = Time.httpdate(text) - Time.now
|
|
234
|
+
delta.positive? ? delta : nil
|
|
235
|
+
rescue ArgumentError
|
|
236
|
+
nil
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def multipart(form, boundary)
|
|
240
|
+
out = +"".b
|
|
241
|
+
form.each do |name, value|
|
|
242
|
+
next if value.nil?
|
|
243
|
+
|
|
244
|
+
out << "--#{boundary}\r\n".b
|
|
245
|
+
if value.is_a?(FileUpload) || value.respond_to?(:read)
|
|
246
|
+
file = FileUpload.wrap(value)
|
|
247
|
+
out << "Content-Disposition: form-data; name=\"#{quote(name)}\"; filename=\"#{quote(file.filename)}\"\r\n".b
|
|
248
|
+
out << "Content-Type: #{file.content_type}\r\n\r\n".b
|
|
249
|
+
out << file.content
|
|
250
|
+
else
|
|
251
|
+
out << "Content-Disposition: form-data; name=\"#{quote(name)}\"\r\n\r\n".b
|
|
252
|
+
out << scalar(value).b
|
|
253
|
+
end
|
|
254
|
+
out << "\r\n".b
|
|
255
|
+
end
|
|
256
|
+
out << "--#{boundary}--\r\n".b
|
|
257
|
+
out
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
def quote(value)
|
|
261
|
+
value.to_s.gsub("\\", "\\\\\\\\").gsub('"', '\\"').delete("\r\n")
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
end
|