5pm 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/CHANGELOG.md +66 -0
- data/LICENSE +21 -0
- data/README.md +209 -0
- data/SECURITY.md +15 -0
- data/docs/api-support.md +71 -0
- data/docs/cached-validation.md +58 -0
- data/docs/implementation-guides.md +12 -0
- data/docs/implementation-status.md +45 -0
- data/docs/live-validation-report.json +177 -0
- data/docs/live-validation.md +109 -0
- data/docs/mcp-plan.md +19 -0
- data/docs/release-preparation/0.1.0-manual-artifacts.json +112 -0
- data/docs/release-preparation/limited-first-release-decision.md +28 -0
- data/docs/release-preparation/manual-first-publication.md +192 -0
- data/docs/releasing.md +154 -0
- data/examples/read_only.rb +12 -0
- data/lib/five_pm/client.rb +145 -0
- data/lib/five_pm/download.rb +16 -0
- data/lib/five_pm/errors.rb +73 -0
- data/lib/five_pm/operations.json +354 -0
- data/lib/five_pm/pagination.rb +51 -0
- data/lib/five_pm/request.rb +80 -0
- data/lib/five_pm/resource.rb +41 -0
- data/lib/five_pm/response.rb +212 -0
- data/lib/five_pm/retry_policy.rb +48 -0
- data/lib/five_pm/services.rb +40 -0
- data/lib/five_pm/transport.rb +147 -0
- data/lib/five_pm/types.json +498 -0
- data/lib/five_pm/version.rb +3 -0
- data/lib/five_pm.rb +9 -0
- data/sig/five_pm.rbs +152 -0
- data/sig/models.rbs +223 -0
- metadata +113 -0
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
|
|
3
|
+
module FivePM
|
|
4
|
+
# Resolve the JSON data wrapper against the operation's declared response part.
|
|
5
|
+
# This is a reader view only; callers retain the complete original wire JSON.
|
|
6
|
+
module Envelope
|
|
7
|
+
def self.parts(payload, contract)
|
|
8
|
+
raise ProtocolError, "expected a response object" unless payload.is_a?(Hash)
|
|
9
|
+
|
|
10
|
+
APIResult.check!(payload)
|
|
11
|
+
return payload unless payload.key?("data")
|
|
12
|
+
|
|
13
|
+
primary = contract.fetch("response").keys - ["sessionId"]
|
|
14
|
+
unless primary.length == 1 && primary.none? { |part| payload.key?(part) }
|
|
15
|
+
raise ProtocolError, "ambiguous response envelope"
|
|
16
|
+
end
|
|
17
|
+
if payload["success"].equal?(false) || payload["error"] || payload["errors"] || payload["fault"]
|
|
18
|
+
raise ProtocolError, "API returned an error envelope"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
payload.merge(primary.first => payload.fetch("data"))
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
module Models
|
|
26
|
+
TYPES = JSON.parse(::File.read(::File.join(__dir__, "types.json")))
|
|
27
|
+
|
|
28
|
+
TYPES.each_value do |fields|
|
|
29
|
+
fields.each_value do |definition|
|
|
30
|
+
definition.each_value(&:freeze)
|
|
31
|
+
definition.freeze
|
|
32
|
+
end
|
|
33
|
+
fields.freeze
|
|
34
|
+
end
|
|
35
|
+
TYPES.freeze
|
|
36
|
+
|
|
37
|
+
# Models keep a frozen snapshot of the original JSON and expose typed readers.
|
|
38
|
+
# No fields are discarded, and date/time strings never acquire a guessed zone.
|
|
39
|
+
class Record
|
|
40
|
+
def initialize(payload, fields: self.class::FIELDS, reader_payload: nil)
|
|
41
|
+
raise ProtocolError, "expected a JSON object for #{self.class.name}" unless payload.is_a?(Hash)
|
|
42
|
+
|
|
43
|
+
@raw = snapshot(payload)
|
|
44
|
+
@values = (reader_payload ? snapshot(reader_payload) : @raw).to_h do |key, value|
|
|
45
|
+
definition = fields[key]
|
|
46
|
+
[key, definition ? convert_field(key, value, definition) : value]
|
|
47
|
+
rescue SchemaError
|
|
48
|
+
raise
|
|
49
|
+
rescue ProtocolError
|
|
50
|
+
raise SchemaError.new(field: key, expected_type: definition.fetch("type"), value: value), cause: nil
|
|
51
|
+
end.freeze
|
|
52
|
+
freeze
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def [](key)
|
|
56
|
+
@values[key.to_s]
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def key?(key)
|
|
60
|
+
@raw.key?(key.to_s)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def to_h
|
|
64
|
+
@raw
|
|
65
|
+
end
|
|
66
|
+
alias raw to_h
|
|
67
|
+
|
|
68
|
+
def to_json(*)
|
|
69
|
+
@raw.to_json(*)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Never print payloads: User and AuthToken can contain secrets, and unknown
|
|
73
|
+
# fields may also hold sensitive data.
|
|
74
|
+
def inspect
|
|
75
|
+
"#<#{self.class.name}>"
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
private
|
|
79
|
+
|
|
80
|
+
def convert_field(_key, value, definition)
|
|
81
|
+
convert(value, definition)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def snapshot(value)
|
|
85
|
+
case value
|
|
86
|
+
when Hash
|
|
87
|
+
value.to_h { |key, item| [key.dup.freeze, snapshot(item)] }.freeze
|
|
88
|
+
when Array
|
|
89
|
+
value.map { |item| snapshot(item) }.freeze
|
|
90
|
+
when String
|
|
91
|
+
value.dup.freeze
|
|
92
|
+
else
|
|
93
|
+
value
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def convert(value, definition)
|
|
98
|
+
return nil if value.nil?
|
|
99
|
+
return scalar(value, definition.fetch("type")) unless definition.fetch("repeated", false)
|
|
100
|
+
|
|
101
|
+
raise ProtocolError, "expected an array for a repeated schema field" unless value.is_a?(Array)
|
|
102
|
+
|
|
103
|
+
value.map { |item| scalar(item, definition.fetch("type")) }.freeze
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def numeric_value?(value)
|
|
107
|
+
value.is_a?(Numeric) ||
|
|
108
|
+
(value.is_a?(String) && /\A[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?\z/.match?(value))
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def boolean(value)
|
|
112
|
+
return value if value.equal?(true) || value.equal?(false)
|
|
113
|
+
return value == 1 if value.is_a?(Integer) && [0, 1].include?(value)
|
|
114
|
+
return true if %w[1 true].include?(value)
|
|
115
|
+
return false if %w[0 false].include?(value)
|
|
116
|
+
|
|
117
|
+
raise ProtocolError, "invalid value for schema type boolean"
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def scalar(value, type)
|
|
121
|
+
return Models.const_get(type, false).new(value) if TYPES.key?(type)
|
|
122
|
+
|
|
123
|
+
case type
|
|
124
|
+
when "integer"
|
|
125
|
+
return value if value.is_a?(Integer)
|
|
126
|
+
return Integer(value, 10) if value.is_a?(String) && /\A[+-]?\d+\z/.match?(value)
|
|
127
|
+
when "float"
|
|
128
|
+
if numeric_value?(value)
|
|
129
|
+
number = Float(value)
|
|
130
|
+
return number if number.finite?
|
|
131
|
+
end
|
|
132
|
+
when "boolean"
|
|
133
|
+
return boolean(value)
|
|
134
|
+
when "string", "dateTime"
|
|
135
|
+
return value if value.is_a?(String)
|
|
136
|
+
end
|
|
137
|
+
raise ProtocolError, "invalid value for schema type #{type}"
|
|
138
|
+
rescue ArgumentError, RangeError
|
|
139
|
+
raise ProtocolError, "invalid value for schema type #{type}", cause: nil
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
TYPES.each do |name, fields|
|
|
144
|
+
model = Class.new(Record)
|
|
145
|
+
model.const_set(:FIELDS, fields)
|
|
146
|
+
if fields.dig("items", "repeated")
|
|
147
|
+
model.include(Enumerable)
|
|
148
|
+
# Enumerable#to_h would otherwise shadow Record's lossless JSON view.
|
|
149
|
+
model.define_method(:to_h, Record.instance_method(:to_h))
|
|
150
|
+
model.define_method(:each) do |&block|
|
|
151
|
+
return enum_for(:each) unless block
|
|
152
|
+
|
|
153
|
+
(self["items"] || []).each(&block)
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
fields.each_key do |wire_name|
|
|
157
|
+
reader = wire_name.gsub(/([a-z0-9])([A-Z])/, '\\1_\\2').downcase
|
|
158
|
+
model.define_method(reader) { self[wire_name] }
|
|
159
|
+
end
|
|
160
|
+
const_set(name, model)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
class User
|
|
164
|
+
private
|
|
165
|
+
|
|
166
|
+
# The test tenant returns a JSON boolean for this XSD string field.
|
|
167
|
+
# Keep both observed booleans and legacy strings in their original form.
|
|
168
|
+
def convert_field(key, value, definition)
|
|
169
|
+
return value if key == "canViewProfiles" && (value.equal?(true) || value.equal?(false))
|
|
170
|
+
|
|
171
|
+
super
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
class Response < Models::Record
|
|
177
|
+
def initialize(payload, contract:)
|
|
178
|
+
@parts = contract.fetch("response").keys.freeze
|
|
179
|
+
fields = contract.fetch("response").to_h do |key, type|
|
|
180
|
+
[key, { "type" => type.split(":").last }]
|
|
181
|
+
end
|
|
182
|
+
super(payload, fields: fields, reader_payload: Envelope.parts(payload, contract))
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def key?(key)
|
|
186
|
+
@values.key?(key.to_s)
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# Only declared response parts get convenience readers. Unknown keys remain
|
|
190
|
+
# accessible through [] and the original JSON snapshot through #to_h.
|
|
191
|
+
def method_missing(name, *arguments)
|
|
192
|
+
wire_name = @parts.find { |part| part.gsub(/([a-z0-9])([A-Z])/, '\\1_\\2').downcase == name.to_s }
|
|
193
|
+
return self[wire_name] if wire_name && arguments.empty?
|
|
194
|
+
|
|
195
|
+
super
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def respond_to_missing?(name, include_private = false)
|
|
199
|
+
@parts.any? { |part| part.gsub(/([a-z0-9])([A-Z])/, '\\1_\\2').downcase == name.to_s } || super
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
private
|
|
203
|
+
|
|
204
|
+
def convert_field(key, value, definition)
|
|
205
|
+
# Live project/task deletions return an integer marker, not an XSD boolean.
|
|
206
|
+
# Preserve that result rather than guessing its meaning or coercing it.
|
|
207
|
+
return value if key == "status" && definition["type"] == "boolean" && value.is_a?(Integer) && value >= 0
|
|
208
|
+
|
|
209
|
+
super
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
module FivePM
|
|
2
|
+
class RetryPolicy
|
|
3
|
+
READS = {
|
|
4
|
+
"authentication" => %w[getAuthorizedUser isSsl],
|
|
5
|
+
"projects" => %w[getById getAll getList], "projectsgroups" => %w[getById getAll],
|
|
6
|
+
"tasks" => %w[getById getList], "users" => %w[getById getByEmail getAll getAuthorizedUser],
|
|
7
|
+
"activity" => %w[getById getList getFilesList downloadFile]
|
|
8
|
+
}.transform_values(&:freeze).freeze
|
|
9
|
+
RETRY_STATUSES = [429, 500, 502, 503, 504].freeze
|
|
10
|
+
|
|
11
|
+
def initialize(max_retries: 0, max_delay: 30, sleeper: ->(seconds) { sleep(seconds) }, random: -> { rand })
|
|
12
|
+
unless max_retries.is_a?(Integer) && (0..3).cover?(max_retries) &&
|
|
13
|
+
max_delay.is_a?(Numeric) && max_delay.real? && max_delay.finite? && max_delay.positive?
|
|
14
|
+
raise ConfigurationError, "read retries must be 0..3 and maximum delay positive and finite"
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
@max_retries = max_retries
|
|
18
|
+
@max_delay = max_delay
|
|
19
|
+
@sleeper = sleeper
|
|
20
|
+
@random = random
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def call(service, operation)
|
|
24
|
+
retries = 0
|
|
25
|
+
begin
|
|
26
|
+
yield
|
|
27
|
+
rescue TransportError, HTTPError => e
|
|
28
|
+
allowed = READS.fetch(service, []).include?(operation) && retries < @max_retries
|
|
29
|
+
allowed &&= e.is_a?(TransportError) || RETRY_STATUSES.include?(e.status)
|
|
30
|
+
raise unless allowed
|
|
31
|
+
|
|
32
|
+
# Never retry earlier than Retry-After. HTTP-date and malformed header values
|
|
33
|
+
# cannot be honored without guessing; the transport marks these non-retryable.
|
|
34
|
+
raise if e.is_a?(RateLimitError) && (!e.retryable? || (e.retry_after && e.retry_after > @max_delay))
|
|
35
|
+
|
|
36
|
+
delay = [0.5 * (2**retries) * (0.5 + @random.call), @max_delay].min
|
|
37
|
+
delay = [delay, e.retry_after || 0].max if e.is_a?(RateLimitError)
|
|
38
|
+
@sleeper.call(delay)
|
|
39
|
+
retries += 1
|
|
40
|
+
retry
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def inspect
|
|
45
|
+
"#<FivePM::RetryPolicy>"
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
module FivePM
|
|
2
|
+
class Authentication < Service
|
|
3
|
+
def authorized_user
|
|
4
|
+
call("getAuthorizedUser")
|
|
5
|
+
end
|
|
6
|
+
|
|
7
|
+
def ssl?
|
|
8
|
+
call("isSsl")
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
class Users < Resource
|
|
13
|
+
def find_by_email(email)
|
|
14
|
+
call("getByEmail", email: email)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
class Activity < Resource
|
|
19
|
+
def files(filter: nil, offset: nil, count: nil)
|
|
20
|
+
call("getFilesList", filter: filter, offset: offset, count: count)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Preserve the WSDL FileList representation; never infer content encoding or
|
|
24
|
+
# follow URLs returned inside records. Actual attachment behavior needs a tenant.
|
|
25
|
+
def attach_files(activity_id:, files:)
|
|
26
|
+
files = files.to_h if files.is_a?(Models::Record)
|
|
27
|
+
raise ArgumentError, "files must be a FileList object" unless files.is_a?(Hash)
|
|
28
|
+
|
|
29
|
+
call("attachFiles", activityId: activity_id, files: files)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def delete_file(id)
|
|
33
|
+
call("removeFile", id: id)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def download_file(id)
|
|
37
|
+
@client.download_file(id)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
require "net/http"
|
|
2
|
+
require "json"
|
|
3
|
+
require "ipaddr"
|
|
4
|
+
require "socket"
|
|
5
|
+
require "openssl"
|
|
6
|
+
require "timeout"
|
|
7
|
+
|
|
8
|
+
module FivePM
|
|
9
|
+
class Transport
|
|
10
|
+
# Restrict requests to account hosts. Resolve and pin a public address per request
|
|
11
|
+
# to prevent a DNS change between validation and connection. Proxies are disabled.
|
|
12
|
+
BLOCKED = %w[0.0.0.0/8 10.0.0.0/8 100.64.0.0/10 127.0.0.0/8 169.254.0.0/16
|
|
13
|
+
172.16.0.0/12 192.0.0.0/24 192.0.2.0/24 192.168.0.0/16
|
|
14
|
+
198.18.0.0/15 198.51.100.0/24 203.0.113.0/24 224.0.0.0/4
|
|
15
|
+
240.0.0.0/4].map { |cidr| IPAddr.new(cidr) }.freeze
|
|
16
|
+
|
|
17
|
+
def initialize(host:, open_timeout: 5, read_timeout: 30, write_timeout: 30, max_response_bytes: 5 * 1024 * 1024)
|
|
18
|
+
unless host.is_a?(String) && /\A[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.5pmweb\.com\z/.match?(host)
|
|
19
|
+
raise ConfigurationError, "host must be an account hostname such as example.5pmweb.com"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
unless max_response_bytes.is_a?(Integer) && max_response_bytes.positive?
|
|
23
|
+
raise ConfigurationError, "maximum response bytes must be a positive integer"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
@max_response_bytes = max_response_bytes
|
|
27
|
+
@host = host.dup.freeze
|
|
28
|
+
@timeouts = { open_timeout: open_timeout, read_timeout: read_timeout, write_timeout: write_timeout }
|
|
29
|
+
return if @timeouts.values.all? do |value|
|
|
30
|
+
value.is_a?(Numeric) && value.real? && value.finite? && value.positive?
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
raise ConfigurationError, "timeouts must be positive finite numbers"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def post(service, operation, parameters)
|
|
37
|
+
perform(service, operation, parameters, binary: false)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def download_file(parameters)
|
|
41
|
+
perform("activity", "downloadFile", parameters, binary: true)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def inspect
|
|
45
|
+
"#<FivePM::Transport>"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def perform(service, operation, parameters, binary:)
|
|
51
|
+
raise ArgumentError, "unsupported 5pm operation" unless OPERATIONS.dig(service, operation)
|
|
52
|
+
|
|
53
|
+
raise ArgumentError, "parameters must be an object" unless parameters.is_a?(Hash)
|
|
54
|
+
|
|
55
|
+
Request.validate!(parameters)
|
|
56
|
+
addresses = Timeout.timeout(@timeouts.fetch(:open_timeout)) do
|
|
57
|
+
Addrinfo.getaddrinfo(@host, 443, Socket::AF_INET, :STREAM).map(&:ip_address).uniq
|
|
58
|
+
end
|
|
59
|
+
if addresses.empty? || addresses.any? { |address| BLOCKED.any? { |network| network.include?(address) } }
|
|
60
|
+
raise ConfigurationError, "account must resolve to public IPv4 addresses"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
http = Net::HTTP.new(@host, 443, nil)
|
|
64
|
+
http.ipaddr = addresses.first
|
|
65
|
+
http.use_ssl = true
|
|
66
|
+
http.verify_mode = OpenSSL::SSL::VERIFY_PEER
|
|
67
|
+
http.max_retries = 0
|
|
68
|
+
@timeouts.each { |key, value| http.public_send("#{key}=", value) }
|
|
69
|
+
request = Net::HTTP::Post.new("/api/v2/service/post/#{service}/#{operation}")
|
|
70
|
+
request["Accept"] = "application/json"
|
|
71
|
+
request.set_form_data(flatten(parameters.merge("retType" => "json")))
|
|
72
|
+
body = +"".b
|
|
73
|
+
content_type = nil
|
|
74
|
+
disposition = nil
|
|
75
|
+
http.start do |connection|
|
|
76
|
+
connection.request(request) do |response|
|
|
77
|
+
check_status!(response)
|
|
78
|
+
content_type = response["Content-Type"]
|
|
79
|
+
disposition = response["Content-Disposition"]
|
|
80
|
+
response.read_body do |chunk|
|
|
81
|
+
if body.bytesize + chunk.bytesize > @max_response_bytes
|
|
82
|
+
raise ProtocolError,
|
|
83
|
+
"5pm response exceeded configured size limit"
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
body << chunk
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
if binary
|
|
91
|
+
check_download_error!(body) unless disposition.to_s.match?(/\battachment\b/i)
|
|
92
|
+
Download.new(bytes: body, content_type: content_type)
|
|
93
|
+
else
|
|
94
|
+
APIResult.check!(parse_json(body))
|
|
95
|
+
end
|
|
96
|
+
rescue JSON::ParserError
|
|
97
|
+
raise ProtocolError, "5pm returned invalid JSON", cause: nil
|
|
98
|
+
rescue SocketError, SystemCallError, IOError, Timeout::Error, OpenSSL::SSL::SSLError
|
|
99
|
+
raise TransportError, "5pm connection failed", cause: nil
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def check_download_error!(body)
|
|
103
|
+
payload = parse_json(body)
|
|
104
|
+
APIResult.check!(payload)
|
|
105
|
+
rescue JSON::ParserError
|
|
106
|
+
# Ordinary file bytes need not be JSON. Never decode or follow file URLs.
|
|
107
|
+
nil
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def parse_json(body)
|
|
111
|
+
# Validate only after all chunks arrive: UTF-8 characters may span chunks.
|
|
112
|
+
# Duplicate the buffer so probing a download never changes its binary bytes.
|
|
113
|
+
text = body.dup.force_encoding(Encoding::UTF_8)
|
|
114
|
+
raise JSON::ParserError, "invalid UTF-8" unless text.valid_encoding?
|
|
115
|
+
|
|
116
|
+
JSON.parse(text)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def check_status!(response)
|
|
120
|
+
status = response.code.to_i
|
|
121
|
+
raise AuthenticationError, "5pm authentication failed" if [401, 403].include?(status)
|
|
122
|
+
|
|
123
|
+
if status == 429
|
|
124
|
+
header = response["Retry-After"]
|
|
125
|
+
seconds = header.to_i if header && /\A[0-9]+\z/.match?(header)
|
|
126
|
+
raise RateLimitError.new(retry_after: seconds, retryable: header.nil? || !seconds.nil?)
|
|
127
|
+
end
|
|
128
|
+
raise NotFoundError, status if status == 404
|
|
129
|
+
raise ServerError, status if (500..599).cover?(status)
|
|
130
|
+
raise HTTPError, status unless (200..299).cover?(status)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def flatten(value, prefix = nil, result = [])
|
|
134
|
+
case value
|
|
135
|
+
when Hash
|
|
136
|
+
value.each { |key, item| flatten(item, prefix ? "#{prefix}[#{key}]" : key.to_s, result) }
|
|
137
|
+
when Array
|
|
138
|
+
value.each_with_index { |item, index| flatten(item, "#{prefix}[#{index}]", result) }
|
|
139
|
+
when nil
|
|
140
|
+
# Omit optional values rather than turning them into empty strings.
|
|
141
|
+
else
|
|
142
|
+
result << [prefix, value.to_s]
|
|
143
|
+
end
|
|
144
|
+
result
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|