pushizo 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.
- checksums.yaml +7 -0
- data/README.md +170 -0
- data/lib/pushizo/base_resource.rb +14 -0
- data/lib/pushizo/client.rb +264 -0
- data/lib/pushizo/errors.rb +96 -0
- data/lib/pushizo/file_upload.rb +30 -0
- data/lib/pushizo/generated/models.rb +9125 -0
- data/lib/pushizo/generated/resources.rb +4221 -0
- data/lib/pushizo/identity_token.rb +40 -0
- data/lib/pushizo/model.rb +117 -0
- data/lib/pushizo/transport.rb +26 -0
- data/lib/pushizo/version.rb +5 -0
- data/lib/pushizo/webhook.rb +92 -0
- data/lib/pushizo.rb +27 -0
- metadata +62 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 2f33d90710be708d8c1564539e767f76f570f49505122780696d768ad402b66b
|
|
4
|
+
data.tar.gz: 782a0f46dd24bb0248fa564180316575c2173f5b74115459a3ef494a5e249cd7
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 1c674747a8637ae7cae4303f0b2125acda5317649b96004a890d02755160f77610852fd1ea481732e511746d5e60af579b30f0038d384d6e733f8f346cd3c066
|
|
7
|
+
data.tar.gz: 2fc0239d42ebc22d32a228ac235ec33b3d145ee88615844c2b33ae2ad5635ef42f64136884e9fef849b076a787deb546cbe5d417bb0d10660a19d03df11dd19a
|
data/README.md
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
<!-- pushizo:header -->
|
|
2
|
+
<p align="center">
|
|
3
|
+
<a href="https://pushizo.com"><img src="https://raw.githubusercontent.com/pushizo/.github/main/profile/assets/mark.svg" alt="Pushizo" width="56" height="56"></a>
|
|
4
|
+
</p>
|
|
5
|
+
<p align="center">
|
|
6
|
+
<a href="https://docs.pushizo.com/docs/sdk/ruby"><img src="https://img.shields.io/badge/docs-docs.pushizo.com-5b4bdb" alt="Docs"></a>
|
|
7
|
+
<a href="https://docs.pushizo.com/docs/api"><img src="https://img.shields.io/badge/API-reference-b44fc4" alt="API reference"></a>
|
|
8
|
+
<a href="https://github.com/pushizo/pushizo-ruby/blob/main/LICENSE"><img src="https://img.shields.io/badge/license-MIT-16130f" alt="License: MIT"></a>
|
|
9
|
+
</p>
|
|
10
|
+
<p align="center"><a href="#english">English</a> · <a href="#türkçe">Türkçe</a></p>
|
|
11
|
+
|
|
12
|
+
## English
|
|
13
|
+
|
|
14
|
+
**Pushizo Ruby SDK** — Server-side SDK for Ruby. Models and resources are generated from the Pushizo OpenAPI schema; retries, idempotency, pagination and webhook verification are built in. Works with Rails.
|
|
15
|
+
|
|
16
|
+
> **Status:** Publishing to RubyGems is in progress.<br>
|
|
17
|
+
> **Durum:** RubyGems yayını hazırlanıyor.
|
|
18
|
+
|
|
19
|
+
**Install from GitHub until then · O zamana kadar GitHub'dan kurulum:**
|
|
20
|
+
|
|
21
|
+
```ruby
|
|
22
|
+
# Gemfile
|
|
23
|
+
gem "pushizo", git: "https://github.com/pushizo/pushizo-ruby", tag: "v0.1.0"
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
**After publication · Yayından sonra:**
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
gem install pushizo
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
The detailed guide below is in Turkish; code samples are language-neutral. Full documentation:
|
|
33
|
+
[docs.pushizo.com/docs/sdk/ruby](https://docs.pushizo.com/docs/sdk/ruby) · API reference: [docs.pushizo.com/docs/api](https://docs.pushizo.com/docs/api).
|
|
34
|
+
|
|
35
|
+
## Türkçe
|
|
36
|
+
<!-- /pushizo:header -->
|
|
37
|
+
|
|
38
|
+
# Pushizo Ruby SDK
|
|
39
|
+
|
|
40
|
+
Pushizo public API'si için Ruby sunucu SDK'sı (Ruby 3.0+, `Net::HTTP`; çalışma zamanı bağımlılığı yok).
|
|
41
|
+
Modeller ve kaynak metotları [Pushizo OpenAPI şemasından](https://github.com/pushizo/pushizo-openapi) üretilir (`lib/pushizo/generated/`); kimlik doğrulama,
|
|
42
|
+
yeniden deneme, idempotency, sayfalama ve webhook imza doğrulaması el yazımıdır.
|
|
43
|
+
|
|
44
|
+
## Kurulum
|
|
45
|
+
|
|
46
|
+
```ruby
|
|
47
|
+
# Gemfile
|
|
48
|
+
gem "pushizo"
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Kullanım
|
|
52
|
+
|
|
53
|
+
```ruby
|
|
54
|
+
require "pushizo"
|
|
55
|
+
|
|
56
|
+
hs = Pushizo::Client.new(api_key: ENV.fetch("HS_API_KEY"), app_id: "app_…") # yerelde base_url: "http://localhost:4000"
|
|
57
|
+
|
|
58
|
+
# Bildirim gönder: Idempotency-Key verilmezse otomatik UUID (yeniden denemelerde aynı anahtar)
|
|
59
|
+
res = hs.notifications.create(
|
|
60
|
+
{
|
|
61
|
+
target: { include_aliases: { external_id: ["user-42"] } },
|
|
62
|
+
contents: { en: "Your order shipped", tr: "Siparişiniz kargoya verildi" }
|
|
63
|
+
},
|
|
64
|
+
idempotency_key: "order-1001"
|
|
65
|
+
)
|
|
66
|
+
puts res.id, res.status
|
|
67
|
+
|
|
68
|
+
# Kullanıcı oluştur/güncelle (Hash veya model nesnesi kabul edilir)
|
|
69
|
+
hs.users.upsert(Pushizo::Models::UpsertUserDto.new(external_id: "user-42", tags: { plan: "pro" }))
|
|
70
|
+
|
|
71
|
+
# Cursor sayfalama: Enumerator, sayfalar tembel yüklenir
|
|
72
|
+
hs.segments.list_all(limit: 100).each { |segment| puts segment.name }
|
|
73
|
+
|
|
74
|
+
# Başka bir app için tek çağrı
|
|
75
|
+
hs.apps.get(app_id: "app_diger")
|
|
76
|
+
|
|
77
|
+
# SDK identity verification
|
|
78
|
+
identity_hash = Pushizo.identity_hash(ENV.fetch("HS_SDK_SECRET"), "user-42")
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Yanıtlar `Pushizo::Models::*` nesneleridir: alanlara snake_case metotla (`msg.status`) veya `msg["status"]` ile
|
|
82
|
+
erişilir, `to_h` JSON adlarıyla ham hash döner, bilinmeyen alanlar korunur. Ruby'nin `Object#send` yöntemiyle
|
|
83
|
+
çakışmaması için `events.send` → `send_events`, `campaigns.send` → `send_campaign` olarak adlandırılmıştır.
|
|
84
|
+
SDK'da metodu olmayan uçlar veya JSON dışı yanıtlar için
|
|
85
|
+
`hs.request(:get, "/v1/apps/{appId}/analytics/messages", query: { format: "csv" })` kullanılabilir.
|
|
86
|
+
|
|
87
|
+
### Ayarlar
|
|
88
|
+
|
|
89
|
+
| Parametre | Varsayılan | Açıklama |
|
|
90
|
+
|-----------|------------|----------|
|
|
91
|
+
| `api_key:` | — | `hs_live_…` API anahtarı (Bearer) |
|
|
92
|
+
| `app_id:` | `nil` | Çağrıda `app_id:` verilmezse kullanılan app |
|
|
93
|
+
| `base_url:` | `https://api.pushizo.com` | API adresi |
|
|
94
|
+
| `timeout:` | `30` | Deneme başına zaman aşımı (sn) |
|
|
95
|
+
| `max_retries:` | `3` | En fazla yeniden deneme (politika: aşağıda) |
|
|
96
|
+
| `auto_idempotency_key:` | `true` | `Idempotency-Key` destekleyen çağrılarda otomatik UUID |
|
|
97
|
+
| `transport:` | `NetHttpTransport` | Özel taşıma (`#call(method, url, headers, body, timeout)`) |
|
|
98
|
+
|
|
99
|
+
Her çağrı `request_options: { timeout: 5, max_retries: 0, headers: { … } }` ile ayrıca ayarlanabilir.
|
|
100
|
+
|
|
101
|
+
### Hatalar
|
|
102
|
+
|
|
103
|
+
429 her metotta; 5xx ve ağ hataları GET/HEAD/OPTIONS/PUT/DELETE'te, POST/PATCH'te yalnızca `Idempotency-Key` varsa (otomatik anahtar dahil) veya istek sunucuya hiç gönderilemediyse (bağlantı reddi, DNS hatası, bağlantı zaman aşımı) üstel geri çekilmeyle (0,3 / 1 / 3 sn) yeniden denenir; `Retry-After` başlığına uyulur.
|
|
104
|
+
Kalıcı hatalar `Pushizo::Error` alt sınıflarıdır ve `status`, `code` (`HS_…`), `request_id`, `details` taşır:
|
|
105
|
+
|
|
106
|
+
```ruby
|
|
107
|
+
begin
|
|
108
|
+
hs.notifications.get("msg_yok")
|
|
109
|
+
rescue Pushizo::NotFoundError
|
|
110
|
+
# 404
|
|
111
|
+
rescue Pushizo::InvalidRequestError => e
|
|
112
|
+
# 400/422 — e.code == Pushizo::ErrorCode::NO_RECIPIENTS
|
|
113
|
+
rescue Pushizo::Error => e
|
|
114
|
+
# AuthenticationError, PermissionDeniedError, ConflictError, RateLimitError, ServerError, NetworkError
|
|
115
|
+
warn "#{e.status} #{e.code} #{e.message}"
|
|
116
|
+
end
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
## Webhook imza doğrulaması
|
|
120
|
+
|
|
121
|
+
```ruby
|
|
122
|
+
# Rails
|
|
123
|
+
class PushizoWebhooksController < ActionController::API
|
|
124
|
+
def create
|
|
125
|
+
events = Pushizo::Webhook.construct_events(
|
|
126
|
+
request.raw_post, # ham gövde
|
|
127
|
+
request.headers["Pushizo-Signature"],
|
|
128
|
+
ENV.fetch("HS_WEBHOOK_SECRET") # rotasyonda [eski, yeni] verilebilir
|
|
129
|
+
)
|
|
130
|
+
events.each { |event| } # tekil veya batch; event["id"] ile tekrarları ayıklayın
|
|
131
|
+
head :no_content
|
|
132
|
+
rescue Pushizo::WebhookSignatureError => e
|
|
133
|
+
render plain: e.reason, status: :bad_request
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
Zaman damgası 5 dakikadan eskiyse (`tolerance:`) istek tekrar (replay) sayılır. Yalnızca doğrulama için
|
|
139
|
+
`Pushizo::Webhook.verify_signature(...)` `ok?`, `timestamp`, `reason` döner.
|
|
140
|
+
|
|
141
|
+
## Geliştirme
|
|
142
|
+
|
|
143
|
+
```bash
|
|
144
|
+
rake test # veya: ruby -Ilib -Itest -e 'Dir["test/test_*.rb"].each { |f| require_relative f }'
|
|
145
|
+
node sdk/scripts/generate-server-sdks.mjs --lang ruby # depo kökünde; generated/ yenilenir
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
`lib/pushizo/generated/` elle düzenlenmez. Webhook testleri `sdk/testdata/webhook-signature-vectors.json`
|
|
149
|
+
ortak vektörlerini kullanır.
|
|
150
|
+
|
|
151
|
+
<!-- pushizo:footer -->
|
|
152
|
+
---
|
|
153
|
+
|
|
154
|
+
## Support · Destek
|
|
155
|
+
|
|
156
|
+
- **Guide · Rehber:** [docs.pushizo.com/docs/sdk/ruby](https://docs.pushizo.com/docs/sdk/ruby)
|
|
157
|
+
- **API reference & OpenAPI · API referansı ve OpenAPI:** [docs.pushizo.com/docs/api](https://docs.pushizo.com/docs/api) ·
|
|
158
|
+
[pushizo-openapi](https://github.com/pushizo/pushizo-openapi)
|
|
159
|
+
- **Bugs & ideas · Hata ve öneri:** [Issues](https://github.com/pushizo/pushizo-ruby/issues)
|
|
160
|
+
- **Security · Güvenlik:** please do not open public issues; email [security@pushizo.com](mailto:security@pushizo.com) ·
|
|
161
|
+
herkese açık issue açmayın, bu adrese yazın.
|
|
162
|
+
- **Help · Destek:** [support@pushizo.com](mailto:support@pushizo.com)
|
|
163
|
+
|
|
164
|
+
This repository is mirrored automatically from the Pushizo main repository on every release. Please open an issue
|
|
165
|
+
instead of a pull request; fixes land in the main repository and ship with the next release.
|
|
166
|
+
Bu depo her sürümde Pushizo ana deposundan otomatik yansıtılır. Pull request yerine issue açın; düzeltmeler ana depoda
|
|
167
|
+
yapılır ve bir sonraki sürümle buraya gelir.
|
|
168
|
+
|
|
169
|
+
Released under the MIT License · MIT lisanslıdır — [LICENSE](https://github.com/pushizo/pushizo-ruby/blob/main/LICENSE).
|
|
170
|
+
<!-- /pushizo:footer -->
|
|
@@ -0,0 +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
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pushizo
|
|
4
|
+
# API hata zarfındaki error.code değerleri (HS_ önekli katalog).
|
|
5
|
+
module ErrorCode
|
|
6
|
+
VALIDATION_ERROR = "HS_VALIDATION_ERROR"
|
|
7
|
+
BAD_REQUEST = "HS_BAD_REQUEST"
|
|
8
|
+
UNAUTHENTICATED = "HS_UNAUTHENTICATED"
|
|
9
|
+
FORBIDDEN = "HS_FORBIDDEN"
|
|
10
|
+
TENANT_MISMATCH = "HS_TENANT_MISMATCH"
|
|
11
|
+
NOT_FOUND = "HS_NOT_FOUND"
|
|
12
|
+
CONFLICT = "HS_CONFLICT"
|
|
13
|
+
IDEMPOTENCY_CONFLICT = "HS_IDEMPOTENCY_CONFLICT"
|
|
14
|
+
IDEMPOTENCY_IN_PROGRESS = "HS_IDEMPOTENCY_IN_PROGRESS"
|
|
15
|
+
NO_RECIPIENTS = "HS_NO_RECIPIENTS"
|
|
16
|
+
CHANNEL_NOT_CONFIGURED = "HS_CHANNEL_NOT_CONFIGURED"
|
|
17
|
+
CONTENT_INVALID = "HS_CONTENT_INVALID"
|
|
18
|
+
SUBSCRIPTION_INVALID = "HS_SUBSCRIPTION_INVALID"
|
|
19
|
+
UNPROCESSABLE = "HS_UNPROCESSABLE"
|
|
20
|
+
RATE_LIMITED = "HS_RATE_LIMITED"
|
|
21
|
+
QUOTA_EXCEEDED = "HS_QUOTA_EXCEEDED"
|
|
22
|
+
INTERNAL = "HS_INTERNAL"
|
|
23
|
+
NOT_IMPLEMENTED = "HS_NOT_IMPLEMENTED"
|
|
24
|
+
PROVIDER_UNAVAILABLE = "HS_PROVIDER_UNAVAILABLE"
|
|
25
|
+
SERVICE_UNAVAILABLE = "HS_SERVICE_UNAVAILABLE"
|
|
26
|
+
# İstemci tarafı: bağlantı kurulamadı veya zaman aşımı (status 0).
|
|
27
|
+
NETWORK = "NETWORK"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# API hata zarfından ({"error": {"code", "message", "request_id", "details"}}) türetilen istisna.
|
|
31
|
+
# HTTP durumuna göre alt sınıflar fırlatılır (NotFoundError, RateLimitError …).
|
|
32
|
+
class Error < StandardError
|
|
33
|
+
attr_reader :status, :code, :request_id, :details, :retry_after
|
|
34
|
+
|
|
35
|
+
def initialize(status, code, message, request_id: nil, details: nil, retry_after: nil)
|
|
36
|
+
super(message)
|
|
37
|
+
@status = status
|
|
38
|
+
@code = code
|
|
39
|
+
@request_id = request_id
|
|
40
|
+
@details = details
|
|
41
|
+
@retry_after = retry_after
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# 429, 5xx veya ağ hatası: yeniden denenebilir.
|
|
45
|
+
def retryable?
|
|
46
|
+
status.zero? || status == 429 || status >= 500
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def to_s
|
|
50
|
+
suffix = request_id ? " (request_id=#{request_id})" : ""
|
|
51
|
+
"[#{status} #{code}] #{super}#{suffix}"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# HTTP durumuna karşılık gelen sınıf.
|
|
55
|
+
def self.class_for(status)
|
|
56
|
+
case status
|
|
57
|
+
when 0 then NetworkError
|
|
58
|
+
when 400, 422 then InvalidRequestError
|
|
59
|
+
when 401 then AuthenticationError
|
|
60
|
+
when 403 then PermissionDeniedError
|
|
61
|
+
when 404 then NotFoundError
|
|
62
|
+
when 409 then ConflictError
|
|
63
|
+
when 429 then RateLimitError
|
|
64
|
+
else status >= 500 ? ServerError : Error
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Bağlantı kurulamadı veya zaman aşımı (status 0).
|
|
70
|
+
class NetworkError < Error; end
|
|
71
|
+
# 400 / 422: istek doğrulanamadı veya işlenemedi (details alan hatalarını içerir).
|
|
72
|
+
class InvalidRequestError < Error; end
|
|
73
|
+
# 401: API anahtarı eksik veya geçersiz.
|
|
74
|
+
class AuthenticationError < Error; end
|
|
75
|
+
# 403: anahtarın kapsamı yetersiz, IP izin listesi dışı veya kaynak başka kiracıya ait.
|
|
76
|
+
class PermissionDeniedError < Error; end
|
|
77
|
+
# 404: kaynak bulunamadı.
|
|
78
|
+
class NotFoundError < Error; end
|
|
79
|
+
# 409: kaynak çakışması veya idempotency anahtarı çakışması.
|
|
80
|
+
class ConflictError < Error; end
|
|
81
|
+
# 429: hız sınırı veya plan kotası aşıldı.
|
|
82
|
+
class RateLimitError < Error; end
|
|
83
|
+
# 5xx: sunucu veya sağlayıcı geçici olarak kullanılamıyor.
|
|
84
|
+
class ServerError < Error; end
|
|
85
|
+
|
|
86
|
+
# Webhook imzası geçersiz. reason: missing_header, malformed_header, timestamp_out_of_tolerance,
|
|
87
|
+
# signature_mismatch veya malformed_body.
|
|
88
|
+
class WebhookSignatureError < StandardError
|
|
89
|
+
attr_reader :reason
|
|
90
|
+
|
|
91
|
+
def initialize(reason)
|
|
92
|
+
super("Pushizo webhook imzası geçersiz: #{reason}")
|
|
93
|
+
@reason = reason
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pushizo
|
|
4
|
+
# Multipart dosya alanı. İçerik bellekte tutulur ki yeniden denemeler aynı gövdeyi göndersin.
|
|
5
|
+
class FileUpload
|
|
6
|
+
attr_reader :filename, :content, :content_type
|
|
7
|
+
|
|
8
|
+
def initialize(content, filename: "upload", content_type: nil)
|
|
9
|
+
@content = content.to_s.b
|
|
10
|
+
@filename = filename.to_s.empty? ? "upload" : filename.to_s
|
|
11
|
+
@content_type = content_type || (@filename.end_with?(".csv") ? "text/csv" : "application/octet-stream")
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Diskteki dosyayı okur.
|
|
15
|
+
def self.from_path(path, content_type: nil)
|
|
16
|
+
new(File.binread(path), filename: File.basename(path), content_type: content_type)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# FileUpload, IO (ör. File.open(path, "rb")) veya String içeriği FileUpload'a çevirir.
|
|
20
|
+
def self.wrap(value)
|
|
21
|
+
case value
|
|
22
|
+
when FileUpload then value
|
|
23
|
+
when String then new(value)
|
|
24
|
+
else
|
|
25
|
+
name = value.respond_to?(:path) && value.path ? File.basename(value.path) : "upload"
|
|
26
|
+
new(value.read, filename: name)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|