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.
- checksums.yaml +7 -0
- data/README.md +235 -0
- data/assets/ruby-flow.svg +153 -0
- data/lib/hook0/client.rb +691 -0
- data/lib/hook0/errors.rb +78 -0
- data/lib/hook0/generated/all.rb +7 -0
- data/lib/hook0/generated/api.rb +1097 -0
- data/lib/hook0/generated/errors.rb +235 -0
- data/lib/hook0/generated/models.rb +3093 -0
- data/lib/hook0/runtime.rb +299 -0
- data/lib/hook0/signature.rb +294 -0
- data/lib/hook0/transport.rb +359 -0
- data/lib/hook0/version.rb +6 -0
- data/lib/hook0.rb +23 -0
- metadata +90 -0
|
@@ -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
|
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: []
|