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