hook0-client 2.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.
@@ -0,0 +1,359 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "uri"
6
+
7
+ require_relative "version"
8
+
9
+ module Hook0
10
+ # A request the API never answered, and what caused that.
11
+ #
12
+ # The three causes are told apart because only one of them could end differently. A request that
13
+ # got no answer — a connection refused or reset, an attempt out of time, a body that stopped
14
+ # mid-way — says nothing about whether the API acted on it, which is exactly why a send carries an
15
+ # identifier the client chose itself, and why repeating it is safe and worth doing. An answer that
16
+ # crossed a ceiling this client set for itself draws the same answer the second time, and reading
17
+ # it again four times over costs the caller four times as much for the same failure. A URL nothing
18
+ # can be sent to was never sent at all, and a repetition builds the same unusable request, turning
19
+ # a misconfiguration into a message that accuses the network.
20
+ #
21
+ # The names are the ones the shared conformance corpus gives them, so the verdict a client applies
22
+ # and the verdict that corpus writes down are the same words.
23
+ class TransportError < StandardError
24
+ # @return [String] which of the corpus's causes this is
25
+ attr_reader :cause_name
26
+
27
+ # @param detail [String] what went wrong, in the words a caller is given
28
+ # @param cause_name [String] which of the corpus's causes this is
29
+ # @param retryable [Boolean] whether repeating the request could end differently
30
+ def initialize(detail, cause_name, retryable)
31
+ super(detail)
32
+ @cause_name = cause_name
33
+ @retryable = retryable
34
+ end
35
+
36
+ # Whether repeating the request that met this could end differently.
37
+ #
38
+ # @return [Boolean]
39
+ def retryable?
40
+ @retryable
41
+ end
42
+
43
+ # The API was reached for and answered nothing this client could read to its end.
44
+ #
45
+ # @param detail [String]
46
+ # @return [TransportError]
47
+ def self.no_answer(detail)
48
+ new(detail, "no_answer", true)
49
+ end
50
+
51
+ # The API answered, and what it answered crossed a ceiling this client set for itself.
52
+ #
53
+ # @param detail [String]
54
+ # @return [TransportError]
55
+ def self.answer_above_a_bound(detail)
56
+ new(detail, "answer_above_a_bound", false)
57
+ end
58
+
59
+ # There is nowhere to send the request, so nothing was sent.
60
+ #
61
+ # @param detail [String]
62
+ # @return [TransportError]
63
+ def self.unusable_api_url(detail)
64
+ new(detail, "unusable_api_url", false)
65
+ end
66
+ end
67
+
68
+ # How a request reaches the API, and what a server on the other end is not allowed to cost.
69
+ #
70
+ # The transport answers the status and the bytes and knows nothing of what the API declares:
71
+ # reading those bytes is the generated half's job, and deciding whether to send them again is the
72
+ # client's. That is what lets one HTTP implementation serve both the hand-written event path and
73
+ # every generated method — a generated group calls whatever object it is handed, and this is the
74
+ # one this gem ships.
75
+ #
76
+ # Nothing here reaches for a third-party HTTP library. Everything a server controls is bounded:
77
+ # how long one exchange may take, and how many bytes of body are read off the socket.
78
+ class Transport
79
+ # Longest one attempt at reaching the API is given before it is abandoned, in seconds.
80
+ #
81
+ # Ten seconds is far above what ingesting an event takes when the API is healthy, and short
82
+ # enough that a stuck connection does not hold a caller for a noticeable time.
83
+ DEFAULT_REQUEST_TIMEOUT = 10.0
84
+
85
+ # Largest response body read off a socket, in bytes.
86
+ DEFAULT_MAX_RESPONSE_BYTES = 8 * 1024 * 1024
87
+
88
+ # How many header lines an answer may carry before it is refused.
89
+ #
90
+ # `Net::HTTP` bounds neither how many header lines it accepts nor how long one may be: it holds
91
+ # fifty thousand of them, and a single value of eight megabytes, without complaint. So the head
92
+ # of an answer is a server-controlled way to spend a caller's memory, and the ceiling has to be
93
+ # this client's own. Sixty-four is well above what the API sends.
94
+ DEFAULT_MAX_RESPONSE_HEADERS = 64
95
+
96
+ # Longest one header line may be, name and value together, in bytes.
97
+ DEFAULT_MAX_HEADER_BYTES = 64 * 1024
98
+
99
+ # Largest whole head an answer may carry, every line counted together, in bytes.
100
+ #
101
+ # This is the one that bounds what a head costs. A line count and a size per line multiply:
102
+ # sixty-four lines of sixty-four kilobytes each is four megabytes of head, and both of the
103
+ # bounds above admit it. They earn their place by refusing early, on the line that crosses
104
+ # them rather than at the end of the head; this one sets the ceiling.
105
+ #
106
+ # Sixteen kilobytes is what Node enforces by default, and matching it is the point: a lower
107
+ # ceiling would refuse heads another target accepts, and a higher one would not bind there at
108
+ # all, leaving each language a different effective limit.
109
+ DEFAULT_MAX_HEAD_BYTES = 16 * 1024
110
+
111
+ # What a request body says it carries, and what an answer is asked for in.
112
+ JSON_MEDIA_TYPE = "application/json"
113
+
114
+ # Longest each part the User-Agent is composed out of may be, in characters.
115
+ #
116
+ # The runtime and the operating system are described by the platform rather than by this gem, so
117
+ # their length is not this gem's to guarantee: they are cut here so that the header cannot grow
118
+ # with whatever the platform feels like saying. Every part is also stripped of anything the
119
+ # grammar of the header uses as punctuation, so a platform cannot forge a shape it does not have.
120
+ MAX_USER_AGENT_PART_CHARS = 64
121
+ private_constant :MAX_USER_AGENT_PART_CHARS
122
+
123
+ # One part of the User-Agent, with everything the header's own grammar uses taken out of it and
124
+ # cut to MAX_USER_AGENT_PART_CHARS.
125
+ def self.clipped(part)
126
+ part.to_s.each_char.select { |character| character.match?(/[ -~]/) && !"();".include?(character) }
127
+ .first(MAX_USER_AGENT_PART_CHARS).join
128
+ end
129
+ private_class_method :clipped
130
+
131
+ # Which SDK, at which version, on which runtime and operating system, is talking to the API.
132
+ #
133
+ # The version is the constant the gemspec reads rather than a number written down again here:
134
+ # one remembered in two places is one that will disagree with itself the first time it is bumped.
135
+ USER_AGENT = "hook0-client-ruby/#{clipped(Hook0::VERSION)} " \
136
+ "(#{clipped("ruby #{RUBY_VERSION}")}; #{clipped(RUBY_PLATFORM)})".freeze
137
+ private_constant :USER_AGENT
138
+
139
+ # Longest a duration this client states its retry policy in may be, in milliseconds.
140
+ #
141
+ # The three durations of a policy are numbers a caller set, and a header is no place for
142
+ # whatever arithmetic they lead to: about twenty-five days is already past any schedule a send
143
+ # could hold, and cutting to it is what keeps the value an integer whatever was configured.
144
+ MAX_STATED_MILLISECONDS = (2**31) - 1
145
+ private_constant :MAX_STATED_MILLISECONDS
146
+
147
+ # The schemes this transport reaches.
148
+ SCHEMES = %w[http https].freeze
149
+
150
+ # What the standard library reports when the API was not reached at all.
151
+ UNREACHABLE = [
152
+ IOError,
153
+ SocketError,
154
+ SystemCallError,
155
+ Timeout::Error,
156
+ Net::HTTPBadResponse,
157
+ Net::HTTPHeaderSyntaxError,
158
+ Net::ProtocolError,
159
+ OpenSSL::SSL::SSLError
160
+ ].freeze
161
+
162
+ # @param base_url [String] where the API lives, such as https://app.hook0.com/api/v1
163
+ # @param token [String] an authentication token valid for that API
164
+ # @param timeout [Float] how long one attempt is given, in seconds
165
+ # @param max_response_bytes [Integer] the largest answer read off a socket
166
+ # @param max_response_headers [Integer] how many header lines an answer may carry
167
+ # @param max_header_bytes [Integer] the longest one header line may be
168
+ # @param max_head_bytes [Integer] the largest whole head, every line counted together
169
+ # @param retry_policy [RetryPolicy] the policy every request states the client was built with
170
+ def initialize(
171
+ base_url,
172
+ token,
173
+ timeout: DEFAULT_REQUEST_TIMEOUT,
174
+ max_response_bytes: DEFAULT_MAX_RESPONSE_BYTES,
175
+ max_response_headers: DEFAULT_MAX_RESPONSE_HEADERS,
176
+ max_header_bytes: DEFAULT_MAX_HEADER_BYTES,
177
+ max_head_bytes: DEFAULT_MAX_HEAD_BYTES,
178
+ retry_policy: RetryPolicy.new
179
+ )
180
+ @base_url = base_url
181
+ @token = token
182
+ @timeout = timeout
183
+ @max_response_bytes = max_response_bytes
184
+ @max_response_headers = max_response_headers
185
+ @max_header_bytes = max_header_bytes
186
+ @max_head_bytes = max_head_bytes
187
+ @retry_policy = retry_policy
188
+ end
189
+
190
+ # What the API answered, whether or not it answered a success.
191
+ #
192
+ # This is the shape the generated half of this gem reads, which is the status and the bytes. A
193
+ # caller that also needs what the answer carried beside its body — the delay a paced instance
194
+ # names is one — asks {#deliver} for it.
195
+ #
196
+ # @param method [String] the HTTP method the operation is issued under
197
+ # @param path [String] where the request lands, absolute or under the base URL
198
+ # @param query [Array<Array<String>>] the name and value pairs of the query string
199
+ # @param body [Object, nil] what to send as a JSON document, or nothing at all
200
+ # @return [Array(Integer, String)] the status and the body
201
+ # @raise [TransportError] when the API answered nothing at all
202
+ def request(method, path, query = [], body = nil)
203
+ status, _, payload = deliver(method, path, query, body)
204
+ [status, payload]
205
+ end
206
+
207
+ # What the API answered, headers included, whether or not it answered a success.
208
+ #
209
+ # Header names are lowercased and a later value wins over an earlier one under the same name, so
210
+ # a caller reads a header without knowing which case the server wrote it in.
211
+ #
212
+ # @param method [String] the HTTP method the operation is issued under
213
+ # @param path [String] where the request lands, absolute or under the base URL
214
+ # @param query [Array<Array<String>>] the name and value pairs of the query string
215
+ # @param body [Object, nil] what to send as a JSON document, or nothing at all
216
+ # @return [Array(Integer, Hash{String => String}, String)] the status, the headers and the body
217
+ # @raise [TransportError] when the API answered nothing at all
218
+ def deliver(method, path, query = [], body = nil)
219
+ target = resolved(path, query)
220
+ exchange(target, built(method, target, body))
221
+ end
222
+
223
+ private
224
+
225
+ # Where a request lands: a path of its own replaces the base's, a relative one extends it.
226
+ def resolved(path, query)
227
+ begin
228
+ target = URI.join("#{@base_url.to_s.chomp("/")}/", path.to_s)
229
+ rescue URI::Error => e
230
+ raise TransportError.unusable_api_url(e.message)
231
+ end
232
+ unless reachable?(target)
233
+ raise TransportError.unusable_api_url("`#{target}` is not somewhere this transport can send a request")
234
+ end
235
+ return target if query.nil? || query.empty?
236
+
237
+ separator = target.query.nil? || target.query.empty? ? "" : "#{target.query}&"
238
+ target.query = "#{separator}#{URI.encode_www_form(query)}"
239
+ target
240
+ end
241
+
242
+ def reachable?(target)
243
+ SCHEMES.include?(target.scheme) && !target.host.nil? && !target.host.empty?
244
+ end
245
+
246
+ # One request, as Net::HTTP carries it.
247
+ def built(method, target, body)
248
+ request = Net::HTTPGenericRequest.new(method.to_s.upcase, !body.nil?, true, target)
249
+ request["Authorization"] = "Bearer #{@token}"
250
+ request["Accept"] = JSON_MEDIA_TYPE
251
+ # Set rather than left alone: what `Net::HTTP` names itself here is the word `Ruby`, which says
252
+ # nothing about which SDK is talking or which version of it.
253
+ request["User-Agent"] = USER_AGENT
254
+ request["Hook0-Client-Options"] = client_options
255
+ unless body.nil?
256
+ request["Content-Type"] = JSON_MEDIA_TYPE
257
+ request.body = JSON.generate(body)
258
+ end
259
+ request
260
+ end
261
+
262
+ # The retry policy this transport was built to serve, as every request states it.
263
+ #
264
+ # What it states is what the policy holds rather than what one send went on to do: a policy
265
+ # allowing a single attempt still names the delays it holds, and an instance reading
266
+ # `attempts=1` already knows none of them will be waited. It is the one client setting the API
267
+ # can see the consequences of without being told — a burst of identical requests is a client
268
+ # repeating one send, and nothing else on the wire tells that apart from a client in a loop.
269
+ #
270
+ # The grammar is the one `X-Hook0-Signature` already travels under, parts joined by `,` and each
271
+ # cut at its first `=`, so nothing here is a second shape to get wrong.
272
+ def client_options
273
+ "attempts=#{@retry_policy.attempts}," \
274
+ "backoff=#{stated_milliseconds(@retry_policy.initial_backoff_in_force)}," \
275
+ "ceiling=#{stated_milliseconds(@retry_policy.max_backoff_in_force)}," \
276
+ "budget=#{stated_milliseconds(@retry_policy.max_total_delay_in_force)}"
277
+ end
278
+
279
+ # One duration of that policy, in the whole milliseconds it is stated as.
280
+ #
281
+ # What arrives here is already the duration in force, so a value no schedule could be built on
282
+ # has been read as its default before this sees it: what is stated and what is waited are the
283
+ # same number by construction rather than by two rules that agree today. All that is left is the
284
+ # ceiling, which keeps a finite duration nobody meant from deciding how long the header is.
285
+ def stated_milliseconds(seconds)
286
+ (seconds.to_f * 1000).round.clamp(0, MAX_STATED_MILLISECONDS)
287
+ end
288
+
289
+ # One exchange, bounded on every axis a server controls.
290
+ def exchange(target, request)
291
+ http = Net::HTTP.new(target.host, target.port)
292
+ http.use_ssl = target.scheme == "https"
293
+ http.open_timeout = @timeout
294
+ http.read_timeout = @timeout
295
+ http.write_timeout = @timeout
296
+
297
+ # `Net::HTTP#request` answers the response rather than what the block it was given answered,
298
+ # so what the block worked out is kept here instead of returned through it.
299
+ answered = nil
300
+ http.start do |session|
301
+ session.request(request) do |answer|
302
+ answered = [answer.code.to_i, carried(answer), bounded(answer)]
303
+ end
304
+ end
305
+ answered
306
+ rescue TransportError
307
+ raise
308
+ rescue *UNREACHABLE => e
309
+ raise TransportError.no_answer(e.message)
310
+ end
311
+
312
+ # What an answer carried beside its body, under the names a caller looks them up by.
313
+ #
314
+ # Refused before the body is read, so an abusive head costs one pass over what `Net::HTTP` has
315
+ # already buffered rather than that plus a megabyte-scale body on top.
316
+ def carried(answer)
317
+ held = 0
318
+ whole = 0
319
+ answer.each_header.to_h do |name, value|
320
+ held += 1
321
+ if held > @max_response_headers
322
+ raise TransportError.answer_above_a_bound(
323
+ "the API answered more than the #{@max_response_headers} header lines read at most"
324
+ )
325
+ end
326
+
327
+ line = name.to_s.bytesize + value.to_s.bytesize
328
+ if line > @max_header_bytes
329
+ raise TransportError.answer_above_a_bound(
330
+ "the API answered a `#{name.to_s.downcase}` header above the #{@max_header_bytes} bytes read at most"
331
+ )
332
+ end
333
+
334
+ whole += line
335
+ if whole > @max_head_bytes
336
+ raise TransportError.answer_above_a_bound(
337
+ "the API answered a head above the #{@max_head_bytes} bytes read at most"
338
+ )
339
+ end
340
+
341
+ [name.downcase, value.to_s.strip]
342
+ end
343
+ end
344
+
345
+ # The body of an answer, up to what this transport agrees to hold.
346
+ def bounded(answer)
347
+ payload = +""
348
+ answer.read_body do |chunk|
349
+ payload << chunk
350
+ if payload.bytesize > @max_response_bytes
351
+ raise TransportError.answer_above_a_bound(
352
+ "the API answered more than the #{@max_response_bytes} bytes read at most"
353
+ )
354
+ end
355
+ end
356
+ payload
357
+ end
358
+ end
359
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hook0
4
+ # What this gem is released as, which the gemspec reads rather than repeats.
5
+ VERSION = "2.0.0"
6
+ end
data/lib/hook0.rb ADDED
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "hook0/version"
4
+ require_relative "hook0/errors"
5
+ require_relative "hook0/runtime"
6
+ require_relative "hook0/transport"
7
+ require_relative "hook0/signature"
8
+ require_relative "hook0/client"
9
+ require_relative "hook0/generated/all"
10
+
11
+ # The Ruby SDK for Hook0.
12
+ #
13
+ # Two halves live here. This one is hand-written: sending an event, upserting the event types an
14
+ # application uses, and verifying that a webhook came from Hook0 unchanged. The other is generated
15
+ # from the OpenAPI snapshot the API commits — one class per schema it declares, one exception per
16
+ # problem it can report, one method per operation — and is reached through {Hook0::Generated}, over
17
+ # the transport this half exports.
18
+ #
19
+ # The gem reaches the network, verifies signatures and decodes what the API answers with the
20
+ # standard library alone, so installing it never drags a transitive dependency into an application
21
+ # that only wanted to send an event.
22
+ module Hook0
23
+ end
metadata ADDED
@@ -0,0 +1,90 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: hook0-client
3
+ version: !ruby/object:Gem::Version
4
+ version: 2.0.0
5
+ platform: ruby
6
+ authors:
7
+ - David Sferruzza
8
+ - François-Guillaume Ribreau
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 1980-01-02 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: minitest
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '5.25'
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '5.25'
27
+ - !ruby/object:Gem::Dependency
28
+ name: rubocop
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '1.89'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '1.89'
41
+ description: |
42
+ Send events to Hook0, upsert the event types your application uses, verify the signature of an
43
+ incoming webhook, and call every operation the API declares through generated, documented
44
+ classes. Sending is idempotent and retried under bounds the caller sets.
45
+ email:
46
+ - david@hook0.com
47
+ - fg@hook0.com
48
+ executables: []
49
+ extensions: []
50
+ extra_rdoc_files: []
51
+ files:
52
+ - README.md
53
+ - assets/ruby-flow.svg
54
+ - lib/hook0.rb
55
+ - lib/hook0/client.rb
56
+ - lib/hook0/errors.rb
57
+ - lib/hook0/generated/all.rb
58
+ - lib/hook0/generated/api.rb
59
+ - lib/hook0/generated/errors.rb
60
+ - lib/hook0/generated/models.rb
61
+ - lib/hook0/runtime.rb
62
+ - lib/hook0/signature.rb
63
+ - lib/hook0/transport.rb
64
+ - lib/hook0/version.rb
65
+ homepage: https://www.hook0.com/
66
+ licenses:
67
+ - MIT
68
+ metadata:
69
+ homepage_uri: https://www.hook0.com/
70
+ documentation_uri: https://documentation.hook0.com/
71
+ source_code_uri: https://gitlab.com/hook0/hook0
72
+ rubygems_mfa_required: 'true'
73
+ rdoc_options: []
74
+ require_paths:
75
+ - lib
76
+ required_ruby_version: !ruby/object:Gem::Requirement
77
+ requirements:
78
+ - - ">="
79
+ - !ruby/object:Gem::Version
80
+ version: '3.1'
81
+ required_rubygems_version: !ruby/object:Gem::Requirement
82
+ requirements:
83
+ - - ">="
84
+ - !ruby/object:Gem::Version
85
+ version: '0'
86
+ requirements: []
87
+ rubygems_version: 3.6.9
88
+ specification_version: 4
89
+ summary: Ruby SDK for Hook0, open-source Webhooks as a service for SaaS
90
+ test_files: []