itrp-client 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 +15 -0
- data/Gemfile +4 -0
- data/Gemfile.lock +58 -0
- data/LICENSE.txt +20 -0
- data/README.md +304 -0
- data/itrp-client.gemspec +40 -0
- data/lib/itrp.rb +35 -0
- data/lib/itrp/ca-bundle.crt +3554 -0
- data/lib/itrp/client.rb +314 -0
- data/lib/itrp/client/attachments.rb +99 -0
- data/lib/itrp/client/multipart.rb +75 -0
- data/lib/itrp/client/response.rb +114 -0
- data/lib/itrp/client/version.rb +5 -0
- metadata +168 -0
data/lib/itrp/client.rb
ADDED
@@ -0,0 +1,314 @@
|
|
1
|
+
require 'net/http'
|
2
|
+
require 'json'
|
3
|
+
require 'uri'
|
4
|
+
require 'date'
|
5
|
+
require 'time'
|
6
|
+
require 'net/https'
|
7
|
+
require 'open-uri'
|
8
|
+
require 'itrp'
|
9
|
+
|
10
|
+
require 'itrp/client/version'
|
11
|
+
require 'itrp/client/response'
|
12
|
+
require 'itrp/client/multipart'
|
13
|
+
require 'itrp/client/attachments'
|
14
|
+
|
15
|
+
# cherry-pick some core extensions from active support
|
16
|
+
require 'active_support/core_ext/module/aliasing.rb'
|
17
|
+
require 'active_support/core_ext/object/blank'
|
18
|
+
require 'active_support/core_ext/object/try.rb'
|
19
|
+
require 'active_support/core_ext/hash/indifferent_access'
|
20
|
+
|
21
|
+
module Itrp
|
22
|
+
class Client
|
23
|
+
MAX_PAGE_SIZE = 100
|
24
|
+
DEFAULT_HEADER = {'Content-Type' => 'application/json'}
|
25
|
+
|
26
|
+
# Create a new ITRP Client
|
27
|
+
#
|
28
|
+
# Shared configuration for all ITRP Clients:
|
29
|
+
# Itrp.configure do |config|
|
30
|
+
# config.api_token = 'd41f5868feb65fc87fa2311a473a8766ea38bc40'
|
31
|
+
# config.account = 'my-sandbox'
|
32
|
+
# ...
|
33
|
+
# end
|
34
|
+
#
|
35
|
+
# Override configuration per ITRP Client:
|
36
|
+
# itrp = Itrp::Client.new(account: 'trusted-sandbox')
|
37
|
+
#
|
38
|
+
# All options available:
|
39
|
+
# - logger: The Ruby Logger instance, default: Logger.new(STDOUT)
|
40
|
+
# - host: The ITRP API host, default: 'https://api.itrp.com'
|
41
|
+
# - api_version: The ITRP API version, default: 'v1'
|
42
|
+
# - api_token: *required* The ITRP API token
|
43
|
+
# - account: Specify a different (trusted) account to work with
|
44
|
+
# @see http://developer.itrp.com/v1/#multiple-accounts
|
45
|
+
# - source: The Source used when creating new records
|
46
|
+
# @see http://developer.itrp.com/v1/general/source/
|
47
|
+
#
|
48
|
+
# - max_retry_time: maximum nr of seconds to wait for server to respond (default = 5400 = 1.5 hours)
|
49
|
+
# the sleep time between retries starts at 2 seconds and doubles after each retry
|
50
|
+
# retry times: 2, 6, 18, 54, 162, 486, 1458, 4374, 13122, ... seconds
|
51
|
+
# one retry will always be performed unless you set the value to -1
|
52
|
+
# - read_timeout: HTTP GET read timeout in seconds (default = 25)
|
53
|
+
# - block_at_rate_limit: Set to +true+ to block the request until the rate limit is lifted, default: +false+
|
54
|
+
# @see http://developer.itrp.com/v1/#rate-limiting
|
55
|
+
#
|
56
|
+
# - proxy_host: Define in case HTTP traffic needs to go through a proxy
|
57
|
+
# - proxy_port: Port of the proxy, defaults to 8080
|
58
|
+
# - proxy_user: Proxy user
|
59
|
+
# - proxy_password: Proxy password
|
60
|
+
def initialize(options = {})
|
61
|
+
@options = Itrp.configuration.current.merge(options)
|
62
|
+
[:host, :api_version, :api_token].each do |required_option|
|
63
|
+
raise ::Itrp::Exception.new("Missing required configuration option #{required_option}") if option(required_option).blank?
|
64
|
+
end
|
65
|
+
@ssl, @domain, @port = ssl_domain_port_path(option(:host))
|
66
|
+
@logger = @options[:logger]
|
67
|
+
end
|
68
|
+
|
69
|
+
# Retrieve an option
|
70
|
+
def option(key)
|
71
|
+
@options[key]
|
72
|
+
end
|
73
|
+
|
74
|
+
# Yield all retrieved resources one-by-one for the given (paged) API query.
|
75
|
+
# Raises an ::Itrp::Exception with the response retrieved from ITRP is invalid
|
76
|
+
# Returns total nr of resources yielded (for logging)
|
77
|
+
def each(path, params = {}, &block)
|
78
|
+
# retrieve the resources using the max page size (least nr of API calls)
|
79
|
+
next_path = expand_path(path, {per_page: MAX_PAGE_SIZE, page: 1}.merge(params))
|
80
|
+
size = 0
|
81
|
+
while next_path
|
82
|
+
# retrieve the records (with retry and optionally wait for rate-limit)
|
83
|
+
response = get(next_path)
|
84
|
+
# raise exception in case the response is invalid
|
85
|
+
raise ::Itrp::Exception.new(response.message) unless response.valid?
|
86
|
+
# yield the resources
|
87
|
+
response.json.each{ |resource| yield resource }
|
88
|
+
size += response.json.size
|
89
|
+
# go to the next page
|
90
|
+
next_path = response.pagination_relative_link(:next)
|
91
|
+
end
|
92
|
+
size
|
93
|
+
end
|
94
|
+
|
95
|
+
# send HTTPS GET request and return instance of Itrp::Response
|
96
|
+
def get(path, params = {}, header = {})
|
97
|
+
_send(Net::HTTP::Get.new(expand_path(path, params), expand_header(header)))
|
98
|
+
end
|
99
|
+
|
100
|
+
# send HTTPS PUT request and return instance of Itrp::Response
|
101
|
+
def put(path, data = {}, header = {})
|
102
|
+
_send(json_request(Net::HTTP::Put, path, data, header))
|
103
|
+
end
|
104
|
+
alias_method :patch, :put
|
105
|
+
|
106
|
+
# send HTTPS POST request and return instance of Itrp::Response
|
107
|
+
def post(path, data = {}, header = {})
|
108
|
+
_send(json_request(Net::HTTP::Post, path, data, header))
|
109
|
+
end
|
110
|
+
|
111
|
+
# upload a CSV file to import
|
112
|
+
# @param csv: The CSV File or the location of the CSV file
|
113
|
+
# @param type: The type, e.g. person, organization, people_contact_details
|
114
|
+
# @raise Itrp::UploadFailed in case the file could was not accepted by ITRP and +block_until_completed+ is +true+
|
115
|
+
# @raise Itrp::Exception in case the import progress could not be monitored
|
116
|
+
def import(csv, type, block_until_completed = false)
|
117
|
+
csv = File.open(csv, 'r') unless csv.respond_to?(:path) && csv.respond_to?(:read)
|
118
|
+
data, headers = Itrp::Multipart::Post.prepare_query(type: type, file: csv)
|
119
|
+
request = Net::HTTP::Post.new(expand_path('/import'), expand_header(headers))
|
120
|
+
request.body = data
|
121
|
+
response = _send(request)
|
122
|
+
@logger.info { "Import file '#{csv.path}' successfully uploaded with token '#{response[:token]}'." } if response.valid?
|
123
|
+
|
124
|
+
if block_until_completed
|
125
|
+
raise ::Itrp::UploadFailed.new("Failed to queue #{type} import. #{response.message}") unless response.valid?
|
126
|
+
token = response[:token]
|
127
|
+
while true
|
128
|
+
response = get("/import/#{token}")
|
129
|
+
raise ::Itrp::Exception.new("Unable to monitor progress for #{type} import. #{response.message}") unless response.valid?
|
130
|
+
# wait 30 seconds while the response is OK and import is still busy
|
131
|
+
break unless ['queued', 'processing'].include?(response[:state])
|
132
|
+
@logger.debug { "Import of '#{csv.path}' is #{response[:state]}. Checking again in 30 seconds." }
|
133
|
+
sleep(30)
|
134
|
+
end
|
135
|
+
end
|
136
|
+
|
137
|
+
response
|
138
|
+
end
|
139
|
+
|
140
|
+
# Export CSV files
|
141
|
+
# @param types: The types to export, e.g. person, organization, people_contact_details
|
142
|
+
# @param from: Retrieve all files since a given data and time
|
143
|
+
# @param block_until_completed: Set to true to monitor the export progress
|
144
|
+
# @raise Itrp::Exception in case the export progress could not be monitored
|
145
|
+
def export(types, from = nil, block_until_completed = false)
|
146
|
+
data = {type: [types].flatten.join(',')}
|
147
|
+
data[:from] = from unless from.blank?
|
148
|
+
response = post('/export', data)
|
149
|
+
@logger.info { "Export for '#{data[:type]}' successfully queued with token '#{response[:token]}'." } if response.valid?
|
150
|
+
|
151
|
+
if block_until_completed
|
152
|
+
raise ::Itrp::UploadFailed.new("Failed to queue '#{data[:type]}' export. #{response.message}") unless response.valid?
|
153
|
+
token = response[:token]
|
154
|
+
while true
|
155
|
+
response = get("/export/#{token}")
|
156
|
+
raise ::Itrp::Exception.new("Unable to monitor progress for '#{data[:type]}' export. #{response.message}") unless response.valid?
|
157
|
+
# wait 30 seconds while the response is OK and export is still busy
|
158
|
+
break unless ['queued', 'processing'].include?(response[:state])
|
159
|
+
@logger.debug { "Export of '#{data[:type]}' is #{response[:state]}. Checking again in 30 seconds." }
|
160
|
+
sleep(30)
|
161
|
+
end
|
162
|
+
end
|
163
|
+
|
164
|
+
response
|
165
|
+
end
|
166
|
+
|
167
|
+
def logger
|
168
|
+
@logger
|
169
|
+
end
|
170
|
+
|
171
|
+
private
|
172
|
+
|
173
|
+
# create a request (place data in body if the request becomes too large)
|
174
|
+
def json_request(request_class, path, data = {}, header = {})
|
175
|
+
Itrp::Attachments.new(self).upload_attachments!(path, data)
|
176
|
+
request = request_class.new(expand_path(path), expand_header(header))
|
177
|
+
body = {}
|
178
|
+
data.each{ |k,v| body[k.to_s] = typecast(v, false) }
|
179
|
+
request.body = body.to_json
|
180
|
+
request
|
181
|
+
end
|
182
|
+
|
183
|
+
URI_ESCAPE_PATTERN = Regexp.new("[^#{URI::PATTERN::UNRESERVED}]")
|
184
|
+
def uri_escape(value)
|
185
|
+
URI.escape(value, URI_ESCAPE_PATTERN).gsub('.', '%2E')
|
186
|
+
end
|
187
|
+
|
188
|
+
# Expand the given header with the default header
|
189
|
+
def expand_header(header = {})
|
190
|
+
header = DEFAULT_HEADER.merge(header)
|
191
|
+
header['X-ITRP-Account'] = option(:account) if option(:account)
|
192
|
+
header['AUTHORIZATION'] = 'Basic ' + ["#{option(:api_token)}:"].pack("m*")
|
193
|
+
if option(:source)
|
194
|
+
header['X-ITRP-Source'] = option(:source)
|
195
|
+
header['HTTP_USER_AGENT'] = option(:source)
|
196
|
+
end
|
197
|
+
header
|
198
|
+
end
|
199
|
+
|
200
|
+
# Expand the given path with the parameters
|
201
|
+
# Examples:
|
202
|
+
# person_id: 5
|
203
|
+
# :"updated_at=>" => yesterday
|
204
|
+
# fields: ["id", "created_at", "sourceID"]
|
205
|
+
def expand_path(path, params = {})
|
206
|
+
path = path.dup
|
207
|
+
path = "/#{path}" unless path =~ /^\// # make sure path starts with /
|
208
|
+
path = "/#{option(:api_version)}#{path}" unless path =~ /^\/v[\d.]+\// # preprend api version
|
209
|
+
params.each do |key, value|
|
210
|
+
path << (path['?'] ? '&' : '?')
|
211
|
+
path << expand_param(key, value)
|
212
|
+
end
|
213
|
+
path
|
214
|
+
end
|
215
|
+
|
216
|
+
# Expand one parameter, e.g. (:"created_at=>", DateTime.now) to "created_at=%3E22011-12-16T12:24:41+01:00"
|
217
|
+
def expand_param(key, value)
|
218
|
+
param = uri_escape(key.to_s).gsub('%3D', '=') # handle :"updated_at=>" or :"person_id!=" parameters
|
219
|
+
param << '=' unless key['=']
|
220
|
+
param << typecast(value)
|
221
|
+
param
|
222
|
+
end
|
223
|
+
|
224
|
+
# Parameter value typecasting
|
225
|
+
def typecast(value, escape = true)
|
226
|
+
case value.class.name.to_sym
|
227
|
+
when :NilClass then ''
|
228
|
+
when :String then escape ? URI.escape(value) : value
|
229
|
+
when :TrueClass then 'true'
|
230
|
+
when :FalseClass then 'false'
|
231
|
+
when :DateTime then value.new_offset(0).iso8601
|
232
|
+
when :Date then value.strftime("%Y-%m-%d")
|
233
|
+
when :Time then value.strftime("%H:%M")
|
234
|
+
when :Array then value.map{ |v| typecast(v, escape) }.join(',')
|
235
|
+
else value.to_s
|
236
|
+
end
|
237
|
+
end
|
238
|
+
|
239
|
+
# Send a request to ITRP and wrap the HTTP Response in an Itrp::Response
|
240
|
+
# Guaranteed to return a Response, thought it may be +empty?+
|
241
|
+
def _send(request, domain = @domain, port = @port, ssl = @ssl)
|
242
|
+
@logger.debug { "Sending #{request.method} request to #{domain}:#{port}#{request.path}" }
|
243
|
+
_response = begin
|
244
|
+
http_with_proxy = option(:proxy_host).blank? ? Net::HTTP : Net::HTTP::Proxy(option(:proxy_host), option(:proxy_port), option(:proxy_user), option(:proxy_password))
|
245
|
+
http = http_with_proxy.new(domain, port)
|
246
|
+
http.read_timeout = option(:read_timeout)
|
247
|
+
http.use_ssl = ssl
|
248
|
+
http.start{ |_http| _http.request(request) }
|
249
|
+
rescue ::Exception => e
|
250
|
+
Struct.new(:body, :message, :code, :header).new(nil, "No Response from Server - #{e.message} for '#{domain}:#{port}#{request.path}'", 500, {})
|
251
|
+
end
|
252
|
+
response = Itrp::Response.new(request, _response)
|
253
|
+
if response.valid?
|
254
|
+
@logger.debug { "Response:\n#{JSON.pretty_generate(response.json)}" }
|
255
|
+
elsif response.raw.body =~ /^\s*<\?xml/i
|
256
|
+
@logger.debug { "XML response:\n#{response.raw.body}" }
|
257
|
+
elsif '303' == response.raw.code.to_s
|
258
|
+
@logger.debug { "Redirect: #{response.raw.header['Location']}" }
|
259
|
+
else
|
260
|
+
@logger.error { "Request failed: #{response.message}" }
|
261
|
+
end
|
262
|
+
response
|
263
|
+
end
|
264
|
+
|
265
|
+
# Wraps the _send method with retries when the server does not responsd, see +initialize+ option +:rate_limit_block+
|
266
|
+
def _send_with_rate_limit_block(request, domain = @domain, port = @port, ssl = @ssl)
|
267
|
+
return _send_without_rate_limit_block(request, domain, port, ssl) unless option(:block_at_rate_limit)
|
268
|
+
now = Time.now
|
269
|
+
begin
|
270
|
+
_response = _send_without_rate_limit_block(request, domain, port, ssl)
|
271
|
+
@logger.warn { "Request throttled, trying again in 5 minutes: #{_response.message}" } and sleep(300) if _response.throttled?
|
272
|
+
end while _response.throttled? && (Time.now - now) < 3660 # max 1 hour and 1 minute
|
273
|
+
_response
|
274
|
+
end
|
275
|
+
alias_method_chain :_send, :rate_limit_block
|
276
|
+
|
277
|
+
# Wraps the _send method with retries when the server does not responsd, see +initialize+ option +:retries+
|
278
|
+
def _send_with_retries(request, domain = @domain, port = @port, ssl = @ssl)
|
279
|
+
retries = 0
|
280
|
+
sleep_time = 2
|
281
|
+
total_retry_time = 0
|
282
|
+
begin
|
283
|
+
_response = _send_without_retries(request, domain, port, ssl)
|
284
|
+
@logger.warn { "Request failed, retry ##{retries += 1} in #{sleep_time} seconds: #{_response.message}" } and sleep(sleep_time) if _response.empty? && option(:max_retry_time) > 0
|
285
|
+
total_retry_time += sleep_time
|
286
|
+
sleep_time *= 2
|
287
|
+
end while _response.empty? && total_retry_time < option(:max_retry_time)
|
288
|
+
_response
|
289
|
+
end
|
290
|
+
alias_method_chain :_send, :retries
|
291
|
+
|
292
|
+
# parse the given URI to [domain, port, ssl, path]
|
293
|
+
def ssl_domain_port_path(uri)
|
294
|
+
uri = URI.parse(uri)
|
295
|
+
ssl = uri.scheme == 'https'
|
296
|
+
[ssl, uri.host, uri.port, uri.path]
|
297
|
+
end
|
298
|
+
|
299
|
+
end
|
300
|
+
end
|
301
|
+
|
302
|
+
# HTTPS with certificate bundle
|
303
|
+
module Net
|
304
|
+
class HTTP
|
305
|
+
alias_method :original_use_ssl=, :use_ssl=
|
306
|
+
|
307
|
+
def use_ssl=(flag)
|
308
|
+
self.ca_file = File.expand_path("../ca-bundle.crt", __FILE__) if flag
|
309
|
+
self.verify_mode = OpenSSL::SSL::VERIFY_PEER
|
310
|
+
self.original_use_ssl = flag
|
311
|
+
end
|
312
|
+
end
|
313
|
+
end
|
314
|
+
|
@@ -0,0 +1,99 @@
|
|
1
|
+
module Itrp
|
2
|
+
class Attachments
|
3
|
+
|
4
|
+
AWS_PROVIDER = 'aws'
|
5
|
+
FILENAME_TEMPLATE = '${filename}'
|
6
|
+
|
7
|
+
def initialize(client)
|
8
|
+
@client = client
|
9
|
+
end
|
10
|
+
|
11
|
+
# upload the attachments in :attachments to ITRP and return the data with the uploaded attachment info
|
12
|
+
def upload_attachments!(path, data)
|
13
|
+
raise_exceptions = !!data.delete(:attachments_exception)
|
14
|
+
attachments = [data.delete(:attachments)].flatten.compact
|
15
|
+
return if attachments.empty?
|
16
|
+
|
17
|
+
# retrieve the upload configuration for this record from ITRP
|
18
|
+
storage = @client.get(path =~ /\d+$/ ? path : "#{path}/new", {attachment_upload_token: true}, @client.send(:expand_header))[:storage_upload]
|
19
|
+
report_error("Attachments not allowed for #{path}", raise_exceptions) and return unless storage
|
20
|
+
|
21
|
+
# upload each attachment and store the {key, filesize} has in the note_attachments parameter
|
22
|
+
data[:note_attachments] = attachments.map {|attachment| upload_attachment(storage, attachment, raise_exceptions) }.compact.to_json
|
23
|
+
end
|
24
|
+
|
25
|
+
private
|
26
|
+
|
27
|
+
def report_error(message, raise_exceptions)
|
28
|
+
if raise_exceptions
|
29
|
+
raise Itrp::UploadFailed.new(message)
|
30
|
+
else
|
31
|
+
@client.logger.error{ message }
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
# upload a single attachment and return the data for the note_attachments
|
36
|
+
# returns nil and provides an error in case the attachment upload failed
|
37
|
+
def upload_attachment(storage, attachment, raise_exceptions)
|
38
|
+
begin
|
39
|
+
# attachment is already a file or we need to open the file from disk
|
40
|
+
unless attachment.respond_to?(:path) && attachment.respond_to?(:read)
|
41
|
+
raise "file does not exist: #{attachment}" unless File.exists?(attachment)
|
42
|
+
attachment = File.open(attachment, 'r')
|
43
|
+
end
|
44
|
+
|
45
|
+
# there are two different upload methods: AWS S3 and ITRP local storage
|
46
|
+
key_template = "#{storage[:upload_path]}#{FILENAME_TEMPLATE}"
|
47
|
+
key = key_template.gsub(FILENAME_TEMPLATE, File.basename(attachment.path))
|
48
|
+
upload_method = storage[:provider] == AWS_PROVIDER ? :aws_upload : :itrp_upload
|
49
|
+
send(upload_method, storage, key_template, key, attachment)
|
50
|
+
|
51
|
+
# return the values for the note_attachments param
|
52
|
+
{key: key, filesize: File.size(attachment.path)}
|
53
|
+
rescue ::Exception => e
|
54
|
+
report_error("Attachment upload failed: #{e.message}", raise_exceptions)
|
55
|
+
nil
|
56
|
+
end
|
57
|
+
end
|
58
|
+
|
59
|
+
def aws_upload(aws, key_template, key, attachment)
|
60
|
+
# upload the file to AWS
|
61
|
+
response = send_file(aws[:upload_uri], {
|
62
|
+
key: key_template,
|
63
|
+
AWSAccessKeyId: aws[:access_key],
|
64
|
+
acl: 'private',
|
65
|
+
signature: aws[:signature],
|
66
|
+
success_action_redirect: aws[:success_url],
|
67
|
+
policy: aws[:policy],
|
68
|
+
file: attachment # file must be last (will that work in Ruby 1.9.3)?
|
69
|
+
})
|
70
|
+
# this is a bit of a hack, but Amazon S3 returns only XML :(
|
71
|
+
xml = response.raw.body || ''
|
72
|
+
error = xml[/<Error>.*<Message>(.*)<\/Message>.*<\/Error>/, 1]
|
73
|
+
raise "AWS upload to #{aws[:upload_uri]} for #{key} failed: #{error}" if error
|
74
|
+
|
75
|
+
# inform ITRP of the successful upload
|
76
|
+
response = @client.get(aws[:success_url].split('/').last, {key: key}, @client.send(:expand_header))
|
77
|
+
raise "ITRP confirmation #{aws[:success_url].split('/').last} for #{key} failed: #{response.message}" unless response.valid?
|
78
|
+
end
|
79
|
+
|
80
|
+
# upload the file directly to ITRP
|
81
|
+
def itrp_upload(itrp, key_template, key, attachment)
|
82
|
+
response = send_file(itrp[:upload_uri], {
|
83
|
+
file: attachment,
|
84
|
+
key: key_template
|
85
|
+
})
|
86
|
+
raise "ITRP upload to #{itrp[:upload_uri]} for #{key} failed: #{response.message}" unless response.valid?
|
87
|
+
end
|
88
|
+
|
89
|
+
def send_file(uri, params)
|
90
|
+
params = {:'Content-Type' => MIME::Types.type_for(params[:key])[0] || MIME::Types["application/octet-stream"][0]}.merge(params)
|
91
|
+
data, header = Itrp::Multipart::Post.prepare_query(params)
|
92
|
+
ssl, domain, port, path = @client.send(:ssl_domain_port_path, uri)
|
93
|
+
request = Net::HTTP::Post.new(path, header)
|
94
|
+
request.body = data
|
95
|
+
@client.send(:_send, request, domain, port, ssl)
|
96
|
+
end
|
97
|
+
|
98
|
+
end
|
99
|
+
end
|
@@ -0,0 +1,75 @@
|
|
1
|
+
require 'cgi'
|
2
|
+
require 'mime/types'
|
3
|
+
|
4
|
+
# Takes a hash of string and file parameters and returns a string of text
|
5
|
+
# formatted to be sent as a multipart form post.
|
6
|
+
#
|
7
|
+
# Author:: Cody Brimhall <mailto:cbrimhall@ucdavis.edu>
|
8
|
+
# Created:: 22 Feb 2008
|
9
|
+
module Itrp
|
10
|
+
module Multipart
|
11
|
+
VERSION = "1.0.0" unless const_defined?(:VERSION)
|
12
|
+
|
13
|
+
# Formats a given hash as a multipart form post
|
14
|
+
# If a hash value responds to :string or :read messages, then it is
|
15
|
+
# interpreted as a file and processed accordingly; otherwise, it is assumed
|
16
|
+
# to be a string
|
17
|
+
class Post
|
18
|
+
# We have to pretend like we're a web browser...
|
19
|
+
USERAGENT = "Mozilla/5.0 (Macintosh; U; PPC Mac OS X; en-us) AppleWebKit/523.10.6 (KHTML, like Gecko) Version/3.0.4 Safari/523.10.6" unless const_defined?(:USERAGENT)
|
20
|
+
BOUNDARY = "0123456789ABLEWASIEREISAWELBA9876543210" unless const_defined?(:BOUNDARY)
|
21
|
+
CONTENT_TYPE = "multipart/form-data; boundary=#{ BOUNDARY }" unless const_defined?(:CONTENT_TYPE)
|
22
|
+
HEADER = { "Content-Type" => CONTENT_TYPE, "User-Agent" => USERAGENT } unless const_defined?(:HEADER)
|
23
|
+
|
24
|
+
def self.prepare_query(params)
|
25
|
+
fp = []
|
26
|
+
|
27
|
+
params.each do |k, v|
|
28
|
+
if v.respond_to?(:path) && v.respond_to?(:read)
|
29
|
+
fp.push(FileParam.new(k, v.path, v.read))
|
30
|
+
else
|
31
|
+
fp.push(StringParam.new(k, v))
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
# Assemble the request body using the special multipart format
|
36
|
+
query = fp.map{ |p| "--#{BOUNDARY}\r\n#{p.to_multipart}" }.join + "--#{BOUNDARY}--"
|
37
|
+
return query, HEADER
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
private
|
42
|
+
|
43
|
+
# Formats a basic string key/value pair for inclusion with a multipart post
|
44
|
+
class StringParam
|
45
|
+
attr_accessor :k, :v
|
46
|
+
|
47
|
+
def initialize(k, v)
|
48
|
+
@k = k
|
49
|
+
@v = v
|
50
|
+
end
|
51
|
+
|
52
|
+
def to_multipart
|
53
|
+
return %(Content-Disposition: form-data; name="#{CGI::escape(k.to_s)}"\r\n\r\n#{v}\r\n)
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
57
|
+
# Formats the contents of a file or string for inclusion with a multipart
|
58
|
+
# form post
|
59
|
+
class FileParam
|
60
|
+
attr_accessor :k, :filename, :content
|
61
|
+
|
62
|
+
def initialize(k, filename, content)
|
63
|
+
@k = k
|
64
|
+
@filename = filename
|
65
|
+
@content = content
|
66
|
+
end
|
67
|
+
|
68
|
+
def to_multipart
|
69
|
+
# If we can tell the possible mime-type from the filename, use the first in the list; otherwise, use "application/octet-stream"
|
70
|
+
mime_type = MIME::Types.type_for(filename)[0] || MIME::Types["application/octet-stream"][0]
|
71
|
+
return %(Content-Disposition: form-data; name="#{CGI::escape(k.to_s)}"; filename="#{filename}"\r\nContent-Type: #{ mime_type.simplified }\r\n\r\n#{ content }\r\n)
|
72
|
+
end
|
73
|
+
end
|
74
|
+
end
|
75
|
+
end
|