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.
@@ -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